增加研究讨论历史功能 ,并尝试用 docker

This commit is contained in:
xucheng 2026-01-14 09:48:11 +08:00
parent 369f21a9db
commit 1d8fa1a495
13 changed files with 707 additions and 2 deletions

81
Dockerfile Normal file
View File

@ -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"]

View File

@ -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前端端口无需更改。*
## 如何运行
### 参数说明

View File

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

View File

@ -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"<LLMUsageLog(id={self.id}, model={self.model}, tokens={self.total_tokens})>"
return f"<LLMUsageLog(id={self.id}, model={self.model}, stock={self.stock_code}, tokens={self.total_tokens})>"

View File

@ -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())

View File

@ -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())

View File

@ -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())

View File

@ -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())

154
entrypoint.sh Normal file
View File

@ -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://<bastion-domain>/releases/portwardenc-amd64
GET https://<bastion-domain>/releases/portwardenc-arm64
GET https://<bastion-domain>/releases/entrypoint.sh
- Linux 裸机安装示例 (amd64):
curl -fsSL https://<bastion-domain>/releases/portwardenc-amd64 -o /usr/local/bin/portwardenc
curl -fsSL https://<bastion-domain>/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://<bastion-domain>/releases/portwardenc-arm64 -o /usr/local/bin/portwardenc
curl -fsSL https://<bastion-domain>/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://<bastion-domain>/releases/portwardenc-${TARGETARCH} /usr/local/bin/portwardenc
ADD https://<bastion-domain>/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: <FIRST_SERVER>/releases/portwardenc-<TARGET_ARCH>
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 "$@"

View File

@ -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 (
<div className="h-full">
<HistoryView />
</div>
)
}
// Home View
if (!selectedCompany || currentView === "home") {
return (

View File

@ -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})`
})
})

View File

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

View File

@ -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<GroupedSession[]>([])
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(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 <div className="flex justify-center items-center h-full"><Loader2 className="animate-spin" /></div>
}
return (
<div className="flex h-full gap-4 p-4 overflow-hidden">
{/* Left Sidebar: Session List */}
<div className="w-[280px] flex-shrink-0 flex flex-col gap-2 h-full">
<div className="flex items-center gap-2 mb-4 flex-shrink-0">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="筛选代码..."
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
className="pl-8 h-9"
/>
</div>
<div className="h-9 w-9 flex items-center justify-center rounded-md border text-muted-foreground bg-muted/20" title="Elephant Placeholder">
<Database className="h-4 w-4" />
</div>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-1 custom-scrollbar">
{groupedSessions
.filter(group =>
group.stock_code.toLowerCase().includes(filterText.toLowerCase())
)
.map(group => (
<div
key={group.session_id}
className={`
cursor-pointer px-3 py-2 rounded-md text-sm transition-colors flex items-center justify-between
${selectedSessionId === group.session_id ? 'bg-secondary font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}
`}
onClick={() => setSelectedSessionId(group.session_id)}
>
<div className="flex items-center gap-2">
<span className="font-mono font-medium">{group.stock_code}</span>
</div>
<span className="text-xs opacity-70 flex items-center gap-1">
{formatDate(group.timestamp)}
</span>
</div>
))}
</div>
</div>
{/* Right Content: Chat Details */}
<div className="flex-1 flex flex-col h-full bg-card rounded-lg border shadow-sm overflow-hidden">
{selectedSession ? (
<div className="flex flex-col h-full">
<div className="p-4 border-b bg-muted/20 flex justify-between items-center flex-shrink-0">
<div className="flex items-center gap-3">
<h3 className="font-bold flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
{selectedSession.stock_code}
</h3>
<Badge variant="outline">{selectedSession.session_id}</Badge>
</div>
<span className="text-sm text-muted-foreground">
{formatFullDate(selectedSession.timestamp)}
</span>
</div>
<div className="flex-1 overflow-y-auto p-6 scroll-smooth">
<div className="space-y-8 max-w-4xl mx-auto pb-8">
{selectedSession.logs.map((log, idx) => (
<div key={log.id} className="space-y-4">
{/* User Message - Hidden as requested */}
{/*
<div className="flex gap-4">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<span className="text-xs font-bold">U</span>
</div>
<div className="flex-1 space-y-2">
<div className="font-semibold text-sm">User</div>
<div className="bg-muted/50 p-4 rounded-lg text-sm whitespace-pre-wrap">
{parsePrompt(log.prompt)}
</div>
</div>
</div>
*/}
{/* Response / Model Message */}
<div className="flex gap-4">
<div className="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-blue-500">AI</span>
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">Model ({log.model})</span>
<span className="text-xs text-muted-foreground">Tokens: {log.total_tokens}</span>
{log.used_google_search ? (
<Badge variant="secondary" className="text-[10px] bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100 hover:bg-green-100">
Google Search: ON
</Badge>
) : (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
Google Search: OFF
</Badge>
)}
</div>
<div className="prose prose-sm dark:prose-invert max-w-none bg-card border p-4 rounded-lg">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{log.response}
</ReactMarkdown>
</div>
</div>
</div>
{idx < selectedSession.logs.length - 1 && <div className="my-8 h-px bg-border" />}
</div>
))}
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<MessageSquare className="w-12 h-12 mb-4 opacity-50" />
<p></p>
</div>
)}
</div>
</div>
)
}
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
}