初始化仅前端版本

This commit is contained in:
2026-03-16 21:47:06 +08:00
parent 43adf1a7c4
commit 5a78b7b392
25 changed files with 699 additions and 153 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,21 @@ FROM python:3.11-slim
# 设置工作目录 # 设置工作目录
WORKDIR /app WORKDIR /app
# 复制依赖文件并安装 # 复制依赖文件
# 注意:这里的 requirements.txt 在 backend/ 目录下
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/app/...
COPY . ./backend/
# 暴露端口 # 暴露端口
EXPOSE 8000 EXPOSE 8000
# 启动命令 # 启动命令
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] # 关键修改:路径改为 backend.app.api.route
CMD ["uvicorn", "backend.app.api.route:app", "--host", "0.0.0.0", "--port", "8000"]

18
backend/api/route.py Normal file
View File

@@ -0,0 +1,18 @@
from fastapi import FastAPI
# 假设这些函数已经在其他地方定义
from backend.core.items import ChatRequest
from backend.tools.get_all_role_and_chat import get_all_role_and_chat as get_chat_file
app = FastAPI()
# 1. 将输入内容持久化存储到本地jsonl方便前端读
@app.post("/generate_reply")
async def save_input_to_json(chat_request: ChatRequest):
return 0
# 2. 从本地jsonl中读取历史对话
@app.get("/get_all_role_and_chat")
def get_all_role_and_chat():
# 直接调用导入的函数
result = get_chat_file()
return result

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

View File

@@ -7,7 +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.parent # 修改这里,添加一个 .parent
# 2. 加载 .env 文件 # 2. 加载 .env 文件
# 假设 .env 文件位于项目根目录下 # 假设 .env 文件位于项目根目录下
@@ -38,8 +38,10 @@ 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()
if __name__ == '__main__':
# 实例化配置对象 # 实例化配置对象
settings = Settings() settings = Settings()
print(settings.BASE_PATH)

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

12
backend/main.py Normal file
View File

@@ -0,0 +1,12 @@
# backend/app/main.py
from fastapi import FastAPI
from .api.routes 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

View File

@@ -0,0 +1,46 @@
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':
jsonl_files.append(str(file))
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
def save_input_to_json( # 假设 ChatRequest 定义在这里或者从其他地方导入
mes: str, # from backend.app.core.items import ChatRequest
role_name: str,
chat_name: str, async def save_input_to_json(chat_request: ChatRequest):
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
async def test():
req = MockChatRequest(
mes="这是重roll后的新回复2", mes="这是重roll后的新回复2",
role_name="test", role_name="test",
chat_name="111", chat_name="111",
name="AI", name="AI",
is_user=False, is_user=False,
floor_number=2 # 与当前楼层号相同表示重roll 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

View File

View File

@@ -1,26 +1,29 @@
version: '3.8' version: '3.8'
services: services:
# 后端服务
backend: backend:
build: ./backend # 使用 backend 目录下的 Dockerfile 构建 build: ./backend
command: uvicorn backend.api.route:app --host 0.0.0.0 --port 8000 --reload
ports: ports:
- "8000:8000" # 映射端口主机8000 -> 容器8000 - "3001:8000"
volumes: volumes:
- ./data:/data # 挂载数据目录(持久化) - .:/app
- ./outputs:/outputs # 挂载输出目录 - ./data:/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: ./frontend
command: streamlit run app.py --server.port=8501 --server.address=0.0.0.0 --server.fileWatcherType=poll
ports: ports:
- "8501:8501" # 映射端口主机8501 -> 容器8501 - "3000:8501"
environment: environment:
- BACKEND_URL=http://backend:8000 # 后端内部地址 - BACKEND_URL=http://backend:8000
- PYTHONUNBUFFERED=1
volumes:
- ./frontend:/app # 确保宿主机路径与容器内路径一致
depends_on: depends_on:
- backend # 等后端启动后再启动前端 - backend
restart: always restart: unless-stopped

View File

@@ -6,7 +6,7 @@ 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 . . COPY . .
@@ -14,5 +14,5 @@ COPY . .
# 暴露端口 # 暴露端口
EXPOSE 8501 EXPOSE 8501
# 启动命令 # 启动命令(使用 8501 端口,与 docker-compose 映射保持一致)
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"] CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]

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,8 +275,33 @@ 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
# --- 顶部工具栏 --- # --- 顶部工具栏 ---
# 工具栏切换按钮
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]) c_top1, c_top2, c_top3 = st.columns([1, 6, 1])
with c_top1: with c_top1:
if st.button("📂 打开", key="btn_open"): if st.button("📂 打开", key="btn_open"):
@@ -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 中的消息历史 # 使用自定义容器类包裹聊天区域
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"]): with st.chat_message(msg["role"]):
content = msg["content"] content = msg["content"]
# 根据开关决定是否解析 HTML # 根据开关决定是否解析 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)
# 可选:在每条消息下添加操作按钮 (编辑/复制/重试) - 预留 # 显示其他信息
# 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("输入消息... (支持 /命令)") 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: