14 Commits

62 changed files with 7975 additions and 168 deletions

11
.env
View File

@@ -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
View File

@@ -6,3 +6,4 @@
# Datasource local storage ignored files # Datasource local storage ignored files
/dataSources/ /dataSources/
/dataSources.local.xml /dataSources.local.xml
.env

View File

@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4"> <module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager"> <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="jdk" jdkName="Python 3.9 (pythonProject1)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>

View File

@@ -4,15 +4,20 @@ FROM python:3.11-slim
# 设置工作目录 # 设置工作目录
WORKDIR /app WORKDIR /app
# 复制依赖文件并安装 # 复制依赖文件
COPY requirements.txt . 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 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"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

18
backend/api/route.py Normal file
View 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()

View File

@@ -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
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -7,8 +7,7 @@ from dotenv import load_dotenv
# __file__ 指向本文件的绝对路径 # __file__ 指向本文件的绝对路径
# .parent 指向 backend/ 目录 # .parent 指向 backend/ 目录
# .parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录) # .parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录)
PROJECT_ROOT = Path(__file__).resolve().parent PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# 2. 加载 .env 文件 # 2. 加载 .env 文件
# 假设 .env 文件位于项目根目录下 # 假设 .env 文件位于项目根目录下
load_dotenv(PROJECT_ROOT / ".env") load_dotenv(PROJECT_ROOT / ".env")
@@ -38,8 +37,12 @@ class Settings:
REGEX_FILE = DATA_PATH / "regex_rules.json" REGEX_FILE = DATA_PATH / "regex_rules.json"
VECTORSTORE_PATH = DATA_PATH / "vectorstore" VECTORSTORE_PATH = DATA_PATH / "vectorstore"
# ... 其他配置 ...
# 实例化配置对象
settings = Settings() 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
View 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

View 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
View 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)

View File

View File

Binary file not shown.

View 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

View File

@@ -1,33 +1,37 @@
import json import json
from typing import Dict, Any
from datetime import datetime from datetime import datetime
import config as cfg from backend.core import config as cfg
from pathlib import Path from pathlib import Path
from ..core.items import ChatRequest
# 假设 ChatRequest 定义在这里或者从其他地方导入
# from backend.app.core.items import ChatRequest
def save_input_to_json( async def save_input_to_json(chat_request: ChatRequest):
mes: str,
role_name: str,
chat_name: str,
name: str,
is_user: bool,
floor_number: int = 0
) -> Dict[str, Any]:
""" """
保存消息到JSONL文件或处理重roll请求 保存消息到JSONL文件或处理重roll请求
参数: 参数:
mes: 消息内容 chat_request: 包含消息详情的请求对象
role_name: 角色名称
chat_name: 对话名称
name: 发送者名称
is_user: 是否为用户消息
floor_number: 楼层号(对话中的第几次回复)用于判断是否为重roll请求
返回:
更新后的消息对象
""" """
# 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 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" 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__': if __name__ == '__main__':
# 测试普通消息保存 # 注意:为了在本地运行测试,你需要手动构造一个 ChatRequest 对象
# save_input_to_json( # 或者临时修改函数签名以便直接传参测试
# mes="你好",
# role_name="test", # 示例:假设 ChatRequest 是一个简单的类或 Pydantic 模型
# chat_name="111", class MockChatRequest:
# name="用户", def __init__(self, **kwargs):
# is_user=True, self.mes = kwargs.get('mes')
# floor_number=0 self.role_name = kwargs.get('role_name')
# ) self.chat_name = kwargs.get('chat_name')
# self.name = kwargs.get('name')
# save_input_to_json( self.is_user = kwargs.get('is_user')
# mes="你好我是AI助手", self.floor_number = kwargs.get('floor_number')
# role_name="test",
# chat_name="111",
# name="AI",
# is_user=False,
# floor_number=1
# )
# 测试重roll最后一条AI消息 # 测试重roll最后一条AI消息
save_input_to_json( import asyncio
mes="这是重roll后的新回复2",
role_name="test",
chat_name="111", async def test():
name="AI", req = MockChatRequest(
is_user=False, mes="这是重roll后的新回复2",
floor_number=2 # 与当前楼层号相同表示重roll role_name="test",
) chat_name="111",
name="AI",
is_user=False,
floor_number=2
)
await save_input_to_json(req)
asyncio.run(test())

View File

View 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
View File

View File

View File

View File

@@ -1,26 +1,34 @@
version: '3.8' version: '3.8'
services: services:
# 后端服务
backend: backend:
build: ./backend # 使用 backend 目录下的 Dockerfile 构建 build: ./backend
command: uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
ports: ports:
- "8000:8000" # 映射端口主机8000 -> 容器8000 - "3001:8000"
volumes: volumes:
- ./data:/data # 挂载数据目录(持久化) - ./backend:/app/backend
- ./outputs:/outputs # 挂载输出目录 - ./data:/app/data
- ./outputs:/outputs
environment: environment:
- DATA_PATH=/data - PYTHONUNBUFFERED=1
- OUTPUT_PATH=/outputs restart: unless-stopped
restart: always
# 前端服务
frontend: frontend:
build: ./frontend # 使用 frontend 目录下的 Dockerfile 构建 build:
context: ./frontend-react
dockerfile: Dockerfile
ports: ports:
- "8501:8501" # 映射端口主机8501 -> 容器8501 - "3000:5173"
volumes:
- ./frontend-react:/app
- /app/node_modules
environment: 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: depends_on:
- backend # 等后端启动后再启动前端 - backend
restart: always restart: unless-stopped

26
frontend-react/Dockerfile Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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;

View 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;

View File

@@ -0,0 +1,2 @@
// frontend-react/src/store/index.js
export { default as useRoleSelectorStore } from './roleSelectorStore';

View 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;
}

View 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;

View File

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

View File

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

View File

@@ -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;

View File

@@ -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;

View 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;
}

View 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;

View 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;
}
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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>
);

View 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,
}
}
}
})

View File

@@ -1,18 +1,22 @@
# 使用 Python 3.11 基础镜像 # 使用 Node.js 18 Alpine 镜像作为基础
FROM python:3.11-slim FROM node:18-alpine
# 设置工作目录 # 设置工作目录
WORKDIR /app WORKDIR /app
# 复制依赖文件并安装 # 设置 npm 镜像源(可选,国内推荐使用,加速依赖下载)
COPY requirements.txt . RUN npm config set registry https://registry.npmmirror.com/
RUN pip install --no-cache-dir -r requirements.txt
# 复制所有代码 # 复制 package.json 和 package-lock.json
COPY . . # 利用 Docker 缓存层,只有依赖变更时才重新安装
COPY package.json package-lock.json* ./
# 暴露端口 # 安装依赖
EXPOSE 8501 RUN npm install
# 启动命令 # 暴露 Vite 默认端口 5173
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"] EXPOSE 5173
# 启动命令由 docker-compose.yml 中的 command 覆盖,
# 这里保留默认的 dev 命令作为 fallback
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

Binary file not shown.

View File

@@ -1,3 +1,4 @@
import requests
import streamlit as st import streamlit as st
import os import os
from pathlib import Path from pathlib import Path
@@ -11,6 +12,8 @@ st.set_page_config(
layout="wide", layout="wide",
initial_sidebar_state="expanded" 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 (蓝白清晰风格) --- # --- 自定义 CSS (蓝白清晰风格) ---
st.markdown(""" st.markdown("""
@@ -114,7 +117,142 @@ st.markdown("""
justify-content: space-between; justify-content: space-between;
align-items: center; 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> </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) """, unsafe_allow_html=True)
# --- 状态初始化 --- # --- 状态初始化 ---
@@ -137,19 +275,44 @@ if "splice_blocks" not in st.session_state:
{"id": 3, "name": "世界书:人物关系", "active": False, "type": "world"}, {"id": 3, "name": "世界书:人物关系", "active": False, "type": "world"},
{"id": 4, "name": "Chat History (自动)", "active": True, "type": "history", "editable": False}, {"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("""
if st.button("📂 打开", key="btn_open"): <div class="toolbar-toggle" onclick="toggleToolbar()">
st.toast("打开会话功能预留") <span id="toolbar-icon">▼</span>
if st.button("💾 保存", key="btn_save"): </div>
st.toast("会话已保存") <script>
with c_top2: function toggleToolbar() {
st.markdown("<h3 style='margin:0; color:#0056B3;'>AI WorkFlow Engine</h3>", unsafe_allow_html=True) var toolbar = document.querySelector('.collapsible-toolbar');
with c_top3: var icon = document.getElementById('toolbar-icon');
if st.button("⚙️ 设置", key="btn_settings"): if (toolbar.style.display === 'none') {
st.toast("全局设置预留") 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:
st.markdown("<h3 style='margin:0; color:#0056B3;'>AI WorkFlow Engine</h3>", unsafe_allow_html=True)
with c_top3:
if st.button("⚙️ 设置", key="btn_settings"):
st.toast("全局设置预留")
st.divider() st.divider()
@@ -160,6 +323,9 @@ col_left, col_mid, col_right = st.columns([1, 3, 1], gap="small")
# 1. 左侧:预设与拼接管理 (蓝白风格适配) # 1. 左侧:预设与拼接管理 (蓝白风格适配)
# ======================= # =======================
with col_left: with col_left:
# 使用自定义容器类
st.markdown('<div class="left-column">', unsafe_allow_html=True)
st.markdown("#### 📜 全局预设") st.markdown("#### 📜 全局预设")
c_pre1, c_pre2 = st.columns([4, 1]) c_pre1, c_pre2 = st.columns([4, 1])
with c_pre1: with c_pre1:
@@ -216,44 +382,134 @@ with col_left:
if st.button("+ 添加拼接块", use_container_width=True): if st.button("+ 添加拼接块", use_container_width=True):
st.toast("添加新功能预留") st.toast("添加新功能预留")
st.markdown('</div>', unsafe_allow_html=True)
# ======================= # =======================
# 2. 中间:流式对话区 (动态读取历史) # 2. 中间:流式对话区 (动态读取历史)
# ======================= # =======================
with col_mid: 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: 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: 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 渲染切换 # HTML 渲染切换
toggle_html = st.toggle("HTML 渲染", value=st.session_state.render_html, key="html_toggle") toggle_html = st.toggle("HTML 渲染", value=st.session_state.render_html, key="html_toggle")
if toggle_html != st.session_state.render_html: if toggle_html != st.session_state.render_html:
st.session_state.render_html = toggle_html st.session_state.render_html = toggle_html
st.rerun() st.rerun()
# --- 核心:动态渲染历史记录 --- # 显示当前会话信息
# 使用 st.container 包裹,确保每次 rerun 都能完整重绘 if selected_dataset and 'selected_file_path' in st.session_state:
chat_container = st.container() 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 中的消息历史 # 使用自定义容器类包裹聊天区域
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":
st.markdown(content, unsafe_allow_html=True)
else:
st.markdown(content)
# 显示其他信息
with st.expander("详细信息", expanded=False):
st.json(msg)
else:
# 否则显示session_state中的消息历史
for i, msg in enumerate(st.session_state.messages): for i, msg in enumerate(st.session_state.messages):
with st.chat_message(msg["role"]): with st.chat_message(msg["role"]):
content = msg["content"] content = msg["content"]
# 根据开关决定是否解析 HTML
if st.session_state.render_html and msg["role"] == "assistant": if st.session_state.render_html and msg["role"] == "assistant":
# 允许 HTML 标签
st.markdown(content, unsafe_allow_html=True) st.markdown(content, unsafe_allow_html=True)
else: else:
# 纯文本/Markdown 模式
st.markdown(content) st.markdown(content)
# 可选:在每条消息下添加操作按钮 (编辑/复制/重试) - 预留 st.markdown('</div>', unsafe_allow_html=True)
# with st.expander("...", expanded=False): ...
# --- 输入区域 --- # --- 输入区域 ---
st.markdown("---") # 使用自定义容器类包裹输入区域
st.markdown('<div class="input-area">', unsafe_allow_html=True)
# 聊天输入框 # 聊天输入框
user_input = st.chat_input("输入消息... (支持 /命令)") user_input = st.chat_input("输入消息... (支持 /命令)")
@@ -262,25 +518,19 @@ with col_mid:
# 1. 将用户输入加入历史 # 1. 将用户输入加入历史
st.session_state.messages.append({"role": "user", "content": user_input}) st.session_state.messages.append({"role": "user", "content": user_input})
# 2. 触发重新渲染,此时上方循环会立即显示用户消息 # 2. 触发重新渲染
# 注意Streamlit 是同步执行的,要模拟“流式”通常需要后端配合 yield
# 这里为了演示前端动态读取,我们先显示用户消息,然后模拟一个后台任务
# 占位符用于显示正在生成的状态或流式内容
with st.chat_message("assistant"): with st.chat_message("assistant"):
message_placeholder = st.empty() message_placeholder = st.empty()
message_placeholder.markdown("*思考中...*") message_placeholder.markdown("*思考中...*")
# === 模拟后端流式响应 (实际项目中此处替换为 requests.post(stream=True)) === # === 模拟后端流式响应 ===
full_response = "" 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>" 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(" ") chunks = simulated_text.split(" ")
for chunk in chunks: for chunk in chunks:
full_response += chunk + " " full_response += chunk + " "
time.sleep(0.1) # 模拟网络延迟 time.sleep(0.1)
if st.session_state.render_html: if st.session_state.render_html:
message_placeholder.markdown(full_response, unsafe_allow_html=True) message_placeholder.markdown(full_response, unsafe_allow_html=True)
@@ -293,10 +543,17 @@ with col_mid:
# 强制刷新以确保持久化显示 # 强制刷新以确保持久化显示
st.rerun() st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
st.markdown('</div>', unsafe_allow_html=True)
# ======================= # =======================
# 3. 右侧:图片与骰子 (蓝白风格) # 3. 右侧:图片与骰子 (蓝白风格)
# ======================= # =======================
with col_right: with col_right:
# 使用自定义容器类
st.markdown('<div class="right-column">', unsafe_allow_html=True)
st.markdown("#### 🖼️ 本地图库") st.markdown("#### 🖼️ 本地图库")
img_path = Path(st.session_state.image_folder) img_path = Path(st.session_state.image_folder)
img_path.mkdir(parents=True, exist_ok=True) img_path.mkdir(parents=True, exist_ok=True)
@@ -347,3 +604,5 @@ with col_right:
unsafe_allow_html=True) unsafe_allow_html=True)
with c_r2: with c_r2:
st.caption(f"目标:{selected_diff.split('(')[1].strip(')')}") st.caption(f"目标:{selected_diff.split('(')[1].strip(')')}")
st.markdown('</div>', unsafe_allow_html=True)

View File

@@ -29,7 +29,7 @@ def render_chat_window(backend_url):
full_response = "" full_response = ""
# 模拟流式接收 (实际需使用 requests stream 或 websocket) # 模拟流式接收 (实际需使用 requests stream 或 websocket)
# POST /api/chat/stream # POST /api/role/stream
try: try:
# 伪代码示例: # 伪代码示例:
# with requests.post(f"{backend_url}/api/chat/stream", json={"message": prompt}, stream=True) as r: # with requests.post(f"{backend_url}/api/chat/stream", json={"message": prompt}, stream=True) as r:

View File

@@ -1,13 +1,11 @@
from fastapi import FastAPI from fastapi import FastAPI
from backend.api.route import router
app = FastAPI() app = FastAPI()
# 注册API路由
app.include_router(router, prefix="/api")
@app.get("/") @app.get("/")
async def root(): async def root():
return {"message": "Hello World"} return {"message": "Hello World"}
@app.get("/hello/{name}")
async def say_hello(name: str):
return {"message": f"Hello {name}"}