From e848f26354e974f24023312ef6ec554aeebdc1f7 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 20 Apr 2026 11:58:32 +0800 Subject: [PATCH] =?UTF-8?q?feat/=20=20internal=20eval=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E7=8E=B0=E6=94=AF=E6=8C=81=E5=AE=9E=E6=9C=BA=E8=81=94=E8=B0=83?= =?UTF-8?q?=E3=80=82=201.=20=E6=98=BE=E7=A4=BA=E5=BD=93=E5=89=8D=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=95=B0=E6=8D=AE=E5=BA=93=E9=87=8C=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=9A=84=E5=8E=86=E5=8F=B2=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=EF=BC=8C=E5=B7=B2=E7=94=9F=E6=88=90=E7=9A=84=E5=9B=9E=E5=BF=86?= =?UTF-8?q?=E5=BD=95=E3=80=82=E6=94=AF=E6=8C=81=E5=9C=A8=E7=BD=91=E9=A1=B5?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=AF=B9=E8=AF=9D=EF=BC=8C=E4=B8=8D=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E6=89=8B=E6=9C=BAapp=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- api/.env.example | 2 + api/app/core/config.py | 11 + api/app/features/auth/router.py | 39 ++ api/app/features/auth/schemas.py | 10 + api/app/features/auth/service.py | 23 + api/docs/internal-eval.md | 7 + api/tests/test_mock_sms_login_http.py | 101 +++ app-eval-web/README.md | 21 +- app-eval-web/src/App.tsx | 3 + app-eval-web/src/components/Sidebar.tsx | 1 + app-eval-web/src/config.ts | 31 + app-eval-web/src/eval.css | 261 ++++++++ app-eval-web/src/hooks/useHashRoute.ts | 2 +- app-eval-web/src/mainApi.ts | 58 ++ app-eval-web/src/pages/LiveTesterPage.tsx | 754 ++++++++++++++++++++++ app-eval-web/src/types.ts | 2 +- app-eval-web/vite.config.ts | 23 + 18 files changed, 1339 insertions(+), 12 deletions(-) create mode 100644 api/tests/test_mock_sms_login_http.py create mode 100644 app-eval-web/src/mainApi.ts create mode 100644 app-eval-web/src/pages/LiveTesterPage.tsx diff --git a/README.md b/README.md index 36e435e..57017e7 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ life-echo/ │ ├── main.py # uvicorn 兼容入口 │ └── README.md ├── app-expo/ # 移动端(Expo Router + React Native) -├── app-eval-web/ # 内部评测 Web(Vite) +├── app-eval-web/ # 内部评测 Web(Vite,仅本机开发,不打包进预发/生产 Docker) └── docs/ # 设计与运维文档 ``` diff --git a/api/.env.example b/api/.env.example index 8c1484f..beeaebe 100644 --- a/api/.env.example +++ b/api/.env.example @@ -183,6 +183,8 @@ MEMORY_COMPACTION_ENABLED=true SECRET_KEY=replace_with_a_strong_random_secret ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=120 +# 内网评测:开启后可用 POST /api/auth/mock/sms-login(跳过短信);APP_ENV=production 时该路由仍返回 404 +# MOCK_SMS_LOGIN_ENABLED=1 # ============================================================================= # Tencent Cloud — 短信 diff --git a/api/app/core/config.py b/api/app/core/config.py index f0595b5..3e1b645 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -50,6 +50,8 @@ class Settings(BaseSettings): algorithm: str = "HS256" access_token_expire_minutes: int = 120 refresh_token_expire_days: int = 30 + # 本地/内网评测:允许 POST /api/auth/mock/sms-login 跳过短信(须显式开启;production 下路由仍拒绝) + mock_sms_login_enabled: bool = False # ── LLM / DeepSeek ─────────────────────────────────────── deepseek_api_key: str = "" @@ -209,6 +211,15 @@ class Settings(BaseSettings): return False return str(v).strip().lower() in ("1", "true", "yes", "on") + @field_validator("mock_sms_login_enabled", mode="before") + @classmethod + def _coerce_mock_sms_login_enabled(cls, v: object) -> bool: + if isinstance(v, bool): + return v + if v is None: + return False + return str(v).strip().lower() in ("1", "true", "yes", "on") + @field_validator("log_agent_verbose", mode="before") @classmethod def _coerce_log_agent_verbose(cls, v: object) -> bool: diff --git a/api/app/features/auth/router.py b/api/app/features/auth/router.py index 32c2f75..68e230b 100644 --- a/api/app/features/auth/router.py +++ b/api/app/features/auth/router.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi.responses import FileResponse from PIL import Image +from app.core.config import settings from app.core.dependencies import get_current_user from app.core.logging import get_logger from app.features.auth.deps import get_auth_service @@ -12,6 +13,7 @@ from app.features.auth.schemas import ( ChangePasswordRequest, ChangePhoneRequest, LoginRequest, + MockSmsLoginRequest, RefreshTokenRequest, RegisterRequest, ResetPasswordRequest, @@ -73,6 +75,13 @@ def _check_terms(agreed: bool) -> None: ) +def _mock_sms_login_route_enabled() -> bool: + env = (settings.app_environment or "").lower().strip() + if env == "production": + return False + return bool(settings.mock_sms_login_enabled) + + # ── registration & login ───────────────────────────────────── @@ -410,6 +419,36 @@ async def login_with_sms( ) +@router.post( + "/mock/sms-login", + response_model=TokenResponse, + summary="[评测] Mock 短信登录(跳过验证码)", + description=( + "需 MOCK_SMS_LOGIN_ENABLED=1 且 APP_ENV 非 production。" + "供 Eval Web 等内网工具联调,勿在生产环境开启。" + ), + responses={404: {"description": "未启用或生产环境已禁用"}}, +) +async def mock_sms_login_route( + request: MockSmsLoginRequest, + service: AuthService = Depends(get_auth_service), +): + if not _mock_sms_login_route_enabled(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") + _check_terms(request.agreed_to_terms) + try: + result = await service.mock_sms_login( + phone=request.phone, + nickname=request.nickname, + ) + except AuthError as e: + raise _map_auth_error(e) + return TokenResponse( + access_token=result["access_token"], + refresh_token=result["refresh_token"], + ) + + @router.post( "/register/sms", response_model=TokenResponse, diff --git a/api/app/features/auth/schemas.py b/api/app/features/auth/schemas.py index b940b49..b3c5dba 100644 --- a/api/app/features/auth/schemas.py +++ b/api/app/features/auth/schemas.py @@ -59,6 +59,16 @@ class SmsLoginRequest(BaseModel): ) +class MockSmsLoginRequest(BaseModel): + """开发/评测专用:与 MOCK_SMS_LOGIN_ENABLED 联用,跳过短信校验。""" + + phone: str = Field(..., min_length=11, max_length=11, description="手机号(11位)") + agreed_to_terms: bool = Field(..., description="是否同意用户协议和隐私政策") + nickname: Optional[str] = Field( + None, max_length=50, description="新用户昵称(可选)" + ) + + class SmsRegisterRequest(BaseModel): phone: str = Field(..., min_length=11, max_length=11, description="手机号(11位)") code: str = Field(..., min_length=6, max_length=6, description="验证码(6位)") diff --git a/api/app/features/auth/service.py b/api/app/features/auth/service.py index eced006..a523aad 100644 --- a/api/app/features/auth/service.py +++ b/api/app/features/auth/service.py @@ -218,6 +218,18 @@ class AuthService: if not success: raise AuthError(message, "INVALID_SMS_CODE") + return await self._sms_login_after_code_verified( + phone, device_info=device_info, nickname=nickname + ) + + async def _sms_login_after_code_verified( + self, + phone: str, + *, + device_info: str = "", + nickname: str | None = None, + ) -> dict: + """SMS 已校验通过后:查找或创建用户并签发令牌。""" user = await repo.get_user_by_phone(phone, self._db) is_new_user = user is None @@ -240,6 +252,17 @@ class AuthService: return {"user": user, "is_new_user": is_new_user, **tokens} + async def mock_sms_login( + self, + phone: str, + device_info: str = "", + nickname: str | None = None, + ) -> dict: + """跳过短信校验的登录/自动注册(仅由 mock 路由在配置允许时调用)。""" + return await self._sms_login_after_code_verified( + phone, device_info=device_info, nickname=nickname + ) + async def register_with_sms( self, phone: str, diff --git a/api/docs/internal-eval.md b/api/docs/internal-eval.md index 9030b5c..20ce1d4 100644 --- a/api/docs/internal-eval.md +++ b/api/docs/internal-eval.md @@ -62,6 +62,8 @@ VITE_EVAL_API_BASE=http://127.0.0.1:8001 VITE_EVAL_API_KEY=与上同 npm run dev 或使用仓库根目录 `npm run eval-web`(需本地已 `npm install` 在 `app-eval-web`)。 +**部署约定**:`app-eval-web` **不提供**生产/预发 Docker 镜像,也 **不**纳入 Staging/Production 的 `api/docker-compose.yml` 部署。评测 UI 仅在开发阶段于本机通过 Vite `npm run dev`(或 `./internal-eval.sh` 拉起)使用;不要在预发/生产环境编译 `dist/` 或挂载独立容器对外提供服务。 + ## 流式评审 `POST /internal/api/evaluation/judge/conversation-stream` 使用 **fetch 读取 SSE**(chunk),请求头携带 `X-Internal-Eval-Key` 即可;不要求浏览器 `EventSource`。Body 可选 **`judge_provider`**:`zhipu`(默认)| `deepseek`,以及 **`judge_model`**(空则用该供应商环境默认)。首轮 `meta` 事件会回显 `judge_provider` / `judge_model`。 @@ -76,6 +78,11 @@ VITE_EVAL_API_BASE=http://127.0.0.1:8001 VITE_EVAL_API_KEY=与上同 npm run dev - **Playground · 分步测评**:选用户导出 MD 为基线 → `eval-sandbox` + 逐轮 `replay/conversation`(**`skip_memoir: true`** 时只做对话)→ **`memoir-submit`** 再可选轮询 **`memoir-phase1-ready`** → 跳转 **Memoir / Stories** 看成稿;支持 **智谱 / DeepSeek R1** 对话流式评分(工具栏「评审模型」)。 - **Memoir**:按 `user_id` 拉库中章节快照与基线对照评审。 - **Stories**:故事列表与评审。 +- **实机联调**(侧栏「实机联调」,哈希路由 `#live`):用与 **消费者主站**相同的 REST / WebSocket 测核心聊天与回忆录。需主站 `main:app` 已启动(默认 **:8000**),并在主站环境启用 Mock 登录(见下)。开发时 `app-eval-web` 将 **`/api`** 与 **`/ws`** 代理到主站(`VITE_MAIN_API_PROXY_TARGET`,默认 `http://127.0.0.1:8000`);也可设 `VITE_MAIN_API_BASE` 直连完整主站 URL。 + +### Mock 登录(仅非 production) + +在主站 `.env` / `.env.development` 中设置 **`MOCK_SMS_LOGIN_ENABLED=1`**(或 `true`)。`APP_ENV=production` 时 **`POST /api/auth/mock/sms-login` 始终返回 404**。请求体:`phone`(11 位)、`agreed_to_terms: true`,可选 `nickname`(新用户);响应与正式短信登录相同(`access_token` + `refresh_token`)。**切勿在生产环境开启。** ## 真实链路透传回放(与 App 一致) diff --git a/api/tests/test_mock_sms_login_http.py b/api/tests/test_mock_sms_login_http.py new file mode 100644 index 0000000..2da6cc3 --- /dev/null +++ b/api/tests/test_mock_sms_login_http.py @@ -0,0 +1,101 @@ +"""Mock 短信登录路由:配置门禁与返回的 JWT 可解析性。""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from app.core.config import settings +from app.core.security import create_access_token, verify_token +from app.features.auth.deps import get_auth_service +from app.features.auth.router import router as auth_router +from app.features.auth.service import AuthService + + +@pytest.fixture +def mock_sms_app() -> FastAPI: + app = FastAPI() + app.include_router(auth_router) + mock_service = MagicMock(spec=AuthService) + uid = str(uuid.uuid4()) + mock_service.mock_sms_login = AsyncMock( + return_value={ + "access_token": create_access_token(data={"sub": uid}), + "refresh_token": "rt-mock", + } + ) + app.dependency_overrides[get_auth_service] = lambda: mock_service + app.state._mock_service = mock_service + app.state._mock_uid = uid + return app + + +@pytest.mark.asyncio +async def test_mock_sms_login_disabled_returns_404(mock_sms_app, monkeypatch) -> None: + monkeypatch.setattr(settings, "mock_sms_login_enabled", False) + monkeypatch.setattr(settings, "app_environment", "development") + transport = ASGITransport(app=mock_sms_app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.post( + "/api/auth/mock/sms-login", + json={"phone": "13800138000", "agreed_to_terms": True}, + ) + assert r.status_code == 404 + + +@pytest.mark.asyncio +async def test_mock_sms_login_production_returns_404(mock_sms_app, monkeypatch) -> None: + monkeypatch.setattr(settings, "mock_sms_login_enabled", True) + monkeypatch.setattr(settings, "app_environment", "production") + transport = ASGITransport(app=mock_sms_app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.post( + "/api/auth/mock/sms-login", + json={"phone": "13800138000", "agreed_to_terms": True}, + ) + assert r.status_code == 404 + + +@pytest.mark.asyncio +async def test_mock_sms_login_enabled_returns_valid_access_jwt( + mock_sms_app, monkeypatch +) -> None: + monkeypatch.setattr(settings, "mock_sms_login_enabled", True) + monkeypatch.setattr(settings, "app_environment", "development") + transport = ASGITransport(app=mock_sms_app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.post( + "/api/auth/mock/sms-login", + json={"phone": "13800138000", "agreed_to_terms": True}, + ) + assert r.status_code == 200 + body = r.json() + assert body.get("token_type") == "bearer" + access = body["access_token"] + payload = verify_token(access) + assert payload is not None + assert payload.get("type") == "access" + assert payload.get("sub") == mock_sms_app.state._mock_uid + + svc: MagicMock = mock_sms_app.state._mock_service + svc.mock_sms_login.assert_awaited_once_with( + phone="13800138000", + nickname=None, + ) + + +@pytest.mark.asyncio +async def test_mock_sms_login_requires_terms(mock_sms_app, monkeypatch) -> None: + monkeypatch.setattr(settings, "mock_sms_login_enabled", True) + monkeypatch.setattr(settings, "app_environment", "development") + transport = ASGITransport(app=mock_sms_app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.post( + "/api/auth/mock/sms-login", + json={"phone": "13800138000", "agreed_to_terms": False}, + ) + assert r.status_code == 400 diff --git a/app-eval-web/README.md b/app-eval-web/README.md index d0eee4a..102fcb5 100644 --- a/app-eval-web/README.md +++ b/app-eval-web/README.md @@ -1,25 +1,28 @@ # 内部评测 Web(Life Echo) -独立 Vite + React 控制台,对接 `app.internal_main:internal_app`。路由仅 **Playground(分步测评)**、**Memoir**、**Memoir · Stories**:先对话重放(`skip_memoir`)→ `memoir-submit` → 查看成稿。 +独立 Vite + React 控制台,对接 `app.internal_main:internal_app`。路由含 **Playground(分步测评)**、**Memoir**、**Memoir · Stories**、**实机联调(`#live`,主站 JWT + WS)**。 + +## 部署范围(重要) + +本目录 **仅用于本地开发**:在开发者机器上 `npm run dev`,依赖 Vite 将 `/internal`、`/api`、`/ws` 代理到本机后端。**不提供**生产或预发环境的 Docker 镜像,**不**随 `api/docker-compose.yml` 部署到 Staging/Production;切勿将 `dist/` 或自建镜像发布到线上。 ## 环境变量 -- `VITE_EVAL_API_BASE` — 内部 API 根,如 `http://127.0.0.1:8001` +- `VITE_EVAL_API_BASE` — 内部评测 API 根(可选;开发留空则走 Vite 代理,默认 `:7999`) - `VITE_EVAL_API_KEY` — 与后端 `INTERNAL_EVAL_API_KEY` 相同 +- `VITE_MAIN_API_BASE` / `VITE_MAIN_API_PROXY_TARGET` — 实机联调用主站 API(见 `api/docs/internal-eval.md`) ## 开发 ```bash npm install -VITE_EVAL_API_BASE=http://127.0.0.1:8001 VITE_EVAL_API_KEY=your-secret npm run dev +VITE_EVAL_API_BASE=http://127.0.0.1:7999 VITE_EVAL_API_KEY=your-secret npm run dev ``` -浏览器打开提示的端口(默认 5174)。 +浏览器打开提示的端口(默认 5174)。仓库根目录亦可:`npm run eval-web`。 -## 构建 +## 生产构建(仅限本地检查,非部署产物) -```bash -npm run build -``` +`npm run build` 仅可用于本地验证 TypeScript/打包是否通过;**不作为**预发/生产发布步骤。正式联调请始终使用 `npm run dev`(以便代理与热更新)。 -产物在 `dist/`,可挂任意静态服务器。对话流式评审使用带 `X-Internal-Eval-Key` 的 `fetch`(见 `api/docs/internal-eval.md`)。 +对话流式评审使用带 `X-Internal-Eval-Key` 的 `fetch`(见 `api/docs/internal-eval.md`)。 diff --git a/app-eval-web/src/App.tsx b/app-eval-web/src/App.tsx index 4fc071b..8bfe4c2 100644 --- a/app-eval-web/src/App.tsx +++ b/app-eval-web/src/App.tsx @@ -10,6 +10,7 @@ import type { AppRoute } from "./types"; import MemoirPage from "./pages/MemoirPage"; import MemoirStoriesPage from "./pages/MemoirStoriesPage"; import PlaygroundPage from "./pages/PlaygroundPage"; +import LiveTesterPage from "./pages/LiveTesterPage"; function RouteOutlet({ route }: { route: AppRoute }) { switch (route) { @@ -19,6 +20,8 @@ function RouteOutlet({ route }: { route: AppRoute }) { return ; case "memoir-stories": return ; + case "live": + return ; default: return ; } diff --git a/app-eval-web/src/components/Sidebar.tsx b/app-eval-web/src/components/Sidebar.tsx index 3ff4973..747075c 100644 --- a/app-eval-web/src/components/Sidebar.tsx +++ b/app-eval-web/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ const NAV: { route: AppRoute; label: string; sub?: string }[] = [ { route: "playground", label: "Playground", sub: "分步测评" }, { route: "memoir", label: "Memoir", sub: "章节对照" }, { route: "memoir-stories", label: "Stories", sub: "故事成稿" }, + { route: "live", label: "实机联调", sub: "主站聊天/回忆录" }, ]; export function Sidebar({ diff --git a/app-eval-web/src/config.ts b/app-eval-web/src/config.ts index 500e712..37285b7 100644 --- a/app-eval-web/src/config.ts +++ b/app-eval-web/src/config.ts @@ -6,6 +6,10 @@ const devProxyTarget = (import.meta.env.VITE_EVAL_PROXY_TARGET as string | undefined)?.trim() || "http://127.0.0.1:7999"; +const devMainApiProxyTarget = + (import.meta.env.VITE_MAIN_API_PROXY_TARGET as string | undefined)?.trim() || + "http://127.0.0.1:8000"; + /** 开发无 VITE_EVAL_API_BASE:相对路径走 Vite proxy(默认 :7999,与 development.sh 一致)。 */ export const apiBase = envApiBase || (import.meta.env.DEV ? "" : "http://127.0.0.1:7999"); @@ -18,6 +22,33 @@ export const apiBaseHint = ? `(开发)请求经 Vite 代理到 ${devProxyTarget}` : `直连 ${apiBase}`; +/** 主站(消费者 App)REST 基址;开发留空则走 Vite `/api` 代理。 */ +export const envMainApiBase = ( + import.meta.env.VITE_MAIN_API_BASE as string | undefined +)?.trim() ?? ""; + +export const mainApiBase = + envMainApiBase || + (import.meta.env.DEV ? "" : "http://127.0.0.1:8000"); + +export const mainApiBaseHint = + mainApiBase === "" + ? `(开发)主站经 Vite 代理到 ${devMainApiProxyTarget}` + : `主站 ${mainApiBase}`; + +/** WebSocket 基址(无路径前缀);开发且 mainApiBase 为空时用当前页面 host + `/ws`。 */ +export function getMainWsBase(): string { + const http = mainApiBase.trim(); + if (http) { + return http.replace(/^http/, "ws").replace(/\/$/, ""); + } + if (typeof window !== "undefined") { + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${proto}//${window.location.host}`; + } + return "ws://127.0.0.1:8000"; +} + export const SESSION_LIST_POLL_MS = 4000; export const DIALOGUE_POLL_MS = 3500; export const ADMIN_POLL_MS = 8000; diff --git a/app-eval-web/src/eval.css b/app-eval-web/src/eval.css index 20a3ff5..518a180 100644 --- a/app-eval-web/src/eval.css +++ b/app-eval-web/src/eval.css @@ -2106,3 +2106,264 @@ code { margin: 0 0 var(--s-3); font-size: var(--text-md); } + +/* Live tester (main API + WS) */ + +.eval-live-page { + padding-bottom: var(--s-6); +} + +.eval-live-login, +.eval-live-session { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--s-3); +} + +.eval-live-field { + display: flex; + flex-direction: column; + gap: var(--s-2); + font-size: var(--text-sm); +} + +.eval-live-check { + display: flex; + align-items: center; + gap: var(--s-2); + font-size: var(--text-sm); + cursor: pointer; +} + +.eval-live-error { + margin: 0; + color: var(--danger-text); + font-size: var(--text-sm); +} + +.eval-live-actions { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); +} + +.eval-live-grid { + display: grid; + grid-template-columns: minmax(200px, 240px) minmax(0, 1fr) minmax(220px, 320px); + gap: var(--s-4); + padding: 0 var(--s-4) var(--s-5); + align-items: start; +} + +@media (max-width: 1100px) { + .eval-live-grid { + grid-template-columns: 1fr; + } +} + +.eval-live-col { + min-height: 0; +} + +.eval-live-new { + width: 100%; +} + +.eval-live-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 52vh; + overflow: auto; + display: flex; + flex-direction: column; + gap: var(--s-1); +} + +.eval-live-list__btn { + width: 100%; + text-align: left; + padding: var(--s-2) var(--s-3); + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--bg-elevated); + font: inherit; + cursor: pointer; + color: var(--text); +} + +.eval-live-list__btn:hover { + border-color: var(--border-strong); +} + +.eval-live-list__btn.is-active { + border-color: var(--accent); + background: var(--accent-muted); +} + +.eval-live-list__title { + display: block; + font-weight: 600; + font-size: var(--text-sm); +} + +.eval-live-list__sub { + display: block; + font-size: var(--text-xs); + color: var(--text-muted); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.eval-live-messages { + display: flex; + flex-direction: column; + gap: var(--s-2); + max-height: 36vh; + overflow: auto; + padding: var(--s-2); + background: var(--bg-muted); + border-radius: var(--r-md); +} + +.eval-live-system-msg { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); + align-items: baseline; + font-size: var(--text-xs); + color: var(--text-muted); + padding: var(--s-1) var(--s-2); + border-radius: var(--r-sm); + background: var(--bg-elevated); + border: 1px dashed var(--border); +} + +.eval-live-system-msg__k { + font-family: var(--font-mono); + color: var(--text-faint); + text-transform: lowercase; +} + +.eval-live-system-msg__t { + word-break: break-word; + color: var(--text-muted); +} + +.eval-live-system-msg--error .eval-live-system-msg__k { + color: var(--danger-text); +} + +.eval-live-compose { + margin-top: var(--s-3); + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.eval-live-compose .eval-textarea { + width: 100%; + resize: vertical; + min-height: 4.5rem; + padding: var(--s-2) var(--s-3); + border: 1px solid var(--border); + border-radius: var(--r-sm); + font: inherit; + background: var(--bg-elevated); + color: var(--text); +} + +.eval-live-chapter-detail { + margin-top: var(--s-3); +} + +.eval-live-chapter-detail h4 { + margin: 0 0 var(--s-2); + font-size: var(--text-sm); +} + +.eval-live-chapter-meta { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--s-1) var(--s-3); + margin: 0 0 var(--s-3); + padding: var(--s-2) var(--s-3); + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--r-sm); + font-size: var(--text-xs); +} + +.eval-live-chapter-meta dt { + margin: 0; + color: var(--text-muted); + font-weight: 500; +} + +.eval-live-chapter-meta dd { + margin: 0; + font-family: var(--font-mono); + font-size: 0.75rem; + word-break: break-word; +} + +.eval-live-chapter-body { + max-height: min(52vh, 28rem); + overflow: auto; + padding: var(--s-3); + background: var(--bg-muted); + border: 1px solid var(--border); + border-radius: var(--r-md); +} + +.eval-live-chapter-body .eval-md { + background: transparent; +} + +.eval-live-chapter-raw { + margin-top: var(--s-3); + border-top: 1px solid var(--border); + padding-top: var(--s-2); +} + +.eval-live-chapter-raw summary { + cursor: pointer; + font-size: var(--text-sm); + color: var(--text-muted); + user-select: none; + list-style: none; +} + +.eval-live-chapter-raw summary::-webkit-details-marker { + display: none; +} + +.eval-live-chapter-raw summary::before { + content: "▸ "; + display: inline-block; + margin-right: var(--s-1); + transform: rotate(0deg); + transition: transform 0.15s ease; +} + +.eval-live-chapter-raw[open] summary::before { + transform: rotate(90deg); +} + +.eval-live-chapter-raw .eval-live-pre { + margin-top: var(--s-2); +} + +.eval-live-pre { + margin: 0; + padding: var(--s-3); + background: var(--bg-muted); + border-radius: var(--r-sm); + font-family: var(--font-mono); + font-size: 0.75rem; + overflow: auto; + max-height: 40vh; +} diff --git a/app-eval-web/src/hooks/useHashRoute.ts b/app-eval-web/src/hooks/useHashRoute.ts index 674e834..34bc294 100644 --- a/app-eval-web/src/hooks/useHashRoute.ts +++ b/app-eval-web/src/hooks/useHashRoute.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import type { AppRoute } from "../types"; -const ROUTES: AppRoute[] = ["playground", "memoir", "memoir-stories"]; +const ROUTES: AppRoute[] = ["playground", "memoir", "memoir-stories", "live"]; function parseHash(): AppRoute { const raw = window.location.hash.slice(1).split("?")[0] || "playground"; diff --git a/app-eval-web/src/mainApi.ts b/app-eval-web/src/mainApi.ts new file mode 100644 index 0000000..aeb130d --- /dev/null +++ b/app-eval-web/src/mainApi.ts @@ -0,0 +1,58 @@ +import { mainApiBase } from "./config"; + +export const LIVE_ACCESS_TOKEN_KEY = "life-echo-eval-live-access-token"; +export const LIVE_REFRESH_TOKEN_KEY = "life-echo-eval-live-refresh-token"; + +export function mainApiUrl(path: string): string { + const p = path.startsWith("/") ? path : `/${path}`; + return `${mainApiBase}${p}`; +} + +export async function mainApiFetch( + path: string, + init: RequestInit & { accessToken: string | null; jsonBody?: unknown }, +): Promise<{ ok: boolean; status: number; data?: T; error?: string }> { + const { accessToken, jsonBody, ...rest } = init; + const headers: Record = { + ...(rest.headers as Record | undefined), + }; + if (jsonBody !== undefined) { + headers["Content-Type"] = "application/json"; + } + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + const url = mainApiUrl(path); + try { + const r = await fetch(url, { + ...rest, + headers, + body: + jsonBody !== undefined ? JSON.stringify(jsonBody) : (rest.body ?? undefined), + }); + const text = await r.text(); + let data: T | undefined; + try { + data = text ? (JSON.parse(text) as T) : undefined; + } catch { + /* ignore */ + } + if (!r.ok) { + const detail = + typeof data === "object" && + data && + "detail" in (data as object) && + (data as { detail: unknown }).detail !== undefined + ? String((data as { detail: unknown }).detail) + : text || r.statusText; + return { ok: false, status: r.status, error: detail }; + } + return { ok: true, data, status: r.status }; + } catch (e: unknown) { + return { + ok: false, + status: 0, + error: e instanceof Error ? e.message : "network error", + }; + } +} diff --git a/app-eval-web/src/pages/LiveTesterPage.tsx b/app-eval-web/src/pages/LiveTesterPage.tsx new file mode 100644 index 0000000..ff59abf --- /dev/null +++ b/app-eval-web/src/pages/LiveTesterPage.tsx @@ -0,0 +1,754 @@ +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { MarkdownBlock } from "../components/MarkdownBlock"; +import { getMainWsBase, mainApiBaseHint } from "../config"; +import { usePushNotice } from "../context/NoticeContext"; +import { + LIVE_ACCESS_TOKEN_KEY, + LIVE_REFRESH_TOKEN_KEY, + mainApiFetch, + mainApiUrl, +} from "../mainApi"; + +type MeResponse = { + id: string; + phone: string; + nickname: string; + subscription_type: string; +}; + +type ConversationRow = { + id: string; + title?: string; + latestMessagePreview?: string | null; + startedAt?: number; +}; + +type MessageRow = { + id: string; + content: string; + senderType: string; + timestamp?: number; +}; + +type TokenResponse = { + access_token: string; + refresh_token: string; + token_type?: string; +}; + +type RawServerMessage = { + type: string; + conversation_id: string; + data: Record; + timestamp?: string; +}; + +type LiveEventRow = { + id: string; + kind: string; + text: string; + at: number; +}; + +function storageGet(key: string): string | null { + try { + return sessionStorage.getItem(key); + } catch { + return null; + } +} + +function storageSet(key: string, value: string) { + try { + sessionStorage.setItem(key, value); + } catch { + /* ignore */ + } +} + +function storageRemove(key: string) { + try { + sessionStorage.removeItem(key); + } catch { + /* ignore */ + } +} + +function chapterDetailMetaRows(d: Record): { label: string; value: string }[] { + const rows: { label: string; value: string }[] = []; + const title = d.title; + if (title != null && String(title).trim()) { + rows.push({ label: "标题", value: String(title).trim() }); + } + const status = d.status; + if (status != null && String(status).trim()) { + rows.push({ label: "状态", value: String(status).trim() }); + } + const wc = d.word_count; + if (typeof wc === "number" && Number.isFinite(wc)) { + rows.push({ label: "字数", value: String(Math.round(wc)) }); + } + const id = d.id; + if (id != null && String(id).trim()) { + rows.push({ label: "id", value: String(id).trim() }); + } + return rows; +} + +export default function LiveTesterPage() { + const { pushNotice } = usePushNotice(); + const [accessToken, setAccessToken] = useState(() => + storageGet(LIVE_ACCESS_TOKEN_KEY), + ); + const [refreshToken, setRefreshToken] = useState(() => + storageGet(LIVE_REFRESH_TOKEN_KEY), + ); + const [me, setMe] = useState(null); + const [phone, setPhone] = useState("13800138000"); + const [agreed, setAgreed] = useState(true); + const [loginBusy, setLoginBusy] = useState(false); + const [loginError, setLoginError] = useState(null); + + const [conversations, setConversations] = useState([]); + const [convLoading, setConvLoading] = useState(false); + const [selectedConvId, setSelectedConvId] = useState(null); + const [messages, setMessages] = useState([]); + const [msgLoading, setMsgLoading] = useState(false); + + const [draft, setDraft] = useState(""); + const [liveEvents, setLiveEvents] = useState([]); + const wsRef = useRef(null); + + const [book, setBook] = useState | null>(null); + const [chapters, setChapters] = useState[]>([]); + const [chapterDetail, setChapterDetail] = useState | null>(null); + const [memoirLoading, setMemoirLoading] = useState(false); + + const persistTokens = useCallback((access: string, refresh: string) => { + storageSet(LIVE_ACCESS_TOKEN_KEY, access); + storageSet(LIVE_REFRESH_TOKEN_KEY, refresh); + setAccessToken(access); + setRefreshToken(refresh); + }, []); + + const clearAuth = useCallback(() => { + storageRemove(LIVE_ACCESS_TOKEN_KEY); + storageRemove(LIVE_REFRESH_TOKEN_KEY); + setAccessToken(null); + setRefreshToken(null); + setMe(null); + setConversations([]); + setSelectedConvId(null); + setMessages([]); + setLiveEvents([]); + setBook(null); + setChapters([]); + setChapterDetail(null); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }, []); + + const refreshAccess = useCallback(async (): Promise => { + const rt = refreshToken ?? storageGet(LIVE_REFRESH_TOKEN_KEY); + if (!rt) return null; + const r = await mainApiFetch("/api/auth/refresh", { + method: "POST", + accessToken: null, + jsonBody: { refresh_token: rt }, + }); + if (!r.ok || !r.data) { + return null; + } + persistTokens(r.data.access_token, r.data.refresh_token); + return r.data.access_token; + }, [persistTokens, refreshToken]); + + const loadMe = useCallback( + async (token: string) => { + const r = await mainApiFetch("/api/auth/me", { + method: "GET", + accessToken: token, + }); + if (r.ok && r.data) { + setMe(r.data); + return; + } + if (r.status === 401) { + const next = await refreshAccess(); + if (next) { + const r2 = await mainApiFetch("/api/auth/me", { + method: "GET", + accessToken: next, + }); + if (r2.ok && r2.data) setMe(r2.data); + } + } + }, + [refreshAccess], + ); + + useEffect(() => { + if (!accessToken) { + setMe(null); + return; + } + void loadMe(accessToken); + }, [accessToken, loadMe]); + + const loadConversations = useCallback(async () => { + const token = accessToken; + if (!token) return; + setConvLoading(true); + try { + let r = await mainApiFetch("/api/conversations", { + method: "GET", + accessToken: token, + }); + if (!r.ok && r.status === 401) { + const next = await refreshAccess(); + if (next) { + r = await mainApiFetch("/api/conversations", { + method: "GET", + accessToken: next, + }); + } + } + if (r.ok && Array.isArray(r.data)) { + setConversations(r.data); + } else { + pushNotice( + "error", + r.error ?? `会话列表失败 (${r.status})`, + ); + } + } finally { + setConvLoading(false); + } + }, [accessToken, pushNotice, refreshAccess]); + + const loadMemoir = useCallback(async () => { + const token = accessToken; + if (!token) return; + setMemoirLoading(true); + try { + let t = token; + let bookR = await mainApiFetch>( + "/api/books/current", + { method: "GET", accessToken: t }, + ); + if (!bookR.ok && bookR.status === 401) { + const next = await refreshAccess(); + if (next) t = next; + bookR = await mainApiFetch>( + "/api/books/current", + { method: "GET", accessToken: t }, + ); + } + if (bookR.ok && bookR.data) setBook(bookR.data); + else setBook(null); + + let chR = await mainApiFetch[]>( + "/api/chapters", + { method: "GET", accessToken: t }, + ); + if (!chR.ok && chR.status === 401) { + const next = await refreshAccess(); + if (next) t = next; + chR = await mainApiFetch[]>( + "/api/chapters", + { method: "GET", accessToken: t }, + ); + } + if (chR.ok && Array.isArray(chR.data)) setChapters(chR.data); + else setChapters([]); + } finally { + setMemoirLoading(false); + } + }, [accessToken, refreshAccess]); + + useEffect(() => { + if (!accessToken) return; + void loadConversations(); + void loadMemoir(); + }, [accessToken, loadConversations, loadMemoir]); + + const loadMessages = useCallback( + async (conversationId: string) => { + const token = accessToken; + if (!token) return; + setMsgLoading(true); + setLiveEvents([]); + try { + let r = await mainApiFetch( + `/api/conversations/${encodeURIComponent(conversationId)}/messages`, + { method: "GET", accessToken: token }, + ); + if (!r.ok && r.status === 401) { + const next = await refreshAccess(); + if (next) { + r = await mainApiFetch( + `/api/conversations/${encodeURIComponent(conversationId)}/messages`, + { method: "GET", accessToken: next }, + ); + } + } + if (r.ok && Array.isArray(r.data)) setMessages(r.data); + else { + setMessages([]); + pushNotice("error", r.error ?? "加载消息失败"); + } + } finally { + setMsgLoading(false); + } + }, + [accessToken, pushNotice, refreshAccess], + ); + + useEffect(() => { + if (!selectedConvId || !accessToken) { + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + return; + } + + const convId = selectedConvId; + const token = accessToken; + const url = `${getMainWsBase()}/ws/conversation/${encodeURIComponent(convId)}?token=${encodeURIComponent(token)}`; + + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onmessage = (ev) => { + let parsed: RawServerMessage; + try { + parsed = JSON.parse(ev.data as string) as RawServerMessage; + } catch { + return; + } + if (parsed.type === "ping" || parsed.type === "pong") return; + + const at = Date.now(); + const id = `${at}_${Math.random().toString(36).slice(2, 8)}`; + const d = parsed.data ?? {}; + + if (parsed.type === "transcript") { + const text = String(d.text ?? "").trim(); + if (!text) return; + setLiveEvents((prev) => { + const withoutMatchingLocal = prev.filter( + (x) => !(x.kind === "user_local" && x.text === text), + ); + return [...withoutMatchingLocal, { id, kind: "transcript", text, at }]; + }); + return; + } + if (parsed.type === "agent_response") { + const text = String(d.text ?? ""); + if (!text.trim()) return; + setLiveEvents((prev) => [ + ...prev, + { id, kind: "agent_response", text, at }, + ]); + return; + } + if (parsed.type === "error") { + const text = String(d.message ?? d.detail ?? "error"); + setLiveEvents((prev) => [...prev, { id, kind: "error", text, at }]); + return; + } + if (parsed.type === "memoir_update") { + setLiveEvents((prev) => [ + ...prev, + { id, kind: "memoir_update", text: JSON.stringify(d), at }, + ]); + void loadMemoir(); + return; + } + if (parsed.type === "connect") { + setLiveEvents((prev) => [ + ...prev, + { id, kind: "connect", text: "已连接", at }, + ]); + } + }; + + ws.onerror = () => { + pushNotice("error", "WebSocket 错误"); + }; + + return () => { + if (wsRef.current === ws) wsRef.current = null; + ws.close(); + }; + }, [selectedConvId, accessToken, pushNotice, loadMemoir]); + + const onSelectConversation = (id: string) => { + setSelectedConvId(id); + void loadMessages(id); + }; + + const onMockLogin = async () => { + setLoginError(null); + setLoginBusy(true); + try { + const r = await mainApiFetch("/api/auth/mock/sms-login", { + method: "POST", + accessToken: null, + jsonBody: { phone, agreed_to_terms: agreed }, + }); + if (!r.ok || !r.data) { + setLoginError( + r.status === 404 + ? "Mock 登录未启用:请在主站 .env 设置 MOCK_SMS_LOGIN_ENABLED=1,且 APP_ENV 非 production" + : (r.error ?? `登录失败 (${r.status})`), + ); + return; + } + persistTokens(r.data.access_token, r.data.refresh_token); + pushNotice("success", "Mock 登录成功"); + void loadMe(r.data.access_token); + } finally { + setLoginBusy(false); + } + }; + + const onNewConversation = async () => { + if (!accessToken) return; + let token = accessToken; + let r = await mainApiFetch("/api/conversations", { + method: "POST", + accessToken: token, + }); + if (!r.ok && r.status === 401) { + const next = await refreshAccess(); + if (next) { + token = next; + r = await mainApiFetch("/api/conversations", { + method: "POST", + accessToken: token, + }); + } + } + if (r.ok && r.data?.id) { + pushNotice("success", "已创建会话"); + await loadConversations(); + onSelectConversation(r.data.id); + } else { + pushNotice("error", r.error ?? "创建会话失败"); + } + }; + + const onSendText = () => { + const text = draft.trim(); + if (!text || !selectedConvId) return; + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + pushNotice("error", "WebSocket 未连接,请稍候再试"); + return; + } + const localId = `user_local_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + setLiveEvents((prev) => [ + ...prev, + { id: localId, kind: "user_local", text, at: Date.now() }, + ]); + const payload = { + type: "text", + conversation_id: selectedConvId, + data: { text }, + }; + ws.send(JSON.stringify(payload)); + setDraft(""); + }; + + const onLoadChapter = async (chapterId: string) => { + if (!accessToken) return; + let token = accessToken; + let r = await mainApiFetch>( + `/api/chapters/${encodeURIComponent(chapterId)}`, + { method: "GET", accessToken: token }, + ); + if (!r.ok && r.status === 401) { + const next = await refreshAccess(); + if (next) { + token = next; + r = await mainApiFetch>( + `/api/chapters/${encodeURIComponent(chapterId)}`, + { method: "GET", accessToken: token }, + ); + } + } + if (r.ok && r.data) setChapterDetail(r.data); + else { + setChapterDetail(null); + pushNotice("error", r.error ?? "加载章节失败"); + } + }; + + const chapterTitles = useMemo( + () => + chapters.map((c) => ({ + id: String(c.id ?? ""), + title: String(c.title ?? c.topic_category ?? c.id ?? "章节"), + })), + [chapters], + ); + + const chapterMetaRowsList = useMemo( + () => (chapterDetail ? chapterDetailMetaRows(chapterDetail) : []), + [chapterDetail], + ); + + return ( +
+
+

实机联调

+

+ 主站 API:{mainApiBaseHint} · Mock:POST {mainApiUrl("/api/auth/mock/sms-login")} +

+
+ +
+

登录(测试账号)

+ {!accessToken ? ( +
+ + + {loginError ? ( +

+ {loginError} +

+ ) : null} + +
+ ) : ( +
+

+ 已登录:{me?.nickname || "…"}({me?.phone ?? "…"})·{" "} + {me?.id} +

+
+ + + +
+
+ )} +
+ + {accessToken ? ( +
+
+

会话

+ +
    + {conversations.map((c) => ( +
  • + +
  • + ))} +
+
+ +
+

聊天

+ {selectedConvId ? ( +

conversation_id: {selectedConvId}

+ ) : ( +

请选择左侧会话

+ )} + {msgLoading ?

加载消息…

: null} +
+ {messages.map((m) => ( +
+ {m.content} +
+ ))} + {liveEvents.map((e) => { + if ( + e.kind === "transcript" || + e.kind === "user_local" + ) { + return ( +
+ {e.text} +
+ ); + } + if (e.kind === "agent_response") { + return ( +
+ {e.text} +
+ ); + } + return ( +
+ {e.kind} + {e.text} +
+ ); + })} +
+
+