增加研究讨论历史功能 ,并尝试用 docker
This commit is contained in:
parent
369f21a9db
commit
1d8fa1a495
81
Dockerfile
Normal file
81
Dockerfile
Normal 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"]
|
||||||
54
README.md
54
README.md
@ -93,6 +93,60 @@ chmod +x deploy.sh
|
|||||||
4. **初始化数据库**:运行 `create_db.py` 创建 SQLite 数据库。
|
4. **初始化数据库**:运行 `create_db.py` 创建 SQLite 数据库。
|
||||||
5. **启动服务**:构建前端(如指定 production)并使用 PM2 启动/重启所有服务。
|
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(前端端口),无需更改。*
|
||||||
|
|
||||||
## 如何运行
|
## 如何运行
|
||||||
|
|
||||||
### 参数说明
|
### 参数说明
|
||||||
|
|||||||
@ -24,6 +24,7 @@ class ChatRequest(BaseModel):
|
|||||||
system_prompt: Optional[str] = None
|
system_prompt: Optional[str] = None
|
||||||
use_google_search: bool = False
|
use_google_search: bool = False
|
||||||
session_id: Optional[str] = None
|
session_id: Optional[str] = None
|
||||||
|
stock_code: Optional[str] = None
|
||||||
|
|
||||||
@router.post("/chat")
|
@router.post("/chat")
|
||||||
async def chat_with_ai(request: ChatRequest, db: AsyncSession = Depends(get_db)):
|
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,
|
response_time=response_time,
|
||||||
used_google_search=request.use_google_search,
|
used_google_search=request.use_google_search,
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
|
stock_code=request.stock_code,
|
||||||
prompt_tokens=prompt_tokens,
|
prompt_tokens=prompt_tokens,
|
||||||
completion_tokens=completion_tokens,
|
completion_tokens=completion_tokens,
|
||||||
total_tokens=total_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}")
|
logger.error(f"Chat error: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(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]
|
||||||
|
|||||||
@ -137,6 +137,7 @@ class LLMUsageLog(Base):
|
|||||||
response_time = Column(Float, nullable=True) # Seconds
|
response_time = Column(Float, nullable=True) # Seconds
|
||||||
used_google_search = Column(Boolean, default=False)
|
used_google_search = Column(Boolean, default=False)
|
||||||
session_id = Column(String, nullable=True)
|
session_id = Column(String, nullable=True)
|
||||||
|
stock_code = Column(String(50), nullable=True)
|
||||||
|
|
||||||
# Token Usage
|
# Token Usage
|
||||||
prompt_tokens = Column(Integer, default=0)
|
prompt_tokens = Column(Integer, default=0)
|
||||||
@ -144,4 +145,4 @@ class LLMUsageLog(Base):
|
|||||||
total_tokens = Column(Integer, default=0)
|
total_tokens = Column(Integer, default=0)
|
||||||
|
|
||||||
def __repr__(self):
|
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})>"
|
||||||
|
|||||||
24
backend/scripts/add_stock_code_column.py
Normal file
24
backend/scripts/add_stock_code_column.py
Normal 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())
|
||||||
54
backend/scripts/backfill_stock_code.py
Normal file
54
backend/scripts/backfill_stock_code.py
Normal 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())
|
||||||
25
backend/scripts/check_alc_logs.py
Normal file
25
backend/scripts/check_alc_logs.py
Normal 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())
|
||||||
24
backend/scripts/inspect_llm_logs.py
Normal file
24
backend/scripts/inspect_llm_logs.py
Normal 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
154
entrypoint.sh
Normal 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 "$@"
|
||||||
|
|
||||||
|
|
||||||
@ -23,6 +23,7 @@ import { HeaderPortal } from "@/components/header-portal"
|
|||||||
import { AppSidebar } from "@/components/app-sidebar"
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
import { StockChart } from "@/components/stock-chart"
|
import { StockChart } from "@/components/stock-chart"
|
||||||
import { AiDiscussionView } from "@/components/ai-discussion-view"
|
import { AiDiscussionView } from "@/components/ai-discussion-view"
|
||||||
|
import { HistoryView } from "@/components/history-view"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
@ -101,6 +102,15 @@ export default function Home() {
|
|||||||
|
|
||||||
// Render Content based on View
|
// Render Content based on View
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
|
// History View (Global, no company selection needed)
|
||||||
|
if (currentView === "history") {
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<HistoryView />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Home View
|
// Home View
|
||||||
if (!selectedCompany || currentView === "home") {
|
if (!selectedCompany || currentView === "home") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -204,7 +204,8 @@ export function AiDiscussionView({ companyName, symbol, market }: { companyName:
|
|||||||
model: model,
|
model: model,
|
||||||
system_prompt: systemPrompt,
|
system_prompt: systemPrompt,
|
||||||
use_google_search: useGoogleSearch,
|
use_google_search: useGoogleSearch,
|
||||||
session_id: sessionId
|
session_id: sessionId,
|
||||||
|
stock_code: `${companyName} (${symbol})`
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export function AppSidebar({ activeTab, onTabChange, className, hasSelectedCompa
|
|||||||
{ id: "financial", label: "财务数据", icon: BarChart3, disabled: !hasSelectedCompany },
|
{ id: "financial", label: "财务数据", icon: BarChart3, disabled: !hasSelectedCompany },
|
||||||
{ id: "chart", label: "股价图", icon: LineChart, disabled: !hasSelectedCompany },
|
{ id: "chart", label: "股价图", icon: LineChart, disabled: !hasSelectedCompany },
|
||||||
{ id: "ai-research", label: "AI研究讨论", icon: MessageSquare, disabled: !hasSelectedCompany },
|
{ id: "ai-research", label: "AI研究讨论", icon: MessageSquare, disabled: !hasSelectedCompany },
|
||||||
|
{ id: "history", label: "讨论历史", icon: MessageSquare, disabled: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
251
frontend/src/components/history-view.tsx
Normal file
251
frontend/src/components/history-view.tsx
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user