2026-04-13 19:49:18 +08:00
|
|
|
|
"""
|
2026-04-16 03:21:38 +08:00
|
|
|
|
右侧栏组件:工具状态和统计信息
|
2026-04-13 19:49:18 +08:00
|
|
|
|
"""
|
2026-04-16 03:21:38 +08:00
|
|
|
|
import streamlit as st
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_info_panel():
|
|
|
|
|
|
st.header("📊 会话信息")
|
|
|
|
|
|
|
|
|
|
|
|
# 当前线程信息
|
|
|
|
|
|
st.subheader("当前对话")
|
|
|
|
|
|
st.code(st.session_state.current_thread_id[:8] + "...", language=None)
|
|
|
|
|
|
|
|
|
|
|
|
st.divider()
|
|
|
|
|
|
|
|
|
|
|
|
# 消息统计
|
|
|
|
|
|
st.subheader("消息统计")
|
|
|
|
|
|
user_msgs = len([m for m in st.session_state.messages if m["role"] == "user"])
|
|
|
|
|
|
assistant_msgs = len([m for m in st.session_state.messages if m["role"] == "assistant"])
|
|
|
|
|
|
|
|
|
|
|
|
st.metric("用户消息", user_msgs)
|
|
|
|
|
|
st.metric("AI 回复", assistant_msgs)
|
|
|
|
|
|
|
|
|
|
|
|
st.divider()
|
|
|
|
|
|
|
|
|
|
|
|
# 使用提示
|
|
|
|
|
|
st.subheader("💡 使用提示")
|
|
|
|
|
|
st.markdown("""
|
|
|
|
|
|
- 左侧可切换历史对话
|
|
|
|
|
|
- 点击"新对话"开始新话题
|
|
|
|
|
|
- 登录后对话历史隔离
|
|
|
|
|
|
- 支持流式实时响应
|
|
|
|
|
|
- 模型可随时切换
|
|
|
|
|
|
""")
|
|
|
|
|
|
"""
|
|
|
|
|
|
中间栏组件:聊天区域
|
|
|
|
|
|
"""
|
|
|
|
|
|
import streamlit as st
|
|
|
|
|
|
from ..config import config
|
|
|
|
|
|
from ..api_client import stream_chat
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_chat_area():
|
|
|
|
|
|
# 模型选择器
|
|
|
|
|
|
col_model, col_empty = st.columns([2, 3])
|
|
|
|
|
|
with col_model:
|
|
|
|
|
|
selected_model_key = st.selectbox(
|
|
|
|
|
|
"🧠 选择模型",
|
|
|
|
|
|
options=list(config.model_options.keys()),
|
|
|
|
|
|
format_func=lambda x: config.model_options[x],
|
|
|
|
|
|
index=list(config.model_options.keys()).index(st.session_state.selected_model) if st.session_state.selected_model in config.model_options else 0
|
|
|
|
|
|
)
|
|
|
|
|
|
st.session_state.selected_model = selected_model_key
|
|
|
|
|
|
|
|
|
|
|
|
st.divider()
|
|
|
|
|
|
|
|
|
|
|
|
# 显示消息历史
|
|
|
|
|
|
chat_container = st.container(height=500)
|
|
|
|
|
|
with chat_container:
|
|
|
|
|
|
for msg in st.session_state.messages:
|
|
|
|
|
|
with st.chat_message(msg["role"]):
|
|
|
|
|
|
st.markdown(msg["content"])
|
|
|
|
|
|
|
|
|
|
|
|
# 输入框
|
|
|
|
|
|
if prompt := st.chat_input("请输入您的问题...", key="chat_input"):
|
|
|
|
|
|
# 显示用户消息
|
|
|
|
|
|
with st.chat_message("user"):
|
|
|
|
|
|
st.markdown(prompt)
|
|
|
|
|
|
st.session_state.messages.append({"role": "user", "content": prompt})
|
|
|
|
|
|
|
|
|
|
|
|
# 流式调用后端
|
|
|
|
|
|
with st.chat_message("assistant"):
|
|
|
|
|
|
message_placeholder = st.empty()
|
|
|
|
|
|
tool_status_placeholder = st.empty()
|
|
|
|
|
|
full_response = ""
|
|
|
|
|
|
|
|
|
|
|
|
stream_gen = stream_chat(
|
|
|
|
|
|
message=prompt,
|
|
|
|
|
|
thread_id=st.session_state.current_thread_id,
|
|
|
|
|
|
model=st.session_state.selected_model,
|
|
|
|
|
|
user_id=st.session_state.user_id
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if stream_gen:
|
|
|
|
|
|
for data in stream_gen:
|
|
|
|
|
|
if data["type"] == "token":
|
|
|
|
|
|
full_response += data["content"]
|
|
|
|
|
|
message_placeholder.markdown(full_response + "▌")
|
|
|
|
|
|
|
|
|
|
|
|
elif data["type"] == "tool_start":
|
|
|
|
|
|
tool_status_placeholder.info(f"🔧 调用工具: {data['tool']}...")
|
|
|
|
|
|
|
|
|
|
|
|
elif data["type"] == "tool_end":
|
|
|
|
|
|
tool_status_placeholder.success(f"✅ 工具 {data['tool']} 完成")
|
|
|
|
|
|
tool_status_placeholder.empty()
|
|
|
|
|
|
|
|
|
|
|
|
elif data["type"] == "done":
|
|
|
|
|
|
# 最终响应
|
|
|
|
|
|
token_usage = data.get("token_usage", {})
|
|
|
|
|
|
elapsed = data.get("elapsed_time", 0)
|
|
|
|
|
|
if token_usage:
|
|
|
|
|
|
st.caption(f"📊 消耗 {token_usage.get('total_tokens', 0)} tokens | ⏱️ {elapsed:.2f}s")
|
|
|
|
|
|
|
|
|
|
|
|
elif data["type"] == "error":
|
|
|
|
|
|
st.error(f"❌ 错误: {data['message']}")
|
|
|
|
|
|
|
|
|
|
|
|
# 显示完整响应
|
|
|
|
|
|
message_placeholder.markdown(full_response)
|
|
|
|
|
|
st.session_state.messages.append({"role": "assistant", "content": full_response})
|
|
|
|
|
|
tool_status_placeholder.empty()
|
|
|
|
|
|
"""
|
|
|
|
|
|
左侧栏组件:用户登录 + 历史对话列表
|
|
|
|
|
|
"""
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
import streamlit as st
|
|
|
|
|
|
from ..state import AppState
|
|
|
|
|
|
from ..api_client import refresh_threads, load_thread_history
|
2026-04-13 19:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 03:21:38 +08:00
|
|
|
|
def render_sidebar():
|
|
|
|
|
|
st.header("👤 用户")
|
|
|
|
|
|
|
|
|
|
|
|
# 用户登录区域
|
|
|
|
|
|
if not st.session_state.logged_in:
|
|
|
|
|
|
username = st.text_input(
|
|
|
|
|
|
"输入用户名(可选)",
|
|
|
|
|
|
key="login_input",
|
|
|
|
|
|
placeholder="留空使用默认用户",
|
|
|
|
|
|
help="未登录将使用 default_user,可能导致对话污染"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if st.button("✅ 进入", type="primary", use_container_width=True):
|
|
|
|
|
|
AppState.login(username)
|
|
|
|
|
|
refresh_threads(st.session_state.user_id)
|
|
|
|
|
|
|
|
|
|
|
|
st.info("💡 建议登录以隔离对话历史")
|
|
|
|
|
|
else:
|
|
|
|
|
|
st.success(f"✅ 当前用户: `{st.session_state.user_id}`")
|
|
|
|
|
|
|
|
|
|
|
|
if st.button("🔄 切换用户", use_container_width=True):
|
|
|
|
|
|
AppState.reset_login()
|
|
|
|
|
|
|
|
|
|
|
|
st.divider()
|
|
|
|
|
|
|
|
|
|
|
|
# 历史对话列表
|
|
|
|
|
|
st.header("📚 对话历史")
|
|
|
|
|
|
|
|
|
|
|
|
# 刷新按钮
|
|
|
|
|
|
if st.button("🔄 刷新列表", use_container_width=True):
|
|
|
|
|
|
refresh_threads(st.session_state.user_id)
|
|
|
|
|
|
|
|
|
|
|
|
# 新对话按钮
|
|
|
|
|
|
if st.button("➕ 新对话", type="primary", use_container_width=True):
|
|
|
|
|
|
AppState.start_new_thread()
|
|
|
|
|
|
|
|
|
|
|
|
st.divider()
|
|
|
|
|
|
|
|
|
|
|
|
# 显示历史列表
|
|
|
|
|
|
if st.session_state.threads:
|
|
|
|
|
|
for thread in st.session_state.threads:
|
|
|
|
|
|
thread_id = thread["thread_id"]
|
|
|
|
|
|
summary = thread.get("summary", "空对话")
|
|
|
|
|
|
message_count = thread.get("message_count", 0)
|
|
|
|
|
|
last_updated = thread.get("last_updated", "")
|
|
|
|
|
|
|
|
|
|
|
|
# 格式化时间
|
|
|
|
|
|
if last_updated:
|
|
|
|
|
|
try:
|
|
|
|
|
|
dt = datetime.fromisoformat(last_updated.replace("Z", "+00:00"))
|
|
|
|
|
|
time_str = dt.strftime("%m-%d %H:%M")
|
|
|
|
|
|
except:
|
|
|
|
|
|
time_str = last_updated[:10]
|
|
|
|
|
|
else:
|
|
|
|
|
|
time_str = "未知"
|
|
|
|
|
|
|
|
|
|
|
|
# 按钮样式
|
|
|
|
|
|
is_current = thread_id == st.session_state.current_thread_id
|
|
|
|
|
|
button_type = "primary" if is_current else "secondary"
|
|
|
|
|
|
|
|
|
|
|
|
if st.button(
|
|
|
|
|
|
f"💬 {summary[:30]}{'...' if len(summary) > 30 else ''}\n\n🕐 {time_str} | {message_count}条",
|
|
|
|
|
|
key=f"thread_{thread_id}",
|
|
|
|
|
|
use_container_width=True,
|
|
|
|
|
|
type=button_type
|
|
|
|
|
|
):
|
|
|
|
|
|
load_thread_history(thread_id, st.session_state.user_id)
|
|
|
|
|
|
else:
|
|
|
|
|
|
st.info("暂无对话历史")
|
|
|
|
|
|
# Components package
|
|
|
|
|
|
"""
|
|
|
|
|
|
后端 API 客户端封装
|
|
|
|
|
|
"""
|
|
|
|
|
|
import json
|
2026-04-13 19:49:18 +08:00
|
|
|
|
import requests
|
|
|
|
|
|
import streamlit as st
|
2026-04-16 03:21:38 +08:00
|
|
|
|
from .config import config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def refresh_threads(user_id: str):
|
|
|
|
|
|
"""刷新用户的历史对话列表"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = requests.get(
|
|
|
|
|
|
f"{config.api_base}/threads",
|
|
|
|
|
|
params={"user_id": user_id, "limit": 50},
|
|
|
|
|
|
timeout=10
|
|
|
|
|
|
)
|
|
|
|
|
|
if resp.status_code == 200:
|
|
|
|
|
|
st.session_state.threads = resp.json()["threads"]
|
|
|
|
|
|
else:
|
|
|
|
|
|
st.error(f"加载历史列表失败: HTTP {resp.status_code}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
st.error(f"加载历史列表失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_thread_history(thread_id: str, user_id: str):
|
|
|
|
|
|
"""加载指定线程的完整消息历史"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = requests.get(
|
|
|
|
|
|
f"{config.api_base}/thread/{thread_id}/messages",
|
|
|
|
|
|
params={"user_id": user_id},
|
|
|
|
|
|
timeout=10
|
|
|
|
|
|
)
|
|
|
|
|
|
if resp.status_code == 200:
|
|
|
|
|
|
st.session_state.messages = resp.json()["messages"]
|
|
|
|
|
|
st.session_state.current_thread_id = thread_id
|
|
|
|
|
|
st.rerun()
|
|
|
|
|
|
else:
|
|
|
|
|
|
st.error(f"加载对话失败: HTTP {resp.status_code}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
st.error(f"加载对话失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stream_chat(message: str, thread_id: str, model: str, user_id: str):
|
|
|
|
|
|
"""流式调用后端聊天接口"""
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"message": message,
|
|
|
|
|
|
"thread_id": thread_id,
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
"user_id": user_id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
with requests.post(
|
|
|
|
|
|
f"{config.api_base}/chat/stream",
|
|
|
|
|
|
json=payload,
|
|
|
|
|
|
stream=True,
|
|
|
|
|
|
timeout=120
|
|
|
|
|
|
) as response:
|
|
|
|
|
|
if response.status_code != 200:
|
|
|
|
|
|
st.error(f"请求失败: HTTP {response.status_code}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
full_response = ""
|
|
|
|
|
|
for line in response.iter_lines():
|
|
|
|
|
|
if line:
|
|
|
|
|
|
line = line.decode('utf-8')
|
|
|
|
|
|
if line.startswith("data: "):
|
|
|
|
|
|
data_str = line[6:]
|
|
|
|
|
|
if data_str == "[DONE]":
|
|
|
|
|
|
break
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = json.loads(data_str)
|
|
|
|
|
|
yield data
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return full_response
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
st.error(f"请求失败: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
"""
|
|
|
|
|
|
Session State 管理
|
|
|
|
|
|
"""
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
import streamlit as st
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AppState:
|
|
|
|
|
|
"""管理 Streamlit Session State"""
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def init():
|
|
|
|
|
|
"""初始化必要的 session state 变量"""
|
|
|
|
|
|
if "user_id" not in st.session_state:
|
|
|
|
|
|
st.session_state.user_id = "default_user"
|
|
|
|
|
|
if "logged_in" not in st.session_state:
|
|
|
|
|
|
st.session_state.logged_in = False
|
|
|
|
|
|
if "threads" not in st.session_state:
|
|
|
|
|
|
st.session_state.threads = []
|
|
|
|
|
|
if "current_thread_id" not in st.session_state:
|
|
|
|
|
|
st.session_state.current_thread_id = str(uuid.uuid4())
|
|
|
|
|
|
if "messages" not in st.session_state:
|
|
|
|
|
|
st.session_state.messages = []
|
|
|
|
|
|
if "selected_model" not in st.session_state:
|
|
|
|
|
|
st.session_state.selected_model = "zhipu"
|
|
|
|
|
|
if "loading_history" not in st.session_state:
|
|
|
|
|
|
st.session_state.loading_history = False
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def reset_login():
|
|
|
|
|
|
"""重置登录状态"""
|
|
|
|
|
|
st.session_state.logged_in = False
|
|
|
|
|
|
st.session_state.user_id = "default_user"
|
|
|
|
|
|
st.session_state.threads = []
|
|
|
|
|
|
st.rerun()
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def login(username: str):
|
|
|
|
|
|
"""执行登录"""
|
|
|
|
|
|
st.session_state.user_id = username.strip() if username.strip() else "default_user"
|
|
|
|
|
|
st.session_state.logged_in = True
|
|
|
|
|
|
st.rerun()
|
2026-04-13 19:49:18 +08:00
|
|
|
|
|
2026-04-16 03:21:38 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def start_new_thread():
|
|
|
|
|
|
"""开始新对话"""
|
|
|
|
|
|
st.session_state.current_thread_id = str(uuid.uuid4())
|
2026-04-13 19:49:18 +08:00
|
|
|
|
st.session_state.messages = []
|
|
|
|
|
|
st.rerun()
|
2026-04-16 03:21:38 +08:00
|
|
|
|
"""
|
|
|
|
|
|
应用配置
|
|
|
|
|
|
"""
|
|
|
|
|
|
import os
|
|
|
|
|
|
from dataclasses import dataclass
|
2026-04-13 19:49:18 +08:00
|
|
|
|
|
2026-04-16 03:21:38 +08:00
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class AppConfig:
|
|
|
|
|
|
page_title: str = "AI 个人助手"
|
|
|
|
|
|
page_icon: str = "🤖"
|
|
|
|
|
|
layout: str = "wide"
|
|
|
|
|
|
# 后端 API 地址配置
|
|
|
|
|
|
# 优先级:环境变量 API_URL > Docker 内部服务名 > 本地开发地址
|
|
|
|
|
|
api_base: str = os.getenv("API_URL", "http://localhost:8001").replace("/chat", "")
|
|
|
|
|
|
|
|
|
|
|
|
model_options: dict = None
|
|
|
|
|
|
|
|
|
|
|
|
def __post_init__(self):
|
|
|
|
|
|
if self.model_options is None:
|
|
|
|
|
|
self.model_options = {
|
|
|
|
|
|
"zhipu": "智谱 GLM-4.7-Flash(在线)",
|
|
|
|
|
|
"deepseek": "DeepSeek V3.2(在线)",
|
|
|
|
|
|
"local": "本地 vLLM(Gemma-4)"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
config = AppConfig()
|
|
|
|
|
|
"""
|
|
|
|
|
|
AI Agent 前端主入口
|
|
|
|
|
|
采用模块化架构,仅负责组装各组件
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
|
|
# 添加项目根目录到 Python 路径,支持绝对导入
|
|
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
|
|
|
|
|
|
import streamlit as st
|
|
|
|
|
|
|
|
|
|
|
|
# 使用绝对导入
|
|
|
|
|
|
from frontend.config import config
|
|
|
|
|
|
from frontend.state import AppState
|
|
|
|
|
|
from frontend.components.sidebar import render_sidebar
|
|
|
|
|
|
from frontend.components.chat_area import render_chat_area
|
|
|
|
|
|
from frontend.components.info_panel import render_info_panel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 页面配置
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
st.set_page_config(
|
|
|
|
|
|
page_title=config.page_title,
|
|
|
|
|
|
page_icon=config.page_icon,
|
|
|
|
|
|
layout=config.layout
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 初始化状态
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
AppState.init()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 主界面
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
def main():
|
|
|
|
|
|
"""主界面渲染 - 三栏布局"""
|
|
|
|
|
|
# 标题
|
|
|
|
|
|
st.title("🤖 个人生活与数据分析助手")
|
|
|
|
|
|
|
|
|
|
|
|
# 三栏布局:左侧栏(1) + 中间栏(3) + 右侧栏(1)
|
|
|
|
|
|
col_sidebar, col_chat, col_info = st.columns([1, 3, 1])
|
|
|
|
|
|
|
|
|
|
|
|
# 左侧栏:用户登录 + 历史对话
|
|
|
|
|
|
with col_sidebar:
|
|
|
|
|
|
render_sidebar()
|
|
|
|
|
|
|
|
|
|
|
|
# 中间栏:模型选择 + 聊天区域 + 输入框
|
|
|
|
|
|
with col_chat:
|
|
|
|
|
|
render_chat_area()
|
|
|
|
|
|
|
|
|
|
|
|
# 右侧栏:会话信息 + 统计 + 使用提示
|
|
|
|
|
|
with col_info:
|
|
|
|
|
|
render_info_panel()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|