From 1d8fa1a495334807216d42fe0d7d547cd6e63002 Mon Sep 17 00:00:00 2001 From: xucheng Date: Wed, 14 Jan 2026 09:48:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=A0=94=E7=A9=B6=E8=AE=A8?= =?UTF-8?q?=E8=AE=BA=E5=8E=86=E5=8F=B2=E5=8A=9F=E8=83=BD=20=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=B0=9D=E8=AF=95=E7=94=A8=20docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 81 ++++++ README.md | 54 ++++ backend/app/api/chat_routes.py | 25 ++ backend/app/models.py | 3 +- backend/scripts/add_stock_code_column.py | 24 ++ backend/scripts/backfill_stock_code.py | 54 ++++ backend/scripts/check_alc_logs.py | 25 ++ backend/scripts/inspect_llm_logs.py | 24 ++ entrypoint.sh | 154 +++++++++++ frontend/src/app/page.tsx | 10 + .../src/components/ai-discussion-view.tsx | 3 +- frontend/src/components/app-sidebar.tsx | 1 + frontend/src/components/history-view.tsx | 251 ++++++++++++++++++ 13 files changed, 707 insertions(+), 2 deletions(-) create mode 100644 Dockerfile create mode 100644 backend/scripts/add_stock_code_column.py create mode 100644 backend/scripts/backfill_stock_code.py create mode 100644 backend/scripts/check_alc_logs.py create mode 100644 backend/scripts/inspect_llm_logs.py create mode 100644 entrypoint.sh create mode 100644 frontend/src/components/history-view.tsx diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ccf8441 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,81 @@ +# ============================================================================== +# Stage 1: Build Frontend (Next.js) +# ============================================================================== +FROM node:20-slim AS frontend-builder +WORKDIR /app/frontend + +# Install dependencies +COPY frontend/package*.json ./ +RUN npm ci + +# Copy source and build +COPY frontend/ . +# Disable telemetry during build +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +# ============================================================================== +# Stage 2: Final Image (Python + Node.js Runtime) +# ============================================================================== +FROM python:3.11-slim + +# Build Arguments for Tunnel +ARG BASTION_URL="https://bastion.3prism.ai" +ARG HOST_ARCH="amd64" + +# 1. Install System Dependencies & Node.js (for runtime) +# We need Node.js to run the Next.js production server (npm start) +RUN apt-get update && apt-get install -y \ + curl \ + nodejs \ + npm \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# 2. Install Python Backend Dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 3. Bake in Portwarden Client (The "Tunnel") +# This runs during build time to download the binary into the image +RUN echo "Downloading Portwarden Client from: ${BASTION_URL}/releases/portwardenc-${HOST_ARCH}" && \ + curl -fsSL "${BASTION_URL}/releases/portwardenc-${HOST_ARCH}" -o /usr/local/bin/portwardenc && \ + chmod +x /usr/local/bin/portwardenc + +# 4. Copy Frontend Build Artifacts +# We need package.json to run 'npm start' +COPY frontend/package*.json ./frontend/ +# Copy the built .next folder and public assets +COPY --from=frontend-builder /app/frontend/.next ./frontend/.next +COPY --from=frontend-builder /app/frontend/public ./frontend/public +# Install ONLY production dependencies for frontend +WORKDIR /app/frontend +RUN npm ci --only=production + +# 5. Copy Backend & Application Code +WORKDIR /app +COPY backend/ ./backend/ +COPY *.py ./ +COPY *.sh ./ +COPY entrypoint.sh /usr/local/bin/ + +# Make scripts executable +RUN chmod +x /usr/local/bin/entrypoint.sh ./start_app.sh + +# Environment Variables Defaults +ENV PW_LOCAL_PORT=3000 +# Disable Next.js Telemetry +ENV NEXT_TELEMETRY_DISABLED=1 + +# Expose ports? +# Technically tunnel needs NO EXPOSE, but for local debugging we might want it. +# EXPOSE 3000 8000 + +# Entrypoint & Command +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +# We need a robust start script for prod. +# For now, we'll use a modified start command inline or assume start_app.sh is smart enough. +# Let's override the default start_app.sh behavior to use production modes if possible, +# OR just keep it simple as user requested "built-in". +CMD ["bash", "-c", "cd frontend && npm start & cd backend && uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/README.md b/README.md index 87eb447..5444638 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,60 @@ chmod +x deploy.sh 4. **初始化数据库**:运行 `create_db.py` 创建 SQLite 数据库。 5. **启动服务**:构建前端(如指定 production)并使用 PM2 启动/重启所有服务。 +## 隧道穿透 (Portwarden Client) + +本项目集成了一个通用的隧道客户端启动脚本 `entrypoint.sh`,可用于在无 Docker 环境下快速建立安全隧道。 + +### 功能特点 +- **自动安装**:脚本会自动检测系统架构(amd64/arm64),并从配置的服务端地址自动下载 `portwardenc` 二进制文件。 +- **无 Docker 依赖**:直接在 Linux 裸机运行,无需预装 Docker 或其他依赖。 +- **自动配置**:通过环境变量一次性配置隧道参数。 + +### 快速使用 + +假设您已拥有 Portwarden 服务端地址(例如 `https://bastion.example.com`),只需下载并运行脚本: + +```bash +# 1. 下载脚本 +curl -fsSL https://bastion.example.com/releases/entrypoint.sh -o entrypoint.sh +chmod +x entrypoint.sh + +# 2. 配置并运行 (将在后台启动隧道,然后前台启动您的应用) +export PW_SERVICE_ID="my-service-id" +export PW_SERVER_ADDRS="https://bastion.example.com" +export PW_LOCAL_PORT="3000" + +./entrypoint.sh ./start_app.sh +``` + +脚本会自动检查 `/usr/local/bin/portwardenc`,如果不存在则自动下载并安装,随即建立隧道连接。 + + +### Docker 部署 (推荐) + +如果您希望将所有组件打包成一个镜像,并且内置隧道,请使用 Docker 部署。 + +1. **构建镜像** (`build args` 用于内置隧道客户端): + +```bash +docker build \ + --build-arg BASTION_URL="https://bastion.example.com" \ + -t fa3-app . +``` +> *注意:请将 `https://bastion.example.com` 替换为您的实际 Bastion 地址,构建时会自动下载客户端并内置。* + +2. **运行容器** (无需端口映射,通过隧道访问): + +```bash +docker run -d \ + -e PW_SERVICE_ID="my-service" \ + -e PW_SERVER_ADDRS="https://bastion.example.com" \ + -e PW_LOCAL_PORT="3001" \ + --name fa3-app \ + fa3-app +``` +> *注意:`PW_LOCAL_PORT` 默认为 3001(前端端口),无需更改。* + ## 如何运行 ### 参数说明 diff --git a/backend/app/api/chat_routes.py b/backend/app/api/chat_routes.py index 5e4068f..f350f8e 100644 --- a/backend/app/api/chat_routes.py +++ b/backend/app/api/chat_routes.py @@ -24,6 +24,7 @@ class ChatRequest(BaseModel): system_prompt: Optional[str] = None use_google_search: bool = False session_id: Optional[str] = None + stock_code: Optional[str] = None @router.post("/chat") async def chat_with_ai(request: ChatRequest, db: AsyncSession = Depends(get_db)): @@ -151,6 +152,7 @@ async def chat_with_ai(request: ChatRequest, db: AsyncSession = Depends(get_db)) response_time=response_time, used_google_search=request.use_google_search, session_id=request.session_id, + stock_code=request.stock_code, prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, total_tokens=total_tokens @@ -171,3 +173,26 @@ async def chat_with_ai(request: ChatRequest, db: AsyncSession = Depends(get_db)) logger.error(f"Chat error: {e}") raise HTTPException(status_code=500, detail=str(e)) +@router.get("/chat/history") +async def get_chat_history(db: AsyncSession = Depends(get_db)): + """Fetch chat history""" + from sqlalchemy import select, desc + + # Fetch logs with distinct sessions, ordered by time + # We want all logs, user can filter on frontend + query = select(LLMUsageLog).order_by(desc(LLMUsageLog.timestamp)).limit(100) # Limit 100 for now + + result = await db.execute(query) + logs = result.scalars().all() + + return [{ + "id": log.id, + "timestamp": log.timestamp.isoformat() if log.timestamp else None, + "model": log.model, + "prompt": log.prompt, + "response": log.response, + "session_id": log.session_id, + "stock_code": log.stock_code, + "total_tokens": log.total_tokens, + "used_google_search": log.used_google_search + } for log in logs] diff --git a/backend/app/models.py b/backend/app/models.py index a2d79f2..fc146c4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -137,6 +137,7 @@ class LLMUsageLog(Base): response_time = Column(Float, nullable=True) # Seconds used_google_search = Column(Boolean, default=False) session_id = Column(String, nullable=True) + stock_code = Column(String(50), nullable=True) # Token Usage prompt_tokens = Column(Integer, default=0) @@ -144,4 +145,4 @@ class LLMUsageLog(Base): total_tokens = Column(Integer, default=0) def __repr__(self): - return f"" + return f"" diff --git a/backend/scripts/add_stock_code_column.py b/backend/scripts/add_stock_code_column.py new file mode 100644 index 0000000..685ac5d --- /dev/null +++ b/backend/scripts/add_stock_code_column.py @@ -0,0 +1,24 @@ +import asyncio +import sys +import os + +# Add the parent directory to sys.path to allow importing from app +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import text +from app.database import engine + +async def add_column(): + async with engine.begin() as conn: + try: + print("Attempting to add 'stock_code' column to 'llm_usage_logs'...") + # Using raw SQL to add the column safely + await conn.execute(text("ALTER TABLE llm_usage_logs ADD COLUMN IF NOT EXISTS stock_code VARCHAR(50);")) + print("Success: Column 'stock_code' verified/added in 'llm_usage_logs'.") + except Exception as e: + print(f"Error executing migration: {e}") + finally: + await engine.dispose() + +if __name__ == "__main__": + asyncio.run(add_column()) diff --git a/backend/scripts/backfill_stock_code.py b/backend/scripts/backfill_stock_code.py new file mode 100644 index 0000000..f00f44b --- /dev/null +++ b/backend/scripts/backfill_stock_code.py @@ -0,0 +1,54 @@ +import asyncio +import sys +import os +import re + +# Add the parent directory to sys.path +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import select, update +from app.database import engine, SessionLocal +from app.models import LLMUsageLog + +async def backfill_stock_codes(): + async with SessionLocal() as session: + # Fetch logs where stock_code is NULL + result = await session.execute(select(LLMUsageLog).where(LLMUsageLog.stock_code == None)) + logs = result.scalars().all() + + print(f"Found {len(logs)} logs to process.") + + # Regex to extract code + # Looks for: 当前分析对象:Name (Code, Market) + # We capture 'Code' + pattern = re.compile(r"当前分析对象:.*? \((.*?),\s*(.*?)\)") + + updates_count = 0 + + for log in logs: + match = pattern.search(log.prompt) + if match: + stock_code = match.group(1).strip() + # market = match.group(2).strip() # We assume we only want the stock code for now + + print(f"Log ID {log.id}: Found code '{stock_code}' in prompt.") + + # key is unique id, but we are iterating objects. + # We can batch update or update one by one. updating object directly. + log.stock_code = stock_code + updates_count += 1 + else: + print(f"Log ID {log.id}: No match found in prompt.") + + if updates_count > 0: + try: + await session.commit() + print(f"Successfully backfilled {updates_count} logs.") + except Exception as e: + print(f"Error committing changes: {e}") + await session.rollback() + else: + print("No updates made.") + +if __name__ == "__main__": + asyncio.run(backfill_stock_codes()) diff --git a/backend/scripts/check_alc_logs.py b/backend/scripts/check_alc_logs.py new file mode 100644 index 0000000..735dd6f --- /dev/null +++ b/backend/scripts/check_alc_logs.py @@ -0,0 +1,25 @@ +import asyncio +import sys +import os +from sqlalchemy import select + +# Add the parent directory to sys.path to allow imports from app +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.database import SessionLocal +from app.models import LLMUsageLog + +async def check_alc_logs(): + async with SessionLocal() as session: + # Search for logs where stock_code contains 'ALC' or prompt contains 'ALC' + # Since stock_code might be 'ALC' or similar. + stmt = select(LLMUsageLog).where(LLMUsageLog.id == 32) + result = await session.execute(stmt) + log = result.scalars().first() + + if log: + print(f"ID: {log.id}, Used Google Search: {log.used_google_search}") + print(f"Prompt preview: {log.prompt[:200]}") + +if __name__ == "__main__": + asyncio.run(check_alc_logs()) diff --git a/backend/scripts/inspect_llm_logs.py b/backend/scripts/inspect_llm_logs.py new file mode 100644 index 0000000..60397a6 --- /dev/null +++ b/backend/scripts/inspect_llm_logs.py @@ -0,0 +1,24 @@ +import asyncio +import sys +import os + +# Add the parent directory to sys.path +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import select +from app.database import engine, SessionLocal +from app.models import LLMUsageLog + +async def inspect_logs(): + async with SessionLocal() as session: + result = await session.execute(select(LLMUsageLog).limit(10)) + logs = result.scalars().all() + + print(f"Found {len(logs)} logs.") + for log in logs: + print(f"ID: {log.id}") + print(f"Prompt Preview: {log.prompt[:200]}...") # Print first 200 chars + print("-" * 50) + +if __name__ == "__main__": + asyncio.run(inspect_logs()) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..362da1a --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,154 @@ +#!/bin/sh +set -e + +show_help() { + cat <<'EOT' +Portwarden 客户端集成脚本 (entrypoint.sh) — 多服务端并发连接版(HTTPS + /portwarden) + +用法: + - 将本脚本作为容器 ENTRYPOINT + - 通过环境变量提供必要配置 + +必需环境变量: + - PW_SERVICE_ID : Portwarden 客户端的唯一服务标识 + - PW_SERVER_ADDRS : 逗号分隔的多个服务端地址 (仅支持 https://;禁止 http://;不得包含路径 /portwarden) + 例如: https://bastion.example.org,https://edge.example.net + - PW_LOCAL_PORT : 应用在容器内监听的端口 (数字,例如 8080) + +行为: + 1) 验证以上环境变量 + 2) 导出为 SERVER_ADDRS/SERVICE_ID/LOCAL_PORT 供 portwardenc 使用 + 3) 后台启动 /usr/local/bin/portwardenc + 4) 执行容器 CMD 指定的主程序 + +获取二进制与脚本 (通过 Bastion WebUI 静态发布 /releases): + - 直接下载: + GET https:///releases/portwardenc-amd64 + GET https:///releases/portwardenc-arm64 + GET https:///releases/entrypoint.sh + + - Linux 裸机安装示例 (amd64): + curl -fsSL https:///releases/portwardenc-amd64 -o /usr/local/bin/portwardenc + curl -fsSL https:///releases/entrypoint.sh -o /usr/local/bin/entrypoint.sh + chmod +x /usr/local/bin/portwardenc /usr/local/bin/entrypoint.sh + + - Linux 裸机安装示例 (arm64): + curl -fsSL https:///releases/portwardenc-arm64 -o /usr/local/bin/portwardenc + curl -fsSL https:///releases/entrypoint.sh -o /usr/local/bin/entrypoint.sh + chmod +x /usr/local/bin/portwardenc /usr/local/bin/entrypoint.sh + + - 参考 Dockerfile 片段: + ARG TARGETARCH + ADD https:///releases/portwardenc-${TARGETARCH} /usr/local/bin/portwardenc + ADD https:///releases/entrypoint.sh /usr/local/bin/entrypoint.sh + RUN chmod +x /usr/local/bin/portwardenc /usr/local/bin/entrypoint.sh + ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] + +环境变量示例: + PW_SERVICE_ID="svc-a" \\ + PW_SERVER_ADDRS="https://bastion.example.org,https://edge.example.net" \\ + PW_LOCAL_PORT="3000" + +EOT +} + +if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then + show_help + exit 0 +fi + +if [ -z "$PW_SERVICE_ID" ] || [ -z "$PW_SERVER_ADDRS" ] || [ -z "$PW_LOCAL_PORT" ]; then + echo "Error: PW_SERVICE_ID, PW_SERVER_ADDRS, and PW_LOCAL_PORT must be set." >&2 + echo "使用 --help 查看用法。" >&2 + exit 1 +fi + +# 拒绝 http://;要求 https://;且不允许包含路径 /portwarden(客户端会自动添加) +if echo "$PW_SERVER_ADDRS" | grep -q "http://"; then + echo "Error: PW_SERVER_ADDRS 中包含 http:// ,仅支持 https:// 地址。" >&2 + exit 1 +fi +if ! echo "$PW_SERVER_ADDRS" | grep -q "https://"; then + echo "Error: PW_SERVER_ADDRS 必须全部为 https:// 地址,多个地址用逗号分隔。" >&2 + exit 1 +fi +if echo "$PW_SERVER_ADDRS" | grep -qi "/portwarden"; then + echo "Error: PW_SERVER_ADDRS 不应包含路径 '/portwarden';客户端会自动添加该前缀。" >&2 + exit 1 +fi +if ! echo "$PW_LOCAL_PORT" | grep -Eq '^[0-9]+$'; then + echo "Error: PW_LOCAL_PORT 必须是数字端口,例如 8080。" >&2 + exit 1 +fi + + +# ----------------------------------------------------------------------------- +# 自动安装逻辑 (Auto-Install Logic) +# ----------------------------------------------------------------------------- +BINARY_PATH="/usr/local/bin/portwardenc" + +if [ ! -f "$BINARY_PATH" ]; then + echo "Portwarden binary not found at $BINARY_PATH. Attempting to auto-install..." + + # 1. Detect Architecture + ARCH=$(uname -m) + case $ARCH in + x86_64) + TARGET_ARCH="amd64" + ;; + aarch64|arm64) + TARGET_ARCH="arm64" + ;; + *) + echo "Error: Unsupported architecture: $ARCH" >&2 + exit 1 + ;; + esac + echo "Detected architecture: $TARGET_ARCH" + + # 2. Derive Download URL from PW_SERVER_ADDRS + # Take the first address from the comma-separated list + FIRST_SERVER=$(echo "$PW_SERVER_ADDRS" | cut -d',' -f1) + # Remove 'https://' prefix if present for clean URL construction (though curl handles full URL) + # Actually, we rely on the server hosting /releases/ under the same domain. + # We construct: /releases/portwardenc- + DOWNLOAD_URL="${FIRST_SERVER}/releases/portwardenc-${TARGET_ARCH}" + + echo "Downloading from: $DOWNLOAD_URL" + + # 3. Download and Install + if command -v curl >/dev/null 2>&1; then + if curl -fsSL "$DOWNLOAD_URL" -o "$BINARY_PATH"; then + chmod +x "$BINARY_PATH" + echo "Successfully installed portwardenc to $BINARY_PATH" + else + echo "Error: Failed to download portwardenc from $DOWNLOAD_URL" >&2 + exit 1 + fi + elif command -v wget >/dev/null 2>&1; then + if wget -qO "$BINARY_PATH" "$DOWNLOAD_URL"; then + chmod +x "$BINARY_PATH" + echo "Successfully installed portwardenc to $BINARY_PATH" + else + echo "Error: Failed to download portwardenc from $DOWNLOAD_URL" >&2 + exit 1 + fi + else + echo "Error: Neither curl nor wget found. Cannot auto-install." >&2 + exit 1 + fi +else + echo "Portwarden binary found at $BINARY_PATH. Skipping installation." +fi + +export SERVER_ADDRS="$PW_SERVER_ADDRS" +export SERVICE_ID="$PW_SERVICE_ID" +export LOCAL_PORT="$PW_LOCAL_PORT" + +echo "Starting Portwarden client in the background..." +/usr/local/bin/portwardenc & + +echo "Executing main container command: $@" +exec "$@" + + diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ff4aa7d..45366c8 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -23,6 +23,7 @@ import { HeaderPortal } from "@/components/header-portal" import { AppSidebar } from "@/components/app-sidebar" import { StockChart } from "@/components/stock-chart" import { AiDiscussionView } from "@/components/ai-discussion-view" +import { HistoryView } from "@/components/history-view" export default function Home() { const searchParams = useSearchParams() @@ -101,6 +102,15 @@ export default function Home() { // Render Content based on View const renderContent = () => { + // History View (Global, no company selection needed) + if (currentView === "history") { + return ( +
+ +
+ ) + } + // Home View if (!selectedCompany || currentView === "home") { return ( diff --git a/frontend/src/components/ai-discussion-view.tsx b/frontend/src/components/ai-discussion-view.tsx index ee3f794..875c0b4 100644 --- a/frontend/src/components/ai-discussion-view.tsx +++ b/frontend/src/components/ai-discussion-view.tsx @@ -204,7 +204,8 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName: model: model, system_prompt: systemPrompt, use_google_search: useGoogleSearch, - session_id: sessionId + session_id: sessionId, + stock_code: `${companyName} (${symbol})` }) }) diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index c5c4011..8791ccf 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -16,6 +16,7 @@ export function AppSidebar({ activeTab, onTabChange, className, hasSelectedCompa { id: "financial", label: "财务数据", icon: BarChart3, disabled: !hasSelectedCompany }, { id: "chart", label: "股价图", icon: LineChart, disabled: !hasSelectedCompany }, { id: "ai-research", label: "AI研究讨论", icon: MessageSquare, disabled: !hasSelectedCompany }, + { id: "history", label: "讨论历史", icon: MessageSquare, disabled: false }, ] return ( diff --git a/frontend/src/components/history-view.tsx b/frontend/src/components/history-view.tsx new file mode 100644 index 0000000..4f9eb80 --- /dev/null +++ b/frontend/src/components/history-view.tsx @@ -0,0 +1,251 @@ +import { useState, useEffect } from "react" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Loader2, MessageSquare, Calendar, Search, Database } from "lucide-react" +import { Input } from "@/components/ui/input" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" + +interface LogEntry { + id: number + timestamp: string + model: string + prompt: string + response: string + session_id: string | null + stock_code: string | null + total_tokens: number + used_google_search: boolean +} + +interface GroupedSession { + session_id: string + stock_code: string + timestamp: string + logs: LogEntry[] +} + +export function HistoryView() { + const [loading, setLoading] = useState(true) + const [groupedSessions, setGroupedSessions] = useState([]) + const [selectedSessionId, setSelectedSessionId] = useState(null) + const [filterText, setFilterText] = useState("") + + useEffect(() => { + fetchHistory() + }, []) + + const fetchHistory = async () => { + try { + // Use relative path to leverage Next.js proxy if configured, or assume same origin + // If dev environment needs full URL, use environment variable or fallback + const res = await fetch("/api/chat/history") + + if (!res.ok) { + console.error("Failed to fetch history:", res.status, res.statusText) + return + } + + const data = await res.json() + + if (!Array.isArray(data)) { + console.error("History data is not an array:", data) + return + } + + // Group by session_id + const groups: { [key: string]: GroupedSession } = {} + + data.forEach((log: LogEntry) => { + const sid = log.session_id || "unknown" + if (!groups[sid]) { + groups[sid] = { + session_id: sid, + stock_code: log.stock_code || "Unknown", + timestamp: log.timestamp, + logs: [] + } + } + groups[sid].logs.push(log) + }) + + // Convert to array and sort by timestamp + const sortedGroups = Object.values(groups).sort((a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ) + + setGroupedSessions(sortedGroups) + if (sortedGroups.length > 0) { + setSelectedSessionId(sortedGroups[0].session_id) + } + } catch (error) { + console.error("Failed to fetch history:", error) + } finally { + setLoading(false) + } + } + + const selectedSession = groupedSessions.find(g => g.session_id === selectedSessionId) + + const formatDate = (dateString: string) => { + const date = new Date(dateString) + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }).format(date) + } + + const formatFullDate = (dateString: string) => { + const date = new Date(dateString) + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }).format(date) + } + + + if (loading) { + return
+ } + + return ( +
+ {/* Left Sidebar: Session List */} +
+
+
+ + setFilterText(e.target.value)} + className="pl-8 h-9" + /> +
+
+ +
+
+
+ {groupedSessions + .filter(group => + group.stock_code.toLowerCase().includes(filterText.toLowerCase()) + ) + .map(group => ( +
setSelectedSessionId(group.session_id)} + > +
+ {group.stock_code} +
+ + {formatDate(group.timestamp)} + +
+ ))} +
+
+ + {/* Right Content: Chat Details */} +
+ {selectedSession ? ( +
+
+
+

+ + {selectedSession.stock_code} +

+ {selectedSession.session_id} +
+ + {formatFullDate(selectedSession.timestamp)} + +
+ +
+
+ {selectedSession.logs.map((log, idx) => ( +
+ {/* User Message - Hidden as requested */} + {/* +
+
+ U +
+
+
User
+
+ {parsePrompt(log.prompt)} +
+
+
+ */} + + {/* Response / Model Message */} +
+
+ AI +
+
+
+ Model ({log.model}) + Tokens: {log.total_tokens} + {log.used_google_search ? ( + + Google Search: ON + + ) : ( + + Google Search: OFF + + )} +
+
+ + {log.response} + +
+
+
+ {idx < selectedSession.logs.length - 1 &&
} +
+ ))} +
+
+
+ ) : ( +
+ +

请选择一个会话查看详情

+
+ )} +
+
+ ) +} + +function parsePrompt(fullPrompt: string) { + if (!fullPrompt) return "" + // Remove system prompt if present using a safer regex + // "System: ... User: ..." + const parts = fullPrompt.split(/User:\s*/) + if (parts.length > 1) { + return parts.slice(1).join("User:").trim() + } + return fullPrompt +}