Compare commits
14 Commits
43adf1a7c4
...
2b1ec63c00
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b1ec63c00 | |||
| 6c74bef8da | |||
| 73cdf5ac23 | |||
| a371039ee6 | |||
| 91d11abe90 | |||
| 4b85b35cf8 | |||
| c99052529d | |||
| bd1fa14f20 | |||
| 1aa90f5acf | |||
| 04ea889d75 | |||
| 3c4a11eca8 | |||
| 85f2bbe78c | |||
| 18ee2b9b2c | |||
| 5a78b7b392 |
11
.env
11
.env
@@ -0,0 +1,11 @@
|
||||
# ---------- 路径配置 ----------
|
||||
VECTORSTORE_PATH=/data/vectorstore
|
||||
STATE_FILE=/data/state.json
|
||||
SCHEMA_FILE=/data/schema.json
|
||||
PRESETS_FILE=/data/presets.json
|
||||
REGEX_FILE=/data/regex_rules.json
|
||||
|
||||
# ---------- 服务地址 ----------
|
||||
COMFYUI_API_URL=http://comfyui:8188
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=8501
|
||||
1
.idea/.gitignore
generated
vendored
1
.idea/.gitignore
generated
vendored
@@ -6,3 +6,4 @@
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
.env
|
||||
|
||||
4
.idea/llm-workflow-engine.iml
generated
4
.idea/llm-workflow-engine.iml
generated
@@ -1,7 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.9 (pythonProject1)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
|
||||
@@ -4,15 +4,20 @@ FROM python:3.11-slim
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖文件并安装
|
||||
# 复制依赖文件
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 安装依赖
|
||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
|
||||
# 复制所有代码
|
||||
COPY app/ ./app/
|
||||
# 修改点:把当前目录(即 backend/)的内容复制到 /app/backend/ 下
|
||||
# 这样镜像内的结构就是 /app/backend/api/route.py
|
||||
COPY . /app/backend/
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000
|
||||
|
||||
# 启动命令
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# 修改点:路径改为 backend.api.route (对应 /app/backend/api/route.py)
|
||||
CMD ["uvicorn", "backend.api.route:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
BIN
backend/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/api/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/route.cpython-311.pyc
Normal file
BIN
backend/api/__pycache__/route.cpython-311.pyc
Normal file
Binary file not shown.
18
backend/api/route.py
Normal file
18
backend/api/route.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from fastapi import APIRouter
|
||||
from ..core.items import ChatRequest
|
||||
from ..tools.get_all_role_and_chat import get_all_role_and_chat
|
||||
from ..tools.save_input_to_json import save_input_to_json
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 1. 将输入内容持久化存储到本地jsonl方便前端读
|
||||
@router.post("/generate_reply")
|
||||
async def save_chat_to_json(chat_request: ChatRequest):
|
||||
# 调用实际的保存函数
|
||||
return await save_input_to_json(chat_request)
|
||||
|
||||
# 2. 从本地jsonl中读取历史对话
|
||||
@router.get("/tool_bar/get_all_role_and_chat")
|
||||
def get_all_role_and_chat_endpoint():
|
||||
# 正确调用函数并返回结果
|
||||
return get_all_role_and_chat()
|
||||
@@ -1,46 +0,0 @@
|
||||
import base64
|
||||
from typing import Any, Dict
|
||||
from IPython.core.magic_arguments import defaults
|
||||
from .. import nodes
|
||||
|
||||
class StartNode():
|
||||
name = "开始节点"
|
||||
inputs = {
|
||||
"user_input": "string", # 用户输入文本
|
||||
"stream": "boolean", # 是否流式输出
|
||||
"img_switch": "boolean", # 是否处理图片
|
||||
"table_switch": "boolean", # 是否处理表格
|
||||
"role_name": "string", # 角色名称
|
||||
"chat_name": "string" # 会话名称
|
||||
}
|
||||
|
||||
async def run(self, text: str = None, image: bytes = None, **kwargs) -> Dict[str, Any]:
|
||||
# 查空:文本不能为空字符串
|
||||
if not text or text.strip() == "":
|
||||
raise ValueError("文本输入不能为空")
|
||||
|
||||
# 查空:图片数据不能为空
|
||||
if image is None or len(image) == 0:
|
||||
raise ValueError("图片输入不能为空")
|
||||
|
||||
# 将图片字节转换为 Base64 字符串,便于在节点间传递
|
||||
image_base64 = base64.b64encode(image).decode('utf-8')
|
||||
|
||||
return {
|
||||
"text": text,
|
||||
"image": image_base64
|
||||
}
|
||||
|
||||
async def run(is_user,floor_number,mes: str = None, stream: bool = False, img_switch: bool = False,name = "default",
|
||||
table_switch: bool = False, role_name: str = None, chat_name: str = None,preset: str = None):
|
||||
# 将输入内容持久化存储到本地json方便前端读
|
||||
nodes.save_input_to_json(mes=mes, role_name=role_name, chat_name=chat_name, name=name, is_user=is_user, floor_number=floor_number)
|
||||
# 对上一条输入内容(已确定不变的内容)调用向量化,根据role和chat嵌入到对应本地数据库
|
||||
embed_input(user_input, role_name, chat_name)
|
||||
# 根据role和chat去读取绑定的世界书
|
||||
# 读取预设,进行拼接
|
||||
# 调用模型,返回结果
|
||||
# 将结果持久化存储到本地json方便前端读(用JSONL)
|
||||
# 如果img_switch是开的,那么异步调用生图,并存储到目标文件夹里
|
||||
# 如果table_switch是开的,那么异步调用表格生成,并存储到目标文件夹里
|
||||
# 将结果返回给前端
|
||||
0
backend/core/__init__.py
Normal file
0
backend/core/__init__.py
Normal file
BIN
backend/core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/core/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/core/__pycache__/config.cpython-311.pyc
Normal file
BIN
backend/core/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/core/__pycache__/items.cpython-311.pyc
Normal file
BIN
backend/core/__pycache__/items.cpython-311.pyc
Normal file
Binary file not shown.
@@ -7,8 +7,7 @@ from dotenv import load_dotenv
|
||||
# __file__ 指向本文件的绝对路径
|
||||
# .parent 指向 backend/ 目录
|
||||
# .parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
# 2. 加载 .env 文件
|
||||
# 假设 .env 文件位于项目根目录下
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
@@ -38,8 +37,12 @@ class Settings:
|
||||
REGEX_FILE = DATA_PATH / "regex_rules.json"
|
||||
VECTORSTORE_PATH = DATA_PATH / "vectorstore"
|
||||
|
||||
# ... 其他配置 ...
|
||||
|
||||
|
||||
# 实例化配置对象
|
||||
settings = Settings()
|
||||
|
||||
if __name__ == '__main__':
|
||||
settings = Settings()
|
||||
print(f"项目根目录: {settings.BASE_PATH}")
|
||||
print(f"数据目录: {settings.DATA_PATH}")
|
||||
print(f"聊天目录: {settings.DATA_PATH / 'chat'}")
|
||||
|
||||
|
||||
24
backend/core/items.py
Normal file
24
backend/core/items.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
# 1. 定义请求体模型
|
||||
class ChatRequest(BaseModel):
|
||||
# --- 基础信息 ---
|
||||
mes: str = Field(..., description="用户输入的消息内容")
|
||||
is_user: bool = Field(..., description="标识发送者是否为用户(True为用户,False为AI)")
|
||||
floor_number: int = Field(..., description="当前对话的楼层号,用于判断是否为重试(Regenerate)请求")
|
||||
|
||||
# --- 身份与会话 ---
|
||||
name: str = Field("default", description="发送者的显示名称,默认为'default'")
|
||||
role_name: Optional[str] = Field(None, description="当前绑定的角色名称")
|
||||
chat_name: Optional[str] = Field(None, description="当前会话的标识名称")
|
||||
preset: Optional[str] = Field(None, description="预设的提示词或系统指令")
|
||||
|
||||
# --- 功能开关 ---
|
||||
stream: bool = Field(False, description="是否开启流式输出")
|
||||
img_switch: bool = Field(False, description="是否开启图片生成功能")
|
||||
table_switch: bool = Field(False, description="是否开启表格生成功能")
|
||||
|
||||
# 其他可能需要的参数,比如历史记录,可以在这里加
|
||||
# history: Optional[List[Dict]] = None
|
||||
158
backend/core/models/chat_history.py
Normal file
158
backend/core/models/chat_history.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, Field
|
||||
import json
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""消息类,代表JSONL文件中的一行消息内容"""
|
||||
name: str = Field(..., description="发言者名称")
|
||||
is_user: bool = Field(..., description="是否为用户消息(true=用户,false=AI/角色)")
|
||||
is_system: bool = Field(False, description="是否为系统消息(系统消息在文本导出时会被排除)")
|
||||
send_date: str = Field(default_factory=lambda: str(int(datetime.now().timestamp() * 1000)),
|
||||
description="发送时间戳(Unix毫秒数)")
|
||||
mes: str = Field(..., description="消息正文内容")
|
||||
extra: Dict[str, Any] = Field(default_factory=dict, description="额外信息,包含推理内容、API、模型等")
|
||||
swipes: List[str] = Field(default_factory=list, description="备选回复列表")
|
||||
swipe_id: int = Field(0, description="当前选中的备选索引(0=第一条)")
|
||||
swipe_info: List[Dict[str, Any]] = Field(default_factory=list, description="每个备选回复的生成信息")
|
||||
title: str = Field("", description="消息标题,用于消息摘要或分支标记")
|
||||
force_avatar: Optional[str] = Field(None, description="强制头像路径")
|
||||
variables: List[Any] = Field(default_factory=list, description="变量值数组")
|
||||
variables_initialized: List[bool] = Field(default_factory=list, description="变量初始化状态数组")
|
||||
is_ejs_processed: List[bool] = Field(default_factory=list, description="EJS模板处理状态数组")
|
||||
gen_started: Optional[str] = Field(None, description="生成开始时间戳(Unix毫秒数)")
|
||||
gen_finished: Optional[str] = Field(None, description="生成结束时间戳(Unix毫秒数)")
|
||||
|
||||
|
||||
class ChatMetadata(BaseModel):
|
||||
"""聊天元数据模型,代表JSONL文件的第一行内容"""
|
||||
integrity: str = Field("", description="完整性校验哈希值(UUID格式)")
|
||||
chat_id_hash: str = Field("", description="聊天ID哈希值")
|
||||
note_prompt: str = Field("", description="笔记提示词")
|
||||
note_interval: int = Field(0, description="笔记间隔(整数)")
|
||||
note_position: int = Field(0, description="笔记位置(整数)")
|
||||
note_depth: int = Field(0, description="笔记深度(整数)")
|
||||
note_role: int = Field(0, description="笔记角色(整数,0=用户,1=助手)")
|
||||
extensions: Dict[str, Any] = Field(default_factory=dict, description="扩展信息,如LittleWhiteBox等")
|
||||
timedWorldInfo: Dict[str, Any] = Field(default_factory=dict, description="定时世界信息")
|
||||
variables: Dict[str, Any] = Field(default_factory=dict, description="变量字典")
|
||||
tainted: bool = Field(False, description="是否被污染")
|
||||
lastInContextMessageId: int = Field(-1, description="上下文中最后一条消息的ID")
|
||||
|
||||
|
||||
class ChatFile(BaseModel):
|
||||
"""聊天文件类,包含元数据和消息列表"""
|
||||
user_name: str = Field("User", description="用户名")
|
||||
character_name: str = Field("Assistant", description="角色名")
|
||||
create_date: str = Field(default_factory=lambda: datetime.now().isoformat(), description="创建日期(ISO 8601格式)")
|
||||
chat_metadata: ChatMetadata = Field(default_factory=ChatMetadata, description="聊天元数据")
|
||||
messages: List[Message] = Field(default_factory=list, description="消息列表")
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
def load_chat_file_data(chat_name: str, role_name: str, base_path: Path = None) -> Dict[str, Any]:
|
||||
"""
|
||||
从文件系统加载聊天原始数据
|
||||
|
||||
参数:
|
||||
chat_name: 聊天名称
|
||||
role_name: 角色名称
|
||||
base_path: 基础路径,默认为项目数据目录
|
||||
|
||||
返回:
|
||||
dict: 包含元数据和消息列表的原始数据字典
|
||||
"""
|
||||
# 设置默认基础路径
|
||||
if base_path is None:
|
||||
from backend.core.config import settings
|
||||
base_path = settings.DATA_PATH / "chat"
|
||||
|
||||
# 构建文件路径
|
||||
file_path = base_path / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
# 检查文件是否存在
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
|
||||
|
||||
# 读取文件内容
|
||||
result = {
|
||||
"user_name": "User",
|
||||
"character_name": role_name,
|
||||
"create_date": datetime.now().isoformat(),
|
||||
"chat_metadata": {},
|
||||
"messages": []
|
||||
}
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
try:
|
||||
# 解析JSON行
|
||||
line_data = json.loads(line.strip())
|
||||
|
||||
# 添加到消息列表
|
||||
result["messages"].append(line_data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def create_chat_file_from_data(data: Dict[str, Any]) -> ChatFile:
|
||||
"""
|
||||
从原始数据创建ChatFile对象
|
||||
|
||||
参数:
|
||||
data: 包含元数据和消息列表的原始数据字典
|
||||
|
||||
返回:
|
||||
ChatFile: 创建的聊天文件对象
|
||||
"""
|
||||
# 提取元数据
|
||||
metadata = data.get("chat_metadata", {})
|
||||
chat_metadata = ChatMetadata(**metadata)
|
||||
|
||||
# 处理消息列表
|
||||
messages = []
|
||||
for msg_data in data.get("messages", []):
|
||||
# 转换为Message对象
|
||||
message = Message(
|
||||
name=msg_data.get('name', ''),
|
||||
is_user=msg_data.get('is_user', False),
|
||||
send_date=msg_data.get('send_date', ''),
|
||||
mes=msg_data.get('content', ''),
|
||||
swipes=msg_data.get('swipes', []),
|
||||
swipe_id=msg_data.get('swipes_id', 0)
|
||||
)
|
||||
messages.append(message)
|
||||
|
||||
# 创建并返回ChatFile对象
|
||||
return ChatFile(
|
||||
user_name=data.get("user_name", "User"),
|
||||
character_name=data.get("character_name", "Assistant"),
|
||||
create_date=data.get("create_date", datetime.now().isoformat()),
|
||||
chat_metadata=chat_metadata,
|
||||
messages=messages
|
||||
)
|
||||
|
||||
|
||||
def load_chat_file(chat_name: str, role_name: str, base_path: Path = None) -> ChatFile:
|
||||
"""
|
||||
从文件系统加载聊天数据并创建ChatFile对象
|
||||
|
||||
参数:
|
||||
chat_name: 聊天名称
|
||||
role_name: 角色名称
|
||||
base_path: 基础路径,默认为项目数据目录
|
||||
|
||||
返回:
|
||||
ChatFile: 加载的聊天文件对象
|
||||
"""
|
||||
# 加载原始数据
|
||||
data = load_chat_file_data(chat_name, role_name, base_path)
|
||||
|
||||
# 创建ChatFile对象
|
||||
return create_chat_file_from_data(data)
|
||||
12
backend/main.py
Normal file
12
backend/main.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# backend/app/main.py
|
||||
from fastapi import FastAPI
|
||||
from .api.route import router
|
||||
|
||||
app = FastAPI(title="LLM Workflow Engine")
|
||||
|
||||
# 注册路由
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
0
backend/nodes/__init__.py
Normal file
0
backend/nodes/__init__.py
Normal file
0
backend/tools/__init__.py
Normal file
0
backend/tools/__init__.py
Normal file
BIN
backend/tools/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/tools/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/tools/__pycache__/get_all_role_and_chat.cpython-311.pyc
Normal file
BIN
backend/tools/__pycache__/get_all_role_and_chat.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/tools/__pycache__/save_input_to_json.cpython-311.pyc
Normal file
BIN
backend/tools/__pycache__/save_input_to_json.cpython-311.pyc
Normal file
Binary file not shown.
47
backend/tools/get_all_role_and_chat.py
Normal file
47
backend/tools/get_all_role_and_chat.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from ..core import config
|
||||
from typing import Dict, List
|
||||
|
||||
# 使用配置中的 DATA_PATH 并添加 "chat" 子目录
|
||||
ROOT_DIR = config.settings.DATA_PATH / "chat"
|
||||
|
||||
def get_all_role_and_chat() -> Dict[str, List[str]]:
|
||||
"""
|
||||
读取配置目录下的所有子文件夹,并收集每个子文件夹中的 JSONL 文件
|
||||
|
||||
返回:
|
||||
dict: 字典结构,键是文件夹名称,值是该文件夹中的 JSONL 文件列表(仅文件名,无路径和后缀)
|
||||
"""
|
||||
result = {}
|
||||
|
||||
# 确保目标目录存在
|
||||
if not ROOT_DIR.exists():
|
||||
print(f"警告: 目录 {ROOT_DIR} 不存在")
|
||||
return result
|
||||
|
||||
# 打印根目录路径和内容(调试用)
|
||||
print(f"正在扫描目录: {ROOT_DIR}")
|
||||
print(f"根目录内容: {list(ROOT_DIR.iterdir())}")
|
||||
|
||||
# 遍历根目录下的所有条目
|
||||
for entry in ROOT_DIR.iterdir():
|
||||
try:
|
||||
# 只处理文件夹
|
||||
if entry.is_dir():
|
||||
print(f"处理文件夹: {entry.name}") # 调试信息
|
||||
jsonl_files = []
|
||||
|
||||
# 遍历子文件夹中的所有文件
|
||||
for file in entry.iterdir():
|
||||
if file.is_file() and file.suffix == '.jsonl':
|
||||
# 使用 file.stem 获取不带后缀的文件名
|
||||
jsonl_files.append(file.stem)
|
||||
print(f" 找到文件: {file.name}") # 调试信息
|
||||
|
||||
# 如果该文件夹中有 JSONL 文件,则添加到结果中
|
||||
if jsonl_files:
|
||||
result[entry.name] = jsonl_files
|
||||
except Exception as e:
|
||||
print(f"处理文件夹 {entry.name} 时出错: {str(e)}")
|
||||
continue
|
||||
|
||||
return result
|
||||
@@ -1,33 +1,37 @@
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime
|
||||
import config as cfg
|
||||
from backend.core import config as cfg
|
||||
from pathlib import Path
|
||||
from ..core.items import ChatRequest
|
||||
|
||||
# 假设 ChatRequest 定义在这里或者从其他地方导入
|
||||
# from backend.app.core.items import ChatRequest
|
||||
|
||||
def save_input_to_json(
|
||||
mes: str,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
name: str,
|
||||
is_user: bool,
|
||||
floor_number: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
async def save_input_to_json(chat_request: ChatRequest):
|
||||
"""
|
||||
保存消息到JSONL文件或处理重roll请求
|
||||
|
||||
参数:
|
||||
mes: 消息内容
|
||||
role_name: 角色名称
|
||||
chat_name: 对话名称
|
||||
name: 发送者名称
|
||||
is_user: 是否为用户消息
|
||||
floor_number: 楼层号(对话中的第几次回复),用于判断是否为重roll请求
|
||||
|
||||
返回:
|
||||
更新后的消息对象
|
||||
chat_request: 包含消息详情的请求对象
|
||||
"""
|
||||
# 1. 从对象中提取属性
|
||||
mes = chat_request.mes
|
||||
role_name = chat_request.role_name
|
||||
chat_name = chat_request.chat_name
|
||||
name = chat_request.name
|
||||
is_user = chat_request.is_user
|
||||
floor_number = chat_request.floor_number
|
||||
# stream, img_switch, table_switch 等虽然在这个函数逻辑中没用到,
|
||||
# 但如果 ChatRequest 中有,也可以提取出来备用
|
||||
# stream = chat_request.stream
|
||||
# ...
|
||||
|
||||
config = cfg.settings
|
||||
# 注意:这里要确保 role_name 和 chat_name 不为 None,否则路径拼接会报错
|
||||
# 建议在函数入口处增加校验,或者在 Pydantic 模型中设置为必填项
|
||||
if not role_name or not chat_name:
|
||||
raise ValueError("role_name and chat_name cannot be empty")
|
||||
|
||||
file_path = config.BASE_PATH / "data" / "chat" / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
# 确保目录存在
|
||||
@@ -115,31 +119,34 @@ def save_input_to_json(
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 测试普通消息保存
|
||||
# save_input_to_json(
|
||||
# mes="你好",
|
||||
# role_name="test",
|
||||
# chat_name="111",
|
||||
# name="用户",
|
||||
# is_user=True,
|
||||
# floor_number=0
|
||||
# )
|
||||
#
|
||||
# save_input_to_json(
|
||||
# mes="你好,我是AI助手",
|
||||
# role_name="test",
|
||||
# chat_name="111",
|
||||
# name="AI",
|
||||
# is_user=False,
|
||||
# floor_number=1
|
||||
# )
|
||||
# 注意:为了在本地运行测试,你需要手动构造一个 ChatRequest 对象
|
||||
# 或者临时修改函数签名以便直接传参测试
|
||||
|
||||
# 示例:假设 ChatRequest 是一个简单的类或 Pydantic 模型
|
||||
class MockChatRequest:
|
||||
def __init__(self, **kwargs):
|
||||
self.mes = kwargs.get('mes')
|
||||
self.role_name = kwargs.get('role_name')
|
||||
self.chat_name = kwargs.get('chat_name')
|
||||
self.name = kwargs.get('name')
|
||||
self.is_user = kwargs.get('is_user')
|
||||
self.floor_number = kwargs.get('floor_number')
|
||||
|
||||
|
||||
# 测试重roll最后一条AI消息
|
||||
save_input_to_json(
|
||||
import asyncio
|
||||
|
||||
|
||||
async def test():
|
||||
req = MockChatRequest(
|
||||
mes="这是重roll后的新回复2",
|
||||
role_name="test",
|
||||
chat_name="111",
|
||||
name="AI",
|
||||
is_user=False,
|
||||
floor_number=2 # 与当前楼层号相同,表示重roll
|
||||
floor_number=2
|
||||
)
|
||||
await save_input_to_json(req)
|
||||
|
||||
|
||||
asyncio.run(test())
|
||||
0
backend/workflows/__init__.py
Normal file
0
backend/workflows/__init__.py
Normal file
201
backend/workflows/main_llm_workflow.py
Normal file
201
backend/workflows/main_llm_workflow.py
Normal file
@@ -0,0 +1,201 @@
|
||||
# backend/app/workflows/llm_workflow.py
|
||||
from typing import Dict, Any, List, Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class WorkflowStatus(Enum):
|
||||
"""工作流状态枚举"""
|
||||
INITIALIZED = "initialized"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
PAUSED = "paused"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowContext:
|
||||
"""工作流上下文"""
|
||||
data: Dict[str, Any]
|
||||
status: WorkflowStatus = WorkflowStatus.INITIALIZED
|
||||
metadata: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
|
||||
|
||||
class WorkflowNode:
|
||||
"""工作流节点声明"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
handler: Callable,
|
||||
enabled: bool = True,
|
||||
config: Dict[str, Any] = None
|
||||
):
|
||||
self.name = name # 节点的唯一标识符,用于区分不同的节点。
|
||||
self.handler = handler # 一个可调用对象(函数或方法),这是节点实际执行的处理逻辑。
|
||||
self.enabled = enabled # 布尔值,控制节点是否启用。默认为 True,如果设置为 False,节点将被跳过。
|
||||
self.config = config or {} # 一个字典,用于存储节点的配置信息。默认为空字典。
|
||||
self.next_nodes: List['WorkflowNode'] = [] # 一个节点列表,用于指定当前节点执行完成后应跳转到的下一个节点。默认为空列表,可能指向多分支。
|
||||
|
||||
def execute(self, context: WorkflowContext) -> WorkflowContext:
|
||||
"""执行节点处理"""
|
||||
if not self.enabled:
|
||||
return context
|
||||
|
||||
try:
|
||||
context = self.handler(context, self.config)
|
||||
return context
|
||||
except Exception as e:
|
||||
context.status = WorkflowStatus.FAILED
|
||||
context.metadata["error"] = str(e)
|
||||
raise
|
||||
|
||||
|
||||
class LLMWorkflow:
|
||||
"""LLM工作流声明"""
|
||||
|
||||
def __init__(self):
|
||||
self.nodes: List[WorkflowNode] = []
|
||||
self._initialize_workflow()
|
||||
|
||||
def _initialize_workflow(self):
|
||||
"""初始化工作流节点(仅声明,不实现)"""
|
||||
# 输入节点
|
||||
input_node = WorkflowNode(
|
||||
name="input",
|
||||
handler=self._input_handler
|
||||
)
|
||||
|
||||
# 输入预处理节点(可开关)
|
||||
preprocessing_node = WorkflowNode(
|
||||
name="preprocessing",
|
||||
handler=self._preprocessing_handler,
|
||||
enabled=False
|
||||
)
|
||||
|
||||
# RAG处理节点
|
||||
rag_node = WorkflowNode(
|
||||
name="rag",
|
||||
handler=self._rag_handler
|
||||
)
|
||||
|
||||
# 提示词组装节点
|
||||
prompt_assembly_node = WorkflowNode(
|
||||
name="prompt_assembly",
|
||||
handler=self._prompt_assembly_handler
|
||||
)
|
||||
|
||||
# LLM请求节点
|
||||
llm_request_node = WorkflowNode(
|
||||
name="llm_request",
|
||||
handler=self._llm_request_handler
|
||||
)
|
||||
|
||||
# 图像生成节点(可开关)
|
||||
image_generation_node = WorkflowNode(
|
||||
name="image_generation",
|
||||
handler=self._image_generation_handler,
|
||||
enabled=False
|
||||
)
|
||||
|
||||
# 动态表格更新节点(可开关)
|
||||
dynamic_table_node = WorkflowNode(
|
||||
name="dynamic_table",
|
||||
handler=self._dynamic_table_handler,
|
||||
enabled=False
|
||||
)
|
||||
|
||||
# 输出过滤节点
|
||||
output_filter_node = WorkflowNode(
|
||||
name="output_filter",
|
||||
handler=self._output_filter_handler
|
||||
)
|
||||
|
||||
# 输出节点
|
||||
output_node = WorkflowNode(
|
||||
name="output",
|
||||
handler=self._output_handler
|
||||
)
|
||||
|
||||
# 设置节点顺序(构建工作流)
|
||||
self.nodes = [
|
||||
input_node,
|
||||
preprocessing_node,
|
||||
rag_node,
|
||||
prompt_assembly_node,
|
||||
llm_request_node,
|
||||
image_generation_node,
|
||||
dynamic_table_node,
|
||||
output_filter_node,
|
||||
output_node
|
||||
]
|
||||
|
||||
def execute(self, context: WorkflowContext) -> WorkflowContext:
|
||||
"""执行工作流"""
|
||||
context.status = WorkflowStatus.RUNNING
|
||||
|
||||
for node in self.nodes:
|
||||
try:
|
||||
context = node.execute(context)
|
||||
|
||||
# 如果工作流失败,停止执行
|
||||
if context.status == WorkflowStatus.FAILED:
|
||||
break
|
||||
except Exception as e:
|
||||
context.status = WorkflowStatus.FAILED
|
||||
context.metadata["error"] = str(e)
|
||||
break
|
||||
|
||||
if context.status != WorkflowStatus.FAILED:
|
||||
context.status = WorkflowStatus.COMPLETED
|
||||
|
||||
return context
|
||||
|
||||
def enable_node(self, node_name: str, enabled: bool = True):
|
||||
"""启用或禁用特定节点"""
|
||||
for node in self.nodes:
|
||||
if node.name == node_name:
|
||||
node.enabled = enabled
|
||||
return True
|
||||
return False
|
||||
|
||||
# 以下是节点处理函数声明(仅声明,不实现)
|
||||
def _input_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""输入节点处理函数"""
|
||||
pass
|
||||
|
||||
def _preprocessing_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""输入预处理节点处理函数"""
|
||||
pass
|
||||
|
||||
def _rag_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""RAG处理节点处理函数"""
|
||||
pass
|
||||
|
||||
def _prompt_assembly_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""提示词组装节点处理函数"""
|
||||
pass
|
||||
|
||||
def _llm_request_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""LLM请求节点处理函数"""
|
||||
pass
|
||||
|
||||
def _image_generation_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""图像生成节点处理函数"""
|
||||
pass
|
||||
|
||||
def _dynamic_table_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""动态表格更新节点处理函数"""
|
||||
pass
|
||||
|
||||
def _output_filter_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""输出过滤节点处理函数"""
|
||||
pass
|
||||
|
||||
def _output_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""输出节点处理函数"""
|
||||
pass
|
||||
0
data/chat/test/111.json
Normal file
0
data/chat/test/111.json
Normal file
0
data/chat/test/12331212.jsonl
Normal file
0
data/chat/test/12331212.jsonl
Normal file
0
data/chat/test1/222.jsonl
Normal file
0
data/chat/test1/222.jsonl
Normal file
@@ -1,26 +1,34 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 后端服务
|
||||
backend:
|
||||
build: ./backend # 使用 backend 目录下的 Dockerfile 构建
|
||||
build: ./backend
|
||||
command: uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
ports:
|
||||
- "8000:8000" # 映射端口:主机8000 -> 容器8000
|
||||
- "3001:8000"
|
||||
volumes:
|
||||
- ./data:/data # 挂载数据目录(持久化)
|
||||
- ./outputs:/outputs # 挂载输出目录
|
||||
- ./backend:/app/backend
|
||||
- ./data:/app/data
|
||||
- ./outputs:/outputs
|
||||
environment:
|
||||
- DATA_PATH=/data
|
||||
- OUTPUT_PATH=/outputs
|
||||
restart: always
|
||||
- PYTHONUNBUFFERED=1
|
||||
restart: unless-stopped
|
||||
|
||||
# 前端服务
|
||||
frontend:
|
||||
build: ./frontend # 使用 frontend 目录下的 Dockerfile 构建
|
||||
build:
|
||||
context: ./frontend-react
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8501:8501" # 映射端口:主机8501 -> 容器8501
|
||||
- "3000:5173"
|
||||
volumes:
|
||||
- ./frontend-react:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- BACKEND_URL=http://backend:8000 # 后端内部地址
|
||||
# 如果不需要特定环境变量,可以完全移除 environment 部分
|
||||
# 或者添加有效的环境变量,例如:
|
||||
- NODE_ENV=development
|
||||
- VITE_BACKEND_URL=http://backend:8000
|
||||
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||
depends_on:
|
||||
- backend # 等后端启动后再启动前端
|
||||
restart: always
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
26
frontend-react/Dockerfile
Normal file
26
frontend-react/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# 使用 Node.js 18 Alpine 镜像作为基础
|
||||
# 必须明确指定 node 版本,否则可能默认为空或 python 镜像
|
||||
FROM node:18-alpine
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置 npm 镜像源(可选,国内推荐使用,加速依赖下载)
|
||||
RUN npm config set registry https://registry.npmmirror.com/
|
||||
|
||||
# 复制 package.json 和 package-lock.json
|
||||
# 利用 Docker 缓存层,只有依赖变更时才重新安装
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
|
||||
# 复制源代码到容器
|
||||
COPY . .
|
||||
|
||||
# 暴露 Vite 默认端口 5173
|
||||
EXPOSE 5173
|
||||
|
||||
# 启动 Vite 开发服务器
|
||||
# --host 0.0.0.0 允许外部访问(Docker 容器外)
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
15
frontend-react/index.html
Normal file
15
frontend-react/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- React 应用将挂载到这个 div 上 -->
|
||||
<div id="root"></div>
|
||||
<!-- Vite 会自动注入这里的脚本标签 -->
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4667
frontend-react/package-lock.json
generated
Normal file
4667
frontend-react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend-react/package.json
Normal file
32
frontend-react/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "ai-chat-frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.38",
|
||||
"marked": "^17.0.4",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"mermaid": "^11.13.0",
|
||||
"react": "^18.3.1",
|
||||
"react-copy-to-clipboard": "^5.1.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
59
frontend-react/src/App.jsx
Normal file
59
frontend-react/src/App.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { useState } from 'react';
|
||||
import Toolbar from './components/ToolBar/ToolBar';
|
||||
import ChatBox from './components/ChatBox/ChatBox';
|
||||
import DicePanel from './components/DicePanel/DicePanel';
|
||||
import ImageDisplay from './components/ImageDisplay/ImageDisplay';
|
||||
import PresetPanel from './components/PresetPanel/PresetPanel';
|
||||
import './index.css';
|
||||
|
||||
function App() {
|
||||
const [selectedRole, setSelectedRole] = useState(null);
|
||||
const [selectedChat, setSelectedChat] = useState(null);
|
||||
|
||||
const handleRoleChange = (role) => {
|
||||
setSelectedRole(role);
|
||||
console.log('角色已更改:', role);
|
||||
};
|
||||
|
||||
const handleChatChange = (role, chat) => {
|
||||
setSelectedChat(chat);
|
||||
console.log('聊天已更改:', role, chat);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Toolbar
|
||||
onRoleChange={handleRoleChange}
|
||||
onChatChange={handleChatChange}
|
||||
/>
|
||||
|
||||
{/* 主内容容器 */}
|
||||
<div className="main-container">
|
||||
{/* 左侧栏 - 预设面板 */}
|
||||
<div className="sidebar-left">
|
||||
<PresetPanel />
|
||||
</div>
|
||||
|
||||
{/* 中间栏:聊天框 */}
|
||||
<div className="chat-area">
|
||||
<ChatBox
|
||||
selectedRole={selectedRole}
|
||||
selectedChat={selectedChat}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧栏 */}
|
||||
<div className="sidebar-right">
|
||||
<div className="right-top">
|
||||
<ImageDisplay /> {/* 图片展示放在顶部 */}
|
||||
</div>
|
||||
<div className="right-bottom">
|
||||
<DicePanel /> {/* 骰子面板放在底部 */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
202
frontend-react/src/Store/Slices/RoleSelectorSlice.jsx
Normal file
202
frontend-react/src/Store/Slices/RoleSelectorSlice.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// 异步获取角色数据
|
||||
const fetchRoleData = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tool_bar/get_all_role_and_chat', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('获取角色数据失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const useRoleSelectorStore = create((set, get) => ({
|
||||
// 状态
|
||||
roleData: {},
|
||||
selectedRole: null,
|
||||
selectedChat: null,
|
||||
hoveredRole: null,
|
||||
clickedRole: null,
|
||||
isLoading: false,
|
||||
searchTerm: '',
|
||||
editingRole: null,
|
||||
editingChat: null,
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null,
|
||||
|
||||
// 操作
|
||||
fetchRoleData: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const data = await fetchRoleData();
|
||||
set({ roleData: data, isLoading: false });
|
||||
} catch (error) {
|
||||
set({ isLoading: false });
|
||||
console.error('获取角色数据失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedRole: (role) => set({ selectedRole: role }),
|
||||
|
||||
setSelectedChat: (chat) => set({ selectedChat: chat }),
|
||||
|
||||
setHoveredRole: (role) => set({ hoveredRole: role }),
|
||||
|
||||
setClickedRole: (role) => set({ clickedRole: role }),
|
||||
|
||||
setSearchTerm: (term) => set({ searchTerm: term }),
|
||||
|
||||
setEditingRole: (role) => set({ editingRole: role }),
|
||||
|
||||
setEditingChat: (chat) => set({ editingChat: chat }),
|
||||
|
||||
setShowDeleteConfirm: (name) => set({ showDeleteConfirm: name }),
|
||||
|
||||
setDeleteType: (type) => set({ deleteType: type }),
|
||||
|
||||
handleRenameRole: (oldName, newName) => {
|
||||
const { roleData, selectedRole } = get();
|
||||
if (newName && newName !== oldName) {
|
||||
const newRoleData = { ...roleData };
|
||||
const chats = newRoleData[oldName];
|
||||
delete newRoleData[oldName];
|
||||
newRoleData[newName] = chats;
|
||||
|
||||
if (selectedRole === oldName) {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
selectedRole: newName,
|
||||
editingRole: null
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
editingRole: null
|
||||
});
|
||||
}
|
||||
} else {
|
||||
set({ editingRole: null });
|
||||
}
|
||||
},
|
||||
|
||||
handleRenameChat: (oldName, newName) => {
|
||||
const { roleData, selectedRole, selectedChat } = get();
|
||||
if (newName && newName !== oldName) {
|
||||
const newRoleData = { ...roleData };
|
||||
const chatIndex = newRoleData[selectedRole].indexOf(oldName);
|
||||
if (chatIndex !== -1) {
|
||||
newRoleData[selectedRole][chatIndex] = newName;
|
||||
|
||||
if (selectedChat === oldName) {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
selectedChat: newName,
|
||||
editingChat: null
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
editingChat: null
|
||||
});
|
||||
}
|
||||
} else {
|
||||
set({ editingChat: null });
|
||||
}
|
||||
} else {
|
||||
set({ editingChat: null });
|
||||
}
|
||||
},
|
||||
|
||||
confirmDelete: () => {
|
||||
const { roleData, selectedRole, selectedChat, showDeleteConfirm, deleteType } = get();
|
||||
const newRoleData = { ...roleData };
|
||||
|
||||
if (deleteType === 'role') {
|
||||
delete newRoleData[showDeleteConfirm];
|
||||
if (selectedRole === showDeleteConfirm) {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
selectedRole: null,
|
||||
selectedChat: null,
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null
|
||||
});
|
||||
}
|
||||
} else if (deleteType === 'chat') {
|
||||
const chatIndex = newRoleData[selectedRole].indexOf(showDeleteConfirm);
|
||||
if (chatIndex !== -1) {
|
||||
newRoleData[selectedRole].splice(chatIndex, 1);
|
||||
if (selectedChat === showDeleteConfirm) {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
selectedChat: null,
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null
|
||||
});
|
||||
}
|
||||
} else {
|
||||
set({
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
cancelDelete: () => set({ showDeleteConfirm: null, deleteType: null }),
|
||||
|
||||
handleAddRole: () => {
|
||||
const { roleData } = get();
|
||||
const newRole = '新角色';
|
||||
const newRoleData = { ...roleData };
|
||||
newRoleData[newRole] = [];
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
editingRole: newRole
|
||||
});
|
||||
},
|
||||
|
||||
handleAddChat: () => {
|
||||
const { roleData, selectedRole } = get();
|
||||
if (!selectedRole) return;
|
||||
|
||||
const newChat = '新聊天';
|
||||
const newRoleData = { ...roleData };
|
||||
newRoleData[selectedRole].push(newChat);
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
editingChat: newChat
|
||||
});
|
||||
},
|
||||
|
||||
resetPanel: () => set({
|
||||
hoveredRole: null,
|
||||
clickedRole: null,
|
||||
editingRole: null,
|
||||
editingChat: null,
|
||||
showDeleteConfirm: null
|
||||
})
|
||||
}));
|
||||
|
||||
export default useRoleSelectorStore;
|
||||
2
frontend-react/src/Store/indexStore.jsx
Normal file
2
frontend-react/src/Store/indexStore.jsx
Normal file
@@ -0,0 +1,2 @@
|
||||
// frontend-react/src/store/index.js
|
||||
export { default as useRoleSelectorStore } from './roleSelectorStore';
|
||||
405
frontend-react/src/components/ChatBox/ChatBox.css
Normal file
405
frontend-react/src/components/ChatBox/ChatBox.css
Normal file
@@ -0,0 +1,405 @@
|
||||
/* ==================== 聊天框区域 ==================== */
|
||||
|
||||
/* React 组件根容器 */
|
||||
.chat-box {
|
||||
height: 100%; /* 修改为100%,填满父容器 */
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #fafafa;
|
||||
overflow: hidden; /* 防止内容溢出 */
|
||||
}
|
||||
|
||||
/* 消息列表容器 */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: calc(95vh - 62px); /* 减去输入区域的高度 */
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
padding-top: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* 设置面板 */
|
||||
.settings-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
background-color: #fff;
|
||||
border-bottom-left-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.settings-panel.collapsed {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-panel.expanded {
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
padding: 10px;
|
||||
border: 1px solid #eee;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f0f0f0;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.settings-panel.collapsed .settings-header:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.settings-options {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.settings-panel.expanded .settings-options {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.setting-item input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ==================== 聊天气泡样式 ==================== */
|
||||
|
||||
/* 消息行容器 */
|
||||
.message {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 区分左右布局 */
|
||||
.message.user {
|
||||
justify-content: flex-end; /* 用户消息靠右 */
|
||||
}
|
||||
|
||||
.message.ai {
|
||||
justify-content: flex-start; /* AI 消息靠左 */
|
||||
}
|
||||
|
||||
/* 消息容器 - 调整为垂直排列 */
|
||||
.message-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
/* 消息头部 - 包含名称和工具栏 */
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
order: 1; /* 确保头部显示在第一个位置 */
|
||||
}
|
||||
|
||||
/* 消息名称 */
|
||||
.message-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 用户消息名称 */
|
||||
.message.user .message-name {
|
||||
background-color: rgba(24, 144, 255, 0.1);
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
/* AI消息名称 */
|
||||
.message.ai .message-name {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 消息工具栏 - 优化布局使图标更紧凑 */
|
||||
.message-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.message:hover .message-toolbar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.message-id {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-buttons {
|
||||
display: flex;
|
||||
gap: 3px; /* 减少图标之间的间距 */
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #555;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* AI消息头部布局:AI助手 - ID - 编辑 - 更多 */
|
||||
.message.ai .message-header {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message.ai .message-name {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.message.ai .message-id {
|
||||
order: 2;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.message.ai .toolbar-buttons {
|
||||
order: 3;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
/* 用户消息头部布局:更多 - 编辑 - ID - 我 */
|
||||
.message.user .message-header {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message.user .message-name {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.message.user .message-id {
|
||||
order: 3;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.message.user .toolbar-buttons {
|
||||
order: 1;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* 消息内容 - 确保显示在名称下方 */
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100%;
|
||||
order: 2; /* 确保内容显示在第二个位置 */
|
||||
}
|
||||
|
||||
/* 气泡本体 */
|
||||
.message .bubble {
|
||||
max-width: 100%;
|
||||
padding: 10px 15px;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* AI 气泡样式 */
|
||||
.message.ai .bubble {
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
border-top-left-radius: 2px;
|
||||
border-bottom-left-radius: 2px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
/* 用户气泡样式 */
|
||||
.message.user .bubble {
|
||||
background-color: #1890ff;
|
||||
color: #fff;
|
||||
border-top-right-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* 针对 AI 消息中的 HTML 内容进行简单的样式重置 */
|
||||
.message.ai .bubble p {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.message.ai .bubble p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.message.ai .bubble ul, .message.ai .bubble ol {
|
||||
margin: 0 0 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.message.ai .bubble b {
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* 编辑模式 */
|
||||
.edit-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.edit-textarea {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.edit-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.edit-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.save-button, .cancel-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: #40a9ff;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background-color: #e6e6e6;
|
||||
}
|
||||
|
||||
/* ==================== 输入区域 ==================== */
|
||||
|
||||
.chat-input-wrapper {
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #ddd;
|
||||
padding: 10px 20px;
|
||||
height: 62px; /* 明确设置高度 */
|
||||
flex-shrink: 0; /* 防止被压缩 */
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
|
||||
.chat-input-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chat-input-area textarea {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
min-height: 42px;
|
||||
max-height: 300px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
overflow-y: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
height: 42px;
|
||||
padding: 0 20px;
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-button:hover {
|
||||
background-color: #40a9ff;
|
||||
}
|
||||
167
frontend-react/src/components/ChatBox/ChatBox.jsx
Normal file
167
frontend-react/src/components/ChatBox/ChatBox.jsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import './ChatBox.css';
|
||||
|
||||
const ChatBox = ({ selectedRole, selectedChat }) => {
|
||||
const [isHtmlRender, setIsHtmlRender] = useState(false);
|
||||
const [isImageGen, setIsImageGen] = useState(false);
|
||||
const [isDynamicTable, setIsDynamicTable] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
// 自动调整 Textarea 高度
|
||||
const adjustHeight = () => {
|
||||
const textarea = textareaRef.current;
|
||||
const wrapper = textarea?.closest('.chat-input-wrapper');
|
||||
|
||||
if (textarea && wrapper) {
|
||||
textarea.style.height = '42px';
|
||||
wrapper.style.height = 'auto';
|
||||
const newHeight = textarea.scrollHeight;
|
||||
|
||||
if (newHeight > 42) {
|
||||
textarea.style.height = newHeight + 'px';
|
||||
} else {
|
||||
textarea.style.height = '42px';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
adjustHeight();
|
||||
};
|
||||
|
||||
// 开始编辑消息
|
||||
const startEdit = (id, content) => {
|
||||
setEditingId(id);
|
||||
setEditContent(content);
|
||||
};
|
||||
|
||||
// 保存编辑的消息
|
||||
const saveEdit = (id) => {
|
||||
// 这里可以添加向后端发送请求的代码
|
||||
console.log(`保存消息 ${id} 的编辑内容: ${editContent}`);
|
||||
|
||||
// 更新前端显示
|
||||
const messageIndex = messages.findIndex(msg => msg.id === id);
|
||||
if (messageIndex !== -1) {
|
||||
messages[messageIndex].content = editContent;
|
||||
}
|
||||
|
||||
// 退出编辑模式
|
||||
setEditingId(null);
|
||||
setEditContent('');
|
||||
};
|
||||
|
||||
// 取消编辑
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditContent('');
|
||||
};
|
||||
|
||||
// 生成示例数据
|
||||
const generateMessages = () => {
|
||||
const messages = [];
|
||||
for (let i = 1; i <= 150; i++) {
|
||||
const isUser = i % 2 !== 0;
|
||||
messages.push({
|
||||
id: i,
|
||||
role: isUser ? 'user' : 'ai',
|
||||
name: isUser ? '我' : 'AI助手',
|
||||
content: isUser
|
||||
? `这是第 ${i} 条用户消息。这是一段比较长的文本,用来测试气泡的换行效果以及滚动条的表现。`
|
||||
: `这是第 ${i} 条 AI 回复。<b>包含 HTML 标签</b>的内容。如果渲染开关开启,这里应该显示粗体字。如果不开启,应该显示原始标签。`
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
};
|
||||
|
||||
const messages = generateMessages();
|
||||
|
||||
return (
|
||||
<div className="chat-box">
|
||||
{/* 上方:消息列表区域 */}
|
||||
<div className="chat-messages">
|
||||
{/* 消息列表 */}
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`message ${msg.role}`}>
|
||||
<div className="message-container">
|
||||
{/* 消息名称和工具栏在同一行 */}
|
||||
<div className="message-header">
|
||||
<div className="message-name">{msg.name}</div>
|
||||
|
||||
{/* 消息工具栏 */}
|
||||
<div className="message-toolbar">
|
||||
<span className="message-id">ID: {msg.id}</span>
|
||||
<div className="toolbar-buttons">
|
||||
<button
|
||||
className="toolbar-button edit-button"
|
||||
onClick={() => startEdit(msg.id, msg.content)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button className="toolbar-button expand-button">
|
||||
⋮
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息内容 */}
|
||||
<div className="message-content">
|
||||
<div className="bubble">
|
||||
{editingId === msg.id ? (
|
||||
<div className="edit-container">
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="edit-textarea"
|
||||
/>
|
||||
<div className="edit-buttons">
|
||||
<button
|
||||
className="save-button"
|
||||
onClick={() => saveEdit(msg.id)}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
className="cancel-button"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
isHtmlRender ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: msg.content }} />
|
||||
) : (
|
||||
<div>{msg.content}</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 下方:输入框区域 */}
|
||||
<div className="chat-input-wrapper">
|
||||
<div className="chat-input-area">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
placeholder="输入消息..."
|
||||
onInput={handleInput}
|
||||
rows="1"
|
||||
style={{ height: '42px' }}
|
||||
/>
|
||||
</div>
|
||||
<button className="send-button">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatBox;
|
||||
11
frontend-react/src/components/DicePanel/DicePanel.jsx
Normal file
11
frontend-react/src/components/DicePanel/DicePanel.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const DicePanel = () => {
|
||||
return (
|
||||
<div className="dice-panel">
|
||||
<div>骰子区</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DicePanel;
|
||||
11
frontend-react/src/components/ImageDisplay/ImageDisplay.jsx
Normal file
11
frontend-react/src/components/ImageDisplay/ImageDisplay.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const ImageDisplay = () => {
|
||||
return (
|
||||
<div className="image-display">
|
||||
<div>图片展示区</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageDisplay;
|
||||
146
frontend-react/src/components/Markdown2Html/Markdown2Html.jsx
Normal file
146
frontend-react/src/components/Markdown2Html/Markdown2Html.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
// npm install react react-dom react-markdown react-syntax-highlighter remark-math rehype-katex react-copy-to-clipboard mermaid katex
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { materialLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import mermaid from "mermaid";
|
||||
import "katex/dist/katex.min.css";
|
||||
|
||||
// Mermaid 初始化配置
|
||||
// Mermaid是一个画图的语言
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "default",
|
||||
securityLevel: "loose",
|
||||
});
|
||||
|
||||
const DownSvg = () => (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24">
|
||||
<path d="M7 10l5 5 5-5z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CopySvg = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24">
|
||||
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
|
||||
const MermaidChart= ({ code }) => {
|
||||
const [svg, setSvg] = useState("");
|
||||
const [id] = useState(() => `mermaid-${Math.random().toString(36).substr(2, 9)}`);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
mermaid.parse(code);
|
||||
mermaid.render(id, code).then(({ svg }) => setSvg(svg));
|
||||
} catch (err) {
|
||||
setSvg(`<pre>Error rendering mermaid: ${err}</pre>`);
|
||||
}
|
||||
}, [code, id]);
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||
};
|
||||
|
||||
const MarkdownToHTML= ({ markdownRAW, className }) => {
|
||||
const [isMermaidLoaded] = useState(true);
|
||||
// 渲染代码块的方法
|
||||
const CodeBlock = useCallback(
|
||||
({ node, inline, className, children, ...props } ) => {
|
||||
const [isShowCode, setIsShowCode] = useState(true); // 添加展开与收起代码块状态
|
||||
const [isShowCopy, setIsShowCopy] = useState(false);// 添加点击复制的状态
|
||||
const match = /language-(\w+)/.exec(className || ""); // 这是用来匹配代码块对应语言的方法
|
||||
const codeContent = String(children).replace(/\n$/, "");
|
||||
// 处理复制成功提示
|
||||
const handleCopy = () => {
|
||||
setIsShowCopy(true);
|
||||
setTimeout(() => setIsShowCopy(false), 1500);
|
||||
};
|
||||
//处理单行代码
|
||||
if (inline) {
|
||||
return <code className={className} {...props}>{children}</code>;
|
||||
}
|
||||
|
||||
// 处理 Mermaid 图表代码
|
||||
if (match?.[1] === "mermaid" && isMermaidLoaded) {
|
||||
return (
|
||||
<div style={{ position: "relative", margin: "20px 0" }}>
|
||||
<div className="code-header">
|
||||
<div
|
||||
style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }}
|
||||
className={isShowCode ? "code-rotate-down" : "code-rotate-right"}
|
||||
onClick={() => setIsShowCode(!isShowCode)}
|
||||
>
|
||||
<DownSvg />
|
||||
</div>
|
||||
<div>{match[1]}</div>
|
||||
<CopyToClipboard text={codeContent} onCopy={handleCopy}>
|
||||
<div className="preview-code-copy" style={{ cursor: "pointer" }}>
|
||||
{isShowCopy && <span className="copy-success">✓ 复制成功</span>}
|
||||
<CopySvg />
|
||||
</div>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
{isShowCode && <MermaidChart code={codeContent} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", padding:"0"}}>
|
||||
<div className="code-header">
|
||||
<div
|
||||
style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }}
|
||||
className={isShowCode ? "code-rotate-down" : "code-rotate-right"}
|
||||
onClick={() => setIsShowCode(!isShowCode)}
|
||||
>
|
||||
<DownSvg style={{
|
||||
transform: isShowCode ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
transition: "transform 0.2s"
|
||||
}} />
|
||||
</div>
|
||||
<div>{match?.[1] || "code"}</div>
|
||||
<CopyToClipboard text={codeContent} onCopy={handleCopy}>
|
||||
<div className="preview-code-copy" style={{ cursor: "pointer" }}>
|
||||
{isShowCopy && <span className="copy-success">✓ 复制成功</span>}
|
||||
<CopySvg />
|
||||
</div>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
{isShowCode && (
|
||||
<SyntaxHighlighter
|
||||
style={materialLight}
|
||||
language={match?.[1] || "text"}
|
||||
PreTag="div"
|
||||
showLineNumbers
|
||||
{...props}
|
||||
>
|
||||
{codeContent}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[isMermaidLoaded]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className} >
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{ code: CodeBlock }}
|
||||
>
|
||||
{markdownRAW}
|
||||
</ReactMarkdown>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownToHTML;
|
||||
@@ -0,0 +1,25 @@
|
||||
// 下面这些包如果显示不存在则运行这个代码安装依赖:
|
||||
// npm install marked marked-highlight highlight.js
|
||||
import { marked } from "marked";
|
||||
import { markedHighlight } from "marked-highlight";
|
||||
import hljs from "highlight.js";
|
||||
import "highlight.js/styles/base16/github.css";
|
||||
|
||||
// 配置 marked 和 markedHighlight
|
||||
marked.use(
|
||||
markedHighlight({
|
||||
langPrefix: "hljs language-",
|
||||
highlight(code, lang) {
|
||||
const language = hljs.getLanguage(lang) ? lang : "plaintext"; // 如果语言不支持,回退为纯文本
|
||||
return hljs.highlight(code, { language }).value; // 使用 highlight.js 高亮代码
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Markdown 解析函数
|
||||
const MarkdownRenderer = (markdownString)=> {
|
||||
const res= marked.parse(markdownString).toString()
|
||||
return res; // 将 Markdown 转换为 HTML
|
||||
};
|
||||
|
||||
export default MarkdownRenderer;
|
||||
57
frontend-react/src/components/PresetPanel/PresetPanel.css
Normal file
57
frontend-react/src/components/PresetPanel/PresetPanel.css
Normal file
@@ -0,0 +1,57 @@
|
||||
/* ==================== 顶部工具栏区域 ==================== */
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.toolbar.expanded {
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.toolbar-content {
|
||||
display: none;
|
||||
width: 100%;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.toolbar.expanded .toolbar-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toolbar-toggle-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid #ddd;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.toolbar-toggle-btn:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
30
frontend-react/src/components/PresetPanel/PresetPanel.jsx
Normal file
30
frontend-react/src/components/PresetPanel/PresetPanel.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import './PresetPanel.css';
|
||||
|
||||
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;
|
||||
533
frontend-react/src/components/RoleSelector/RoleSelector.css
Normal file
533
frontend-react/src/components/RoleSelector/RoleSelector.css
Normal file
@@ -0,0 +1,533 @@
|
||||
/* ==================== 角色选择器容器 ==================== */
|
||||
|
||||
.role-selector {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f8fafc;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
/* 添加微妙的背景纹理 */
|
||||
background-image: radial-gradient(#e5e7eb 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
/* 添加平滑滚动 */
|
||||
scroll-behavior: smooth;
|
||||
/* 优化移动端体验 */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* ==================== 当前选中角色显示 ==================== */
|
||||
|
||||
.selected-role-display {
|
||||
padding: 16px 24px;
|
||||
border-radius: 12px;
|
||||
/* 更丰富的渐变效果 */
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
margin-bottom: 24px;
|
||||
/* 增强阴影效果 */
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
/* 添加微妙的动画效果 */
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.selected-role-display:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.2) inset;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.role-badge.empty {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.role-label {
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.role-name {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-name {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* ==================== 搜索栏样式 ==================== */
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
width: 100%;
|
||||
padding: 12px 16px 12px 40px; /* 为搜索图标留出空间 */
|
||||
border: 2px solid transparent;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 添加搜索图标 */
|
||||
.search-bar::before {
|
||||
content: '🔍';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.search-bar input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.search-bar input::placeholder {
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
/* ==================== 角色列表样式 ==================== */
|
||||
|
||||
.role-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); /* 响应式网格布局 */
|
||||
gap: 16px;
|
||||
padding-right: 8px;
|
||||
padding-bottom: 8px;
|
||||
/* 自定义滚动条样式 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 transparent;
|
||||
}
|
||||
|
||||
/* Webkit浏览器滚动条样式 */
|
||||
.role-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.role-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.role-list::-webkit-scrollbar-thumb {
|
||||
background-color: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.role-item {
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background-color: #fff;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
min-height: 140px;
|
||||
position: relative;
|
||||
/* 添加微妙的边框效果 */
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.role-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
.role-item.active {
|
||||
border: 2px solid #667eea;
|
||||
background-color: #f5f7ff;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.role-header {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.role-header .role-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-item.active .role-header .role-name {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.role-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.role-item:hover .role-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background-color: #fff;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.icon-btn.delete:hover {
|
||||
background-color: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* ==================== 聊天列表样式 ==================== */
|
||||
|
||||
.chat-list {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 8px 0;
|
||||
background-color: #fafbfc;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
/* 自定义滚动条样式 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 transparent;
|
||||
}
|
||||
|
||||
/* Webkit浏览器滚动条样式 */
|
||||
.chat-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.chat-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-list::-webkit-scrollbar-thumb {
|
||||
background-color: #cbd5e1;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
padding: 10px 16px 10px 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.2s ease;
|
||||
/* 添加微妙的左边框指示器 */
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.chat-item:hover {
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
.chat-item.active {
|
||||
background-color: #eef1ff;
|
||||
color: #667eea;
|
||||
/* 激活状态添加左边框指示器 */
|
||||
border-left-color: #667eea;
|
||||
}
|
||||
|
||||
.chat-item .chat-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-item.active .chat-name {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
/* ==================== 加载和空状态 ==================== */
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 添加加载动画 */
|
||||
.loading::before {
|
||||
content: '';
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 添加空状态图标 */
|
||||
.empty-state::before {
|
||||
content: '📭';
|
||||
font-size: 48px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ==================== 模态框样式 ==================== */
|
||||
|
||||
.delete-confirm-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
/* 添加微妙的背景动画 */
|
||||
background-image: radial-gradient(circle at center, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 28px;
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.15);
|
||||
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* 添加微妙的边框效果 */
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(24px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
margin-bottom: 28px;
|
||||
color: #6b7280;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-actions button {
|
||||
padding: 11px 24px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
/* 添加微妙的阴影效果 */
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-actions button:first-child {
|
||||
background-color: #f3f4f6;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.modal-actions button:first-child:hover {
|
||||
background-color: #e5e7eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal-actions button.danger {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.modal-actions button.danger:hover {
|
||||
background-color: #f87171;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ==================== 响应式设计 ==================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.role-selector {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.role-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-item {
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background-color: #fff;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
min-height: 160px; /* 修改:增加最小高度,使其更适配宽度 */
|
||||
position: relative;
|
||||
/* 添加微妙的边框效果 */
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.role-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.selected-role-display {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 暗色模式支持 ==================== */
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.role-selector {
|
||||
background-color: #1f2937;
|
||||
background-image: radial-gradient(#374151 1px, transparent 1px);
|
||||
}
|
||||
|
||||
.role-item {
|
||||
background-color: #374151;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.role-item:hover {
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.role-header .role-name {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
background-color: #374151;
|
||||
border-top-color: #4b5563;
|
||||
}
|
||||
|
||||
.chat-item:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.chat-item .chat-name {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
background-color: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #374151;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
323
frontend-react/src/components/RoleSelector/RoleSelector.jsx
Normal file
323
frontend-react/src/components/RoleSelector/RoleSelector.jsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import useRoleSelectorStore from '../../store/Slices/RoleSelectorSlice';
|
||||
import './RoleSelector.css';
|
||||
|
||||
const RoleSelector = ({ onRoleChange, onChatChange }) => {
|
||||
const panelRef = useRef(null);
|
||||
|
||||
// 从 Zustand store 中获取状态和操作
|
||||
const {
|
||||
roleData,
|
||||
selectedRole,
|
||||
selectedChat,
|
||||
hoveredRole,
|
||||
clickedRole,
|
||||
isLoading,
|
||||
searchTerm,
|
||||
editingRole,
|
||||
editingChat,
|
||||
showDeleteConfirm,
|
||||
deleteType,
|
||||
fetchRoleData,
|
||||
setSelectedRole,
|
||||
setSelectedChat,
|
||||
setHoveredRole,
|
||||
setClickedRole,
|
||||
setSearchTerm,
|
||||
setEditingRole,
|
||||
setEditingChat,
|
||||
setShowDeleteConfirm,
|
||||
setDeleteType,
|
||||
handleRenameRole,
|
||||
handleRenameChat,
|
||||
confirmDelete,
|
||||
cancelDelete,
|
||||
handleAddRole,
|
||||
handleAddChat,
|
||||
resetPanel
|
||||
} = useRoleSelectorStore();
|
||||
|
||||
// 组件挂载时获取数据
|
||||
useEffect(() => {
|
||||
fetchRoleData();
|
||||
}, [fetchRoleData]);
|
||||
|
||||
// 点击外部关闭面板
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (panelRef.current && !panelRef.current.contains(event.target)) {
|
||||
resetPanel();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [resetPanel]);
|
||||
|
||||
// 处理角色选择
|
||||
const handleRoleSelect = (role) => {
|
||||
setSelectedRole(role);
|
||||
if (onRoleChange) {
|
||||
onRoleChange(role);
|
||||
}
|
||||
|
||||
// 如果该角色有聊天记录,默认选择第一个
|
||||
if (roleData[role] && roleData[role].length > 0) {
|
||||
const firstChat = roleData[role][0];
|
||||
setSelectedChat(firstChat);
|
||||
if (onChatChange) {
|
||||
onChatChange(role, firstChat);
|
||||
}
|
||||
} else {
|
||||
setSelectedChat(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理聊天选择
|
||||
const handleChatSelect = (chat) => {
|
||||
setSelectedChat(chat);
|
||||
setHoveredRole(null);
|
||||
setClickedRole(null);
|
||||
if (onChatChange) {
|
||||
onChatChange(selectedRole, chat);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理角色卡片点击
|
||||
const handleRoleCardClick = (role) => {
|
||||
if (clickedRole === role) {
|
||||
setClickedRole(null);
|
||||
} else {
|
||||
setClickedRole(role);
|
||||
handleRoleSelect(role);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理搜索
|
||||
const handleSearchChange = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
};
|
||||
|
||||
// 处理角色编辑
|
||||
const handleEditRole = (e, role) => {
|
||||
e.stopPropagation();
|
||||
setEditingRole(role);
|
||||
};
|
||||
|
||||
// 处理角色重命名
|
||||
const handleRenameRoleWrapper = (e, oldName) => {
|
||||
e.stopPropagation();
|
||||
const newName = e.target.value;
|
||||
handleRenameRole(oldName, newName);
|
||||
if (selectedRole === oldName && newName && newName !== oldName) {
|
||||
if (onRoleChange) {
|
||||
onRoleChange(newName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理聊天编辑
|
||||
const handleEditChat = (e, chat) => {
|
||||
e.stopPropagation();
|
||||
setEditingChat(chat);
|
||||
};
|
||||
|
||||
// 处理聊天重命名
|
||||
const handleRenameChatWrapper = (e, oldName) => {
|
||||
e.stopPropagation();
|
||||
const newName = e.target.value;
|
||||
handleRenameChat(oldName, newName);
|
||||
if (selectedChat === oldName && newName && newName !== oldName) {
|
||||
if (onChatChange) {
|
||||
onChatChange(selectedRole, newName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理删除确认
|
||||
const handleDeleteClick = (e, type, name) => {
|
||||
e.stopPropagation();
|
||||
setDeleteType(type);
|
||||
setShowDeleteConfirm(name);
|
||||
};
|
||||
|
||||
// 确认删除
|
||||
const confirmDeleteWrapper = () => {
|
||||
confirmDelete();
|
||||
if (deleteType === 'role' && selectedRole === showDeleteConfirm) {
|
||||
if (onRoleChange) {
|
||||
onRoleChange(null);
|
||||
}
|
||||
} else if (deleteType === 'chat' && selectedChat === showDeleteConfirm) {
|
||||
if (onChatChange) {
|
||||
onChatChange(selectedRole, null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 过滤角色
|
||||
const filteredRoles = Object.keys(roleData).filter(role =>
|
||||
role.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="role-selector" ref={panelRef}>
|
||||
<div className="selected-role-display">
|
||||
{selectedRole ? (
|
||||
<div className="role-badge">
|
||||
<span className="role-label">当前角色/聊天:</span>
|
||||
<span className="role-name">{selectedRole}</span>
|
||||
{selectedChat && <span className="chat-name">/ {selectedChat}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="role-badge empty">
|
||||
<span>未选择角色</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="search-bar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索角色..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="role-list">
|
||||
{isLoading ? (
|
||||
<div className="loading">加载中...</div>
|
||||
) : filteredRoles.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>没有找到匹配的角色</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredRoles.map(role => (
|
||||
<div
|
||||
key={role}
|
||||
className={`role-item ${selectedRole === role ? 'active' : ''}`}
|
||||
onClick={() => handleRoleCardClick(role)}
|
||||
onMouseEnter={() => setHoveredRole(role)}
|
||||
onMouseLeave={() => setHoveredRole(null)}
|
||||
>
|
||||
<div className="role-header">
|
||||
{editingRole === role ? (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={role}
|
||||
autoFocus
|
||||
onBlur={(e) => handleRenameRoleWrapper(e, role)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRenameRoleWrapper(e, role);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<div className="role-name">{role}</div>
|
||||
)}
|
||||
|
||||
<div className="role-actions">
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="编辑"
|
||||
onClick={(e) => handleEditRole(e, role)}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn delete"
|
||||
title="删除"
|
||||
onClick={(e) => handleDeleteClick(e, 'role', role)}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 聊天列表 */}
|
||||
{(hoveredRole === role || clickedRole === role) && roleData[role] && roleData[role].length > 0 && (
|
||||
<div className="chat-list">
|
||||
{roleData[role].map(chat => (
|
||||
<div
|
||||
key={chat}
|
||||
className={`chat-item ${selectedChat === chat ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleChatSelect(chat);
|
||||
}}
|
||||
>
|
||||
<div className="chat-content">
|
||||
{editingChat === chat ? (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={chat}
|
||||
autoFocus
|
||||
onBlur={(e) => handleRenameChatWrapper(e, chat)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRenameChatWrapper(e, chat);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<div className="chat-name">{chat}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chat-actions">
|
||||
{editingChat !== chat && (
|
||||
<>
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="编辑"
|
||||
onClick={(e) => handleEditChat(e, chat)}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn delete"
|
||||
title="删除"
|
||||
onClick={(e) => handleDeleteClick(e, 'chat', chat)}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="delete-confirm-modal">
|
||||
<div className="modal-content">
|
||||
<h3>确认删除</h3>
|
||||
<p>确定要删除{deleteType === 'role' ? '角色' : '聊天'} "{showDeleteConfirm}" 吗?</p>
|
||||
<div className="modal-actions">
|
||||
<button className="modal-button cancel-button" onClick={cancelDelete}>
|
||||
取消
|
||||
</button>
|
||||
<button className="modal-button danger" onClick={confirmDeleteWrapper}>
|
||||
确认删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoleSelector;
|
||||
154
frontend-react/src/components/ToolBar/ToolBar.css
Normal file
154
frontend-react/src/components/ToolBar/ToolBar.css
Normal file
@@ -0,0 +1,154 @@
|
||||
/* ==================== 顶部工具栏区域 ==================== */
|
||||
|
||||
.toolbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
justify-content: space-between;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 工具栏主要部分容器 */
|
||||
.toolbar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 工具栏图标容器 */
|
||||
.toolbar-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 工具栏图标 */
|
||||
.toolbar-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 18px;
|
||||
color: #555;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.toolbar-icon:hover {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.toolbar-icon.active {
|
||||
background-color: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ==================== 弹出面板通用样式 ==================== */
|
||||
|
||||
.close-panel-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 5px 10px;
|
||||
margin-left: auto;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.close-panel-button:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.panel-overlay {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
width: 90%;
|
||||
max-width: 1200px;
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-container {
|
||||
margin-top: 50px;
|
||||
height: calc(100vh - 50px);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
141
frontend-react/src/components/ToolBar/ToolBar.jsx
Normal file
141
frontend-react/src/components/ToolBar/ToolBar.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import RoleSelector from '../RoleSelector/RoleSelector';
|
||||
import './ToolBar.css';
|
||||
|
||||
const Toolbar = () => {
|
||||
const [activePanel, setActivePanel] = useState(null);
|
||||
const panelRef = useRef(null);
|
||||
|
||||
// 点击外部关闭面板
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (panelRef.current && !panelRef.current.contains(event.target)) {
|
||||
setActivePanel(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 处理面板切换
|
||||
const handlePanelToggle = (panelName) => {
|
||||
if (activePanel === panelName) {
|
||||
setActivePanel(null);
|
||||
} else {
|
||||
setActivePanel(panelName);
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭面板
|
||||
const handleClosePanel = () => {
|
||||
setActivePanel(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
{/* 左侧工具栏图标 */}
|
||||
<div className="toolbar-section">
|
||||
<div className="toolbar-icons">
|
||||
{/* Logo图标 */}
|
||||
<div
|
||||
className="toolbar-icon"
|
||||
title="首页"
|
||||
onClick={() => handlePanelToggle(null)}
|
||||
>
|
||||
🤖
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间操作图标 */}
|
||||
<div className="toolbar-section">
|
||||
<div className="toolbar-icons">
|
||||
{/* 角色选择图标 */}
|
||||
<div
|
||||
className={`toolbar-icon ${activePanel === 'role' ? 'active' : ''}`}
|
||||
title="角色管理"
|
||||
onClick={() => handlePanelToggle('role')}
|
||||
>
|
||||
👤
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧工具栏图标 */}
|
||||
<div className="toolbar-section">
|
||||
<div className="toolbar-icons" style={{ justifyContent: 'flex-end' }}>
|
||||
<div
|
||||
className="toolbar-icon"
|
||||
title="设置"
|
||||
onClick={() => handlePanelToggle('settings')}
|
||||
>
|
||||
⚙️
|
||||
</div>
|
||||
<div
|
||||
className="toolbar-icon"
|
||||
title="帮助"
|
||||
onClick={() => handlePanelToggle('help')}
|
||||
>
|
||||
❓
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 角色管理面板 */}
|
||||
{activePanel === 'role' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
<div className="panel-header">
|
||||
<h3>角色管理</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<RoleSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activePanel === 'settings' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
<div className="panel-header">
|
||||
<h3>设置</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p>设置内容...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activePanel === 'help' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
<div className="panel-header">
|
||||
<h3>帮助</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p>帮助内容...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toolbar;
|
||||
61
frontend-react/src/index.css
Normal file
61
frontend-react/src/index.css
Normal file
@@ -0,0 +1,61 @@
|
||||
/* 重置样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, .app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden; /* 防止出现滚动条 */
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-container {
|
||||
flex: 1; /* 这会让主容器占据剩余的所有空间 */
|
||||
display: flex;
|
||||
overflow: hidden; /* 防止内容溢出 */
|
||||
height: 100%; /* 确保高度填满 */
|
||||
}
|
||||
|
||||
/* 左侧栏 */
|
||||
.sidebar-left {
|
||||
width: 250px; /* 或者你想要的宽度 */
|
||||
height: 继承父元素高度; /* 确保高度填满 */
|
||||
overflow-y: auto; /* 内容过多时显示滚动条 */
|
||||
}
|
||||
|
||||
/* 中间聊天区域 */
|
||||
.chat-area {
|
||||
flex: 1; /* 占据剩余空间 */
|
||||
height: 100%; /* 确保高度填满 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* 防止内容溢出 */
|
||||
}
|
||||
|
||||
/* 右侧栏 */
|
||||
.sidebar-right {
|
||||
width: 300px; /* 或者你想要的宽度 */
|
||||
height: 100%; /* 确保高度填满 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 右侧栏顶部 */
|
||||
.right-top {
|
||||
flex: 1; /* 占据剩余空间 */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 右侧栏底部 */
|
||||
.right-bottom {
|
||||
height: 200px; /* 或者你想要的高度 */
|
||||
overflow-y: auto;
|
||||
}
|
||||
14
frontend-react/src/main.jsx
Normal file
14
frontend-react/src/main.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
|
||||
// 获取 HTML 中的根元素
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
// 创建 React 根节点并渲染 App 组件
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
14
frontend-react/vite.config.js
Normal file
14
frontend-react/vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,18 +1,22 @@
|
||||
# 使用 Python 3.11 基础镜像
|
||||
FROM python:3.11-slim
|
||||
# 使用 Node.js 18 Alpine 镜像作为基础
|
||||
FROM node:18-alpine
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖文件并安装
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# 设置 npm 镜像源(可选,国内推荐使用,加速依赖下载)
|
||||
RUN npm config set registry https://registry.npmmirror.com/
|
||||
|
||||
# 复制所有代码
|
||||
COPY . .
|
||||
# 复制 package.json 和 package-lock.json
|
||||
# 利用 Docker 缓存层,只有依赖变更时才重新安装
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8501
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
|
||||
# 启动命令
|
||||
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
||||
# 暴露 Vite 默认端口 5173
|
||||
EXPOSE 5173
|
||||
|
||||
# 启动命令由 docker-compose.yml 中的 command 覆盖,
|
||||
# 这里保留默认的 dev 命令作为 fallback
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
|
||||
BIN
frontend/__pycache__/app.cpython-39.pyc
Normal file
BIN
frontend/__pycache__/app.cpython-39.pyc
Normal file
Binary file not shown.
313
frontend/app.py
313
frontend/app.py
@@ -1,3 +1,4 @@
|
||||
import requests
|
||||
import streamlit as st
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -11,6 +12,8 @@ st.set_page_config(
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded"
|
||||
)
|
||||
BACKEND_URL = os.getenv("BACKEND_URL", "http://127.0.0.1:8000")
|
||||
print(f"DEBUG BACKEND_URL: {BACKEND_URL}", flush=True) # 看 docker logs
|
||||
|
||||
# --- 自定义 CSS (蓝白清晰风格) ---
|
||||
st.markdown("""
|
||||
@@ -114,7 +117,142 @@ st.markdown("""
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 可折叠工具栏 */
|
||||
.collapsible-toolbar {
|
||||
background-color: #FFFFFF;
|
||||
border-bottom: 1px solid #D1D9E6;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 隐藏工具栏时的样式 */
|
||||
.toolbar-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 工具栏切换按钮 */
|
||||
.toolbar-toggle {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 999;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #D1D9E6;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* 三栏布局 - 修改部分 */
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.three-column-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-column, .middle-column, .right-column {
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left-column {
|
||||
flex: 1;
|
||||
border-right: 1px solid #D1D9E6;
|
||||
}
|
||||
|
||||
.middle-column {
|
||||
flex: 3;
|
||||
border-right: 1px solid #D1D9E6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.right-column {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 中间列的聊天区域 */
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
height: calc(100% - 80px); /* 减去输入区域的高度 */
|
||||
}
|
||||
|
||||
/* 中间列的输入区域 */
|
||||
.input-area {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px;
|
||||
border-top: 1px solid #D1D9E6;
|
||||
background-color: #F0F4F8;
|
||||
height: 80px; /* 固定高度 */
|
||||
}
|
||||
|
||||
/* 隐藏Streamlit默认的滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 动态调整布局高度
|
||||
function adjustLayout() {
|
||||
// 获取三个列容器
|
||||
const leftColumn = document.querySelector('.left-column');
|
||||
const middleColumn = document.querySelector('.middle-column');
|
||||
const rightColumn = document.querySelector('.right-column');
|
||||
|
||||
// 设置高度为视口高度减去顶部工具栏高度
|
||||
const height = window.innerHeight - 60; // 减去顶部工具栏的高度
|
||||
|
||||
if (leftColumn) leftColumn.style.height = `${height}px`;
|
||||
if (middleColumn) middleColumn.style.height = `${height}px`;
|
||||
if (rightColumn) rightColumn.style.height = `${height}px`;
|
||||
|
||||
// 调整聊天区域高度
|
||||
const chatArea = document.querySelector('.chat-area');
|
||||
if (chatArea) {
|
||||
const inputArea = document.querySelector('.input-area');
|
||||
const inputHeight = inputArea ? inputArea.offsetHeight : 80;
|
||||
chatArea.style.height = `${height - inputHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时调整布局
|
||||
window.addEventListener('load', adjustLayout);
|
||||
|
||||
// 窗口大小改变时重新调整布局
|
||||
window.addEventListener('resize', adjustLayout);
|
||||
|
||||
// 每次Streamlit重新渲染后调整布局
|
||||
document.addEventListener('newElementRendered', adjustLayout);
|
||||
</script>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# --- 状态初始化 ---
|
||||
@@ -137,17 +275,42 @@ if "splice_blocks" not in st.session_state:
|
||||
{"id": 3, "name": "世界书:人物关系", "active": False, "type": "world"},
|
||||
{"id": 4, "name": "Chat History (自动)", "active": True, "type": "history", "editable": False},
|
||||
]
|
||||
if "toolbar_visible" not in st.session_state:
|
||||
st.session_state.toolbar_visible = True
|
||||
|
||||
# --- 顶部工具栏 ---
|
||||
c_top1, c_top2, c_top3 = st.columns([1, 6, 1])
|
||||
with c_top1:
|
||||
# 工具栏切换按钮
|
||||
st.markdown("""
|
||||
<div class="toolbar-toggle" onclick="toggleToolbar()">
|
||||
<span id="toolbar-icon">▼</span>
|
||||
</div>
|
||||
<script>
|
||||
function toggleToolbar() {
|
||||
var toolbar = document.querySelector('.collapsible-toolbar');
|
||||
var icon = document.getElementById('toolbar-icon');
|
||||
if (toolbar.style.display === 'none') {
|
||||
toolbar.style.display = 'block';
|
||||
icon.textContent = '▼';
|
||||
} else {
|
||||
toolbar.style.display = 'none';
|
||||
icon.textContent = '▲';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# 工具栏内容
|
||||
if st.session_state.toolbar_visible:
|
||||
with st.container():
|
||||
c_top1, c_top2, c_top3 = st.columns([1, 6, 1])
|
||||
with c_top1:
|
||||
if st.button("📂 打开", key="btn_open"):
|
||||
st.toast("打开会话功能预留")
|
||||
if st.button("💾 保存", key="btn_save"):
|
||||
st.toast("会话已保存")
|
||||
with c_top2:
|
||||
with c_top2:
|
||||
st.markdown("<h3 style='margin:0; color:#0056B3;'>AI WorkFlow Engine</h3>", unsafe_allow_html=True)
|
||||
with c_top3:
|
||||
with c_top3:
|
||||
if st.button("⚙️ 设置", key="btn_settings"):
|
||||
st.toast("全局设置预留")
|
||||
|
||||
@@ -160,6 +323,9 @@ col_left, col_mid, col_right = st.columns([1, 3, 1], gap="small")
|
||||
# 1. 左侧:预设与拼接管理 (蓝白风格适配)
|
||||
# =======================
|
||||
with col_left:
|
||||
# 使用自定义容器类
|
||||
st.markdown('<div class="left-column">', unsafe_allow_html=True)
|
||||
|
||||
st.markdown("#### 📜 全局预设")
|
||||
c_pre1, c_pre2 = st.columns([4, 1])
|
||||
with c_pre1:
|
||||
@@ -216,44 +382,134 @@ with col_left:
|
||||
if st.button("+ 添加拼接块", use_container_width=True):
|
||||
st.toast("添加新功能预留")
|
||||
|
||||
st.markdown('</div>', unsafe_allow_html=True)
|
||||
|
||||
# =======================
|
||||
# 2. 中间:流式对话区 (动态读取历史)
|
||||
# =======================
|
||||
with col_mid:
|
||||
# 顶部控制条
|
||||
c_ctrl1, c_ctrl2 = st.columns([4, 1])
|
||||
# 使用自定义容器类
|
||||
st.markdown('<div class="middle-column">', unsafe_allow_html=True)
|
||||
|
||||
# --- 控制区域 ---
|
||||
c_ctrl1, c_ctrl2, c_ctrl3 = st.columns([3, 2, 1])
|
||||
|
||||
with c_ctrl1:
|
||||
st.caption("当前会话:Active_Session_01")
|
||||
# --- 数据集选择下拉框 ---
|
||||
try:
|
||||
response = requests.get(f"{BACKEND_URL}/get_all_role_and_chat")
|
||||
if response.status_code == 200:
|
||||
datasets = response.json()
|
||||
dataset_options = list(datasets.keys())
|
||||
else:
|
||||
st.error(f"获取数据集失败: {response.status_code}")
|
||||
dataset_options = []
|
||||
except requests.exceptions.RequestException as e:
|
||||
st.error(f"请求数据集时出错: {e}")
|
||||
dataset_options = []
|
||||
|
||||
selected_dataset = st.selectbox(
|
||||
"选择数据集",
|
||||
dataset_options,
|
||||
index=0 if dataset_options else None,
|
||||
key="dataset_selector"
|
||||
)
|
||||
|
||||
with c_ctrl2:
|
||||
# --- 文件路径选择下拉框 ---
|
||||
# 初始化两层下拉框的数据结构
|
||||
chat_history_options = {}
|
||||
file_options = []
|
||||
|
||||
if selected_dataset:
|
||||
# 使用已经获取的数据集数据
|
||||
chat_history_options = datasets
|
||||
# 获取当前选中数据集对应的文件列表
|
||||
file_options = chat_history_options.get(selected_dataset, [])
|
||||
|
||||
# 第一层下拉框:选择聊天会话(这里应该直接使用selected_dataset)
|
||||
# 不需要再创建一个selectbox,因为已经选择了数据集
|
||||
selected_chat_session = selected_dataset
|
||||
|
||||
# 第二层下拉框:选择文件路径(value列表)
|
||||
if selected_chat_session:
|
||||
file_options = chat_history_options.get(selected_chat_session, [])
|
||||
if file_options:
|
||||
# 提取文件名并去除.jsonl后缀,用于显示
|
||||
display_names = [os.path.basename(f).replace('.jsonl', '') for f in file_options]
|
||||
|
||||
# 创建文件名到完整路径的映射
|
||||
file_name_to_path = {os.path.basename(f).replace('.jsonl', ''): f for f in file_options}
|
||||
|
||||
selected_file_display = st.selectbox(
|
||||
"选择聊天",
|
||||
display_names,
|
||||
index=0 if display_names else None,
|
||||
key="file_selector"
|
||||
)
|
||||
|
||||
if selected_file_display:
|
||||
# 保存完整路径到session_state
|
||||
st.session_state.selected_file_path = file_name_to_path[selected_file_display]
|
||||
else:
|
||||
# 如果没有文件路径,清空选择
|
||||
if "file_selector" in st.session_state:
|
||||
del st.session_state["file_selector"]
|
||||
else:
|
||||
# 如果没有选择会话,清空选择
|
||||
if "file_selector" in st.session_state:
|
||||
del st.session_state["file_selector"]
|
||||
|
||||
with c_ctrl3:
|
||||
# HTML 渲染切换
|
||||
toggle_html = st.toggle("HTML 渲染", value=st.session_state.render_html, key="html_toggle")
|
||||
if toggle_html != st.session_state.render_html:
|
||||
st.session_state.render_html = toggle_html
|
||||
st.rerun()
|
||||
|
||||
# --- 核心:动态渲染历史记录 ---
|
||||
# 使用 st.container 包裹,确保每次 rerun 都能完整重绘
|
||||
chat_container = st.container()
|
||||
# 显示当前会话信息
|
||||
if selected_dataset and 'selected_file_path' in st.session_state:
|
||||
file_name = os.path.basename(st.session_state.selected_file_path).replace('.jsonl', '')
|
||||
st.caption(f"当前会话:{selected_dataset} - {file_name}")
|
||||
else:
|
||||
st.caption("当前会话:Active_Session_01")
|
||||
|
||||
with chat_container:
|
||||
# 遍历 session_state 中的消息历史
|
||||
for i, msg in enumerate(st.session_state.messages):
|
||||
# --- 核心:动态渲染历史记录 ---
|
||||
# 使用自定义容器类包裹聊天区域
|
||||
st.markdown('<div class="chat-area">', unsafe_allow_html=True)
|
||||
|
||||
# 如果选择了聊天记录,则显示该记录
|
||||
if hasattr(st.session_state, 'selected_chat_data') and st.session_state.selected_chat_data:
|
||||
# 显示选中的聊天记录
|
||||
msg = st.session_state.selected_chat_data
|
||||
with st.chat_message(msg["role"]):
|
||||
content = msg["content"]
|
||||
|
||||
# 根据开关决定是否解析 HTML
|
||||
if st.session_state.render_html and msg["role"] == "assistant":
|
||||
# 允许 HTML 标签
|
||||
st.markdown(content, unsafe_allow_html=True)
|
||||
else:
|
||||
# 纯文本/Markdown 模式
|
||||
st.markdown(content)
|
||||
|
||||
# 可选:在每条消息下添加操作按钮 (编辑/复制/重试) - 预留
|
||||
# with st.expander("...", expanded=False): ...
|
||||
# 显示其他信息
|
||||
with st.expander("详细信息", expanded=False):
|
||||
st.json(msg)
|
||||
else:
|
||||
# 否则显示session_state中的消息历史
|
||||
for i, msg in enumerate(st.session_state.messages):
|
||||
with st.chat_message(msg["role"]):
|
||||
content = msg["content"]
|
||||
|
||||
if st.session_state.render_html and msg["role"] == "assistant":
|
||||
st.markdown(content, unsafe_allow_html=True)
|
||||
else:
|
||||
st.markdown(content)
|
||||
|
||||
st.markdown('</div>', unsafe_allow_html=True)
|
||||
|
||||
# --- 输入区域 ---
|
||||
st.markdown("---")
|
||||
# 使用自定义容器类包裹输入区域
|
||||
st.markdown('<div class="input-area">', unsafe_allow_html=True)
|
||||
|
||||
# 聊天输入框
|
||||
user_input = st.chat_input("输入消息... (支持 /命令)")
|
||||
@@ -262,25 +518,19 @@ with col_mid:
|
||||
# 1. 将用户输入加入历史
|
||||
st.session_state.messages.append({"role": "user", "content": user_input})
|
||||
|
||||
# 2. 触发重新渲染,此时上方循环会立即显示用户消息
|
||||
# 注意:Streamlit 是同步执行的,要模拟“流式”通常需要后端配合 yield
|
||||
# 这里为了演示前端动态读取,我们先显示用户消息,然后模拟一个后台任务
|
||||
|
||||
# 占位符用于显示正在生成的状态或流式内容
|
||||
# 2. 触发重新渲染
|
||||
with st.chat_message("assistant"):
|
||||
message_placeholder = st.empty()
|
||||
message_placeholder.markdown("*思考中...*")
|
||||
|
||||
# === 模拟后端流式响应 (实际项目中此处替换为 requests.post(stream=True)) ===
|
||||
# === 模拟后端流式响应 ===
|
||||
full_response = ""
|
||||
# 构造一个包含 HTML 的回复用于测试
|
||||
simulated_text = f"收到您的指令:**{user_input}**。\n\n这是一个测试回复,如果您开启了 **HTML 渲染**,下方将显示彩色文本和表格:<br><span style='color:#0056B3; font-weight:bold;'>蓝色高亮文本</span><br><table border='1' style='border-collapse:collapse; width:100%;'><tr><th>属性</th><th>值</th></tr><tr><td>状态</td><td>正常</td></tr></table>"
|
||||
|
||||
# 简单的逐字模拟 (实际应来自后端 chunk)
|
||||
chunks = simulated_text.split(" ")
|
||||
for chunk in chunks:
|
||||
full_response += chunk + " "
|
||||
time.sleep(0.1) # 模拟网络延迟
|
||||
time.sleep(0.1)
|
||||
|
||||
if st.session_state.render_html:
|
||||
message_placeholder.markdown(full_response, unsafe_allow_html=True)
|
||||
@@ -293,10 +543,17 @@ with col_mid:
|
||||
# 强制刷新以确保持久化显示
|
||||
st.rerun()
|
||||
|
||||
st.markdown('</div>', unsafe_allow_html=True)
|
||||
|
||||
st.markdown('</div>', unsafe_allow_html=True)
|
||||
|
||||
# =======================
|
||||
# 3. 右侧:图片与骰子 (蓝白风格)
|
||||
# =======================
|
||||
with col_right:
|
||||
# 使用自定义容器类
|
||||
st.markdown('<div class="right-column">', unsafe_allow_html=True)
|
||||
|
||||
st.markdown("#### 🖼️ 本地图库")
|
||||
img_path = Path(st.session_state.image_folder)
|
||||
img_path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -347,3 +604,5 @@ with col_right:
|
||||
unsafe_allow_html=True)
|
||||
with c_r2:
|
||||
st.caption(f"目标:{selected_diff.split('(')[1].strip(')')}")
|
||||
|
||||
st.markdown('</div>', unsafe_allow_html=True)
|
||||
|
||||
@@ -29,7 +29,7 @@ def render_chat_window(backend_url):
|
||||
full_response = ""
|
||||
|
||||
# 模拟流式接收 (实际需使用 requests stream 或 websocket)
|
||||
# POST /api/chat/stream
|
||||
# POST /api/role/stream
|
||||
try:
|
||||
# 伪代码示例:
|
||||
# with requests.post(f"{backend_url}/api/chat/stream", json={"message": prompt}, stream=True) as r:
|
||||
|
||||
8
main.py
8
main.py
@@ -1,13 +1,11 @@
|
||||
from fastapi import FastAPI
|
||||
from backend.api.route import router
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# 注册API路由
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Hello World"}
|
||||
|
||||
|
||||
@app.get("/hello/{name}")
|
||||
async def say_hello(name: str):
|
||||
return {"message": f"Hello {name}"}
|
||||
|
||||
Reference in New Issue
Block a user