Files
ailine/frontend/frontend.py

410 lines
13 KiB
Python
Raw Normal View History

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": "本地 vLLMGemma-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()