diff --git a/.env.example b/.env.example index 01d57b4c..ee48bbf5 100644 --- a/.env.example +++ b/.env.example @@ -28,18 +28,18 @@ LLM_REQUEST_TIMEOUT=90 # 模型名统一使用裸名,不带供应商前缀 # GLM-4.7 配 GLM_API_KEY -# MiniMax-M2.5 配 OPENAI_API_KEY +# GLM-5(经 OpenAI 兼容网关)配 OPENAI_API_KEY GLM_API_KEY= GLM_BASE_URL=https://api.edgefn.net/v1 GLM_MODEL=GLM-4.7 OPENAI_API_KEY= OPENAI_BASE_URL=https://api.edgefn.net/v1 -OPENAI_MODEL=MiniMax-M2.5 +OPENAI_MODEL=GLM-5 LLM_ROUTING_LOW_PROVIDER=glm LLM_ROUTING_LOW_MODEL=GLM-4.7 LLM_ROUTING_HIGH_PROVIDER=openai -LLM_ROUTING_HIGH_MODEL=MiniMax-M2.5 +LLM_ROUTING_HIGH_MODEL=GLM-5 LLM_ROUTING_SAFETY_PROVIDER=glm LLM_ROUTING_SAFETY_MODEL=GLM-4.7 diff --git a/CLAUDE.md b/CLAUDE.md index 1731f694..be2f4932 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ AIProjectManger/ ├── CLAUDE.md # 现有 Claude 说明文档 ├── start.sh / stop.sh # 一键启动/停止入口 ├── docker-compose*.yml # 容器编排 +├── mdproject/ # 执行计划与任务清单(中文为主) ├── backend/ # FastAPI 后端 ├── frontend/ # React 前端 ├── scripts/ # 根目录运维脚本 @@ -75,6 +76,7 @@ frontend/ │ ├── pages/ # 页面级组件 │ ├── components/ │ │ ├── ui/ # 基础 UI 组件 +│ │ ├── inbox/ # 消息台 Feed 列表组件 │ │ └── sstc/ # SSTC 工作流组件(已模块化拆分) │ ├── hooks/ # 自定义 hooks │ ├── lib/ # 通用工具 @@ -85,6 +87,10 @@ frontend/ └── vite.config.ts ``` +- 新增:`frontend/src/components/inbox/InboxListItem.tsx`(消息台 Feed 行组件,支持移动端与行内展开) +- 新增:`frontend/src/lib/inboxTextMap.ts`(消息台用户文案映射与标题清洗) +- 新增:`frontend/src/lib/formatInboxTime.ts`(消息台相对时间与完整时间格式化) + ### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后) - `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件) - `useSSTCWorkspaceOrchestration.ts`:工作区编排 Hook(状态聚合、请求编排、事件路由) @@ -125,8 +131,17 @@ frontend/ ```bash ./start.sh dev # 推荐:docker-dev 热更新模式 ./stop.sh dev +./devops/tunnel/start.sh # 公网穿透启动器(独立入口) +./scripts/start_model_probe.sh # 独立模型连通性检测(中文日志) ``` +- 启动入口收敛:仅保留 2 个用户入口 + - 正常启动器:`./start.sh` + - 公网穿透启动器:`./devops/tunnel/start.sh` +- 已移除:`scripts/start_legacy_local.sh`、`scripts/stop_legacy_local.sh`(旧本地启动链路) +- 新增:`scripts/start_model_probe.sh`(独立模型连通性检测启动器,输出中文日志并落盘) +- 新增:`scripts/model_probe.py`(模型检测核心逻辑,读取 `.env` 并探测 low/high 路由模型) + ### 前端 ```bash cd frontend diff --git a/backend/app/main.py b/backend/app/main.py index 6f7be938..dcdab3df 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import os +import time from contextlib import asynccontextmanager, suppress from datetime import datetime, timedelta, timezone @@ -518,9 +519,101 @@ async def _warmup_world_models() -> None: logger.warning("[WM_WARMUP] 预热失败", error=str(exc)) +def _startup_log(message: str, **fields) -> None: + logger.info(f"[后端] {message}", **fields) + + +async def _probe_model_connectivity_on_startup() -> None: + from .services.llm import llm_service + + low_provider = str(os.getenv("LLM_ROUTING_LOW_PROVIDER", os.getenv("LLM_PROVIDER", "glm")) or "").strip() or "glm" + low_model = str(os.getenv("LLM_ROUTING_LOW_MODEL", os.getenv("GLM_MODEL", "GLM-4.7")) or "").strip() or "GLM-4.7" + + high_provider = str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", os.getenv("LLM_PROVIDER", "openai")) or "").strip() or "openai" + high_model = str(os.getenv("LLM_ROUTING_HIGH_MODEL", os.getenv("OPENAI_MODEL", "GLM-5")) or "").strip() or "GLM-5" + + probe_targets: list[tuple[str, str, str]] = [ + ("LOW", low_provider, low_model), + ("HIGH", high_provider, high_model), + ] + deduped_targets: list[tuple[str, str, str]] = [] + seen: set[tuple[str, str]] = set() + for level, provider, model in probe_targets: + key = (provider.lower(), model) + if key in seen: + continue + seen.add(key) + deduped_targets.append((level, provider, model)) + + lines: list[str] = [ + "┌──────────────────────────────┐", + "│ 模型连通性检测 │", + ] + + for level, provider, model in deduped_targets: + try: + runtime = llm_service._env_provider_runtime(provider) + runtime["model"] = model + except Exception as exc: + logger.error( + "startup_model_probe_runtime_invalid", + level=level, + provider=provider, + model=model, + error=str(exc), + ) + lines.append(f"│ {model:<12} ✗ 失败 (0.0s) │") + continue + started_at = time.perf_counter() + ok = False + error_text = "" + try: + result = await asyncio.wait_for( + llm_service._chat_completion( + provider=str(runtime.get("provider") or provider), + prompt="ping", + system_prompt="你是连通性检测助手,只回复 OK。", + api_key=str(runtime.get("api_key") or ""), + base_url=str(runtime.get("base_url") or ""), + model=str(runtime.get("model") or model), + temperature=0.0, + max_tokens=1, + function_name="startup_model_probe", + response_mode="fast", + ), + timeout=8.0, + ) + ok = bool(str(result or "").strip()) + if not ok: + error_text = "empty_response" + except Exception as exc: + error_text = str(exc) + logger.error( + "startup_model_probe_failed", + level=level, + provider=provider, + model=model, + error=error_text, + ) + + elapsed = time.perf_counter() - started_at + if ok: + lines.append(f"│ {model:<12} ✓ 正常 ({elapsed:.1f}s) │") + else: + lines.append(f"│ {model:<12} ✗ 失败 ({elapsed:.1f}s) │") + if error_text: + _startup_log("模型连通性失败(不阻断启动)", model=model, provider=provider, error=error_text) + + lines.append("└──────────────────────────────┘") + for line in lines: + logger.info(line) + + @asynccontextmanager async def lifespan(_: FastAPI): """应用生命周期:初始化表与系统技能标签。""" + backend_port = str(os.getenv("BACKEND_PORT", "8000") or "8000").strip() or "8000" + _startup_log("服务启动初始化中") auto_create_tables = os.getenv("AUTO_CREATE_TABLES", "false").lower() in { "1", "true", @@ -535,6 +628,8 @@ async def lifespan(_: FastAPI): raise RuntimeError("AUTO_CREATE_TABLES=true 已被禁用;请使用 Alembic migration 变更数据库。") async with engine.begin() as conn: await conn.run_sync(_check_database_dialect_guard) + _startup_log("数据库连接成功") + await _probe_model_connectivity_on_startup() async with AsyncSessionLocal() as db: try: @@ -569,8 +664,10 @@ async def lifespan(_: FastAPI): _warmup_world_models(), name="wm-world-model-warmup", ) + _startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}") yield + _startup_log("服务关闭中") outbox_task.cancel() try: await outbox_task @@ -586,6 +683,7 @@ async def lifespan(_: FastAPI): await knowledge_scheduler.stop() await scheduler.stop() await engine.dispose() + _startup_log("服务已关闭") app = FastAPI( diff --git a/devops/tunnel/start-quick.sh b/devops/tunnel/start-quick.sh deleted file mode 100755 index ef2177ae..00000000 --- a/devops/tunnel/start-quick.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/bin/bash - -# Cloudflare Quick Tunnel (No Domain Required) -# Generates a random stable-ish URL for the session - -echo "🚀 Starting Quick Tunnel..." -echo "⚠️ Note: This uses a random trycloudflare.com URL." -echo "⚠️ For a fixed custom domain, you must PURCHASE a domain and configure it via setup.sh" - -# Kill existing tunnels -pkill -f "cloudflared tunnel" >/dev/null 2>&1 || true - -echo "🌐 Starting Frontend Tunnel (port 5174)..." -cloudflared tunnel --url http://localhost:5174 > logs/tunnel-frontend.log 2>&1 & -PID_FRONT=$! - -echo "🌐 Starting Backend Tunnel (port 8000)..." -cloudflared tunnel --url http://localhost:8000 > logs/tunnel-backend.log 2>&1 & -PID_BACK=$! - -echo "⏳ Waiting for tunnels to initialize..." -sleep 5 - -URL_FRONT=$(grep -o 'https://.*\.trycloudflare.com' logs/tunnel-frontend.log | head -1) -URL_BACK=$(grep -o 'https://.*\.trycloudflare.com' logs/tunnel-backend.log | head -1) - -check_url() { - local url="$1" - local max_retry="$2" - local retry=1 - while [ "$retry" -le "$max_retry" ]; do - if curl -kfsS --max-time 8 "$url" >/dev/null 2>&1; then - return 0 - fi - sleep 2 - retry=$((retry + 1)) - done - return 1 -} - -if [ -n "$URL_FRONT" ] && [ -n "$URL_BACK" ]; then - FRONT_OK=false - BACK_OK=false - if check_url "${URL_FRONT}/login" 25 || check_url "${URL_FRONT}" 25; then - FRONT_OK=true - fi - if check_url "${URL_BACK}/healthz" 25; then - BACK_OK=true - fi - - echo "" - if [ "$FRONT_OK" = true ] && [ "$BACK_OK" = true ]; then - echo "✅ Tunnels Started and reachable!" - else - echo "⚠️ Tunnels started, but health check not fully ready yet. Retry in 10-30s." - fi - echo "----------------------------------------" - echo "Frontend: $URL_FRONT" - echo "Backend: $URL_BACK" - echo "----------------------------------------" - echo "ℹ️ Frontend tunnel can call backend via /api proxy; no need to set VITE_API_BASE_URL." - echo "----------------------------------------" -else - echo "❌ Failed to get tunnel URLs. Check logs/tunnel-*.log" -fi - -wait $PID_FRONT $PID_BACK diff --git a/devops/tunnel/start.sh b/devops/tunnel/start.sh index 51eb4cbc..a37605e0 100755 --- a/devops/tunnel/start.sh +++ b/devops/tunnel/start.sh @@ -1,46 +1,135 @@ -#!/bin/bash +#!/usr/bin/env bash +set -euo pipefail -# Cloudflare Tunnel Start Script for PMFlow +# PMFlow 公网穿透启动器(保留为唯一 tunnel 入口) +# 优先命名隧道;未配置时自动退回 quick tunnel。 -CONFIG_FILE="devops/tunnel/config.yml" -SETUP_SCRIPT="devops/tunnel/setup.sh" +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" +CONFIG_FILE="${SCRIPT_DIR}/config.yml" +SETUP_SCRIPT="${SCRIPT_DIR}/setup.sh" +LOG_DIR="${ROOT_DIR}/logs" -echo "🚀 Checking Cloudflare Tunnel configuration..." +FRONTEND_PORT="${PMFLOW_TUNNEL_FRONTEND_PORT:-${FRONTEND_PORT:-5174}}" +BACKEND_PORT="${PMFLOW_TUNNEL_BACKEND_PORT:-${BACKEND_PORT:-8000}}" +TUNNEL_PROTOCOL="${PMFLOW_TUNNEL_PROTOCOL:-quic}" +HEALTH_RETRIES="${PMFLOW_TUNNEL_HEALTH_RETRIES:-3}" +FOREGROUND="${PMFLOW_TUNNEL_FOREGROUND:-1}" -# Check if config.yml exists -if [ ! -f "$CONFIG_FILE" ]; then - echo "⚠️ Named Tunnel Configuration not found." - echo " If you have a domain, you can set up a stable tunnel." - echo " If not, you can use Quick Tunnels (random URLs)." - echo "" - read -p "Do you have a domain and want to set up a named tunnel? (y/N): " SETUP_CHOICE - - if [[ "$SETUP_CHOICE" =~ ^[Yy]$ ]]; then - if [ -f "$SETUP_SCRIPT" ]; then - # Run setup script and check its exit code - if ! bash "$SETUP_SCRIPT"; then - echo "❌ Setup failed or was cancelled." - exit 1 - fi - else - echo "❌ Setup script not found at $SETUP_SCRIPT" - exit 1 - fi - else - echo "🔄 Falling back to Quick Tunnels..." - bash devops/tunnel/start-quick.sh - exit 0 +FRONT_LOG="${LOG_DIR}/tunnel-frontend.log" +BACK_LOG="${LOG_DIR}/tunnel-backend.log" + +extract_url() { + local logfile="$1" + grep -aEo 'https://[-a-z0-9]+\.trycloudflare\.com' "$logfile" 2>/dev/null | tail -1 +} + +wait_for_url() { + local logfile="$1" + local retries="${2:-30}" + local i=1 + local url="" + while [ "$i" -le "$retries" ]; do + url="$(extract_url "$logfile")" + if [ -n "$url" ]; then + echo "$url" + return 0 fi + sleep 1 + i=$((i + 1)) + done + return 1 +} + +check_url() { + local url="$1" + local max_retry="$2" + local retry=1 + while [ "$retry" -le "$max_retry" ]; do + if curl -kfsS --max-time 8 "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + retry=$((retry + 1)) + done + return 1 +} + +run_quick_tunnels() { + mkdir -p "$LOG_DIR" + pkill -f "cloudflared tunnel .*127\\.0\\.0\\.1:${FRONTEND_PORT}" >/dev/null 2>&1 || true + pkill -f "cloudflared tunnel .*127\\.0\\.0\\.1:${BACKEND_PORT}" >/dev/null 2>&1 || true + + echo "🚀 启动临时隧道..." + echo "📂 日志目录: ${LOG_DIR}" + echo "🌐 前端隧道: 127.0.0.1:${FRONTEND_PORT}" + cloudflared tunnel --protocol "${TUNNEL_PROTOCOL}" --url "http://127.0.0.1:${FRONTEND_PORT}" >"$FRONT_LOG" 2>&1 & + local front_pid=$! + + echo "🌐 后端隧道: 127.0.0.1:${BACKEND_PORT}" + cloudflared tunnel --protocol "${TUNNEL_PROTOCOL}" --url "http://127.0.0.1:${BACKEND_PORT}" >"$BACK_LOG" 2>&1 & + local back_pid=$! + + echo "⏳ 等待隧道地址..." + local front_url="" + local back_url="" + front_url="$(wait_for_url "$FRONT_LOG" 45 || true)" + back_url="$(wait_for_url "$BACK_LOG" 45 || true)" + + if [ -z "$front_url" ] || [ -z "$back_url" ]; then + echo "❌ 未获取到公网地址。" + echo " 前端日志: $FRONT_LOG" + echo " 后端日志: $BACK_LOG" + tail -n 20 "$FRONT_LOG" || true + tail -n 20 "$BACK_LOG" || true + kill "$front_pid" "$back_pid" >/dev/null 2>&1 || true + return 1 + fi + + local front_ok=false + local back_ok=false + if check_url "${front_url}/login" "$HEALTH_RETRIES" || check_url "${front_url}" "$HEALTH_RETRIES"; then + front_ok=true + fi + if check_url "${back_url}/healthz" "$HEALTH_RETRIES"; then + back_ok=true + fi + + if [ "$front_ok" = true ] && [ "$back_ok" = true ]; then + echo "✅ 隧道连通成功" + else + echo "⚠️ 隧道已启动,但健康检查未全部通过(可稍后重试)" + fi + + echo "----------------------------------------" + echo "前端: ${front_url}" + echo "后端: ${back_url}" + echo "----------------------------------------" + echo "ℹ️ 停止命令: pkill -f \"cloudflared tunnel\"" + + if [ "$FOREGROUND" != "1" ]; then + echo "✅ 已切到后台运行" + echo " 前端 PID: ${front_pid}" + echo " 后端 PID: ${back_pid}" + return 0 + fi + + cleanup() { + echo "" + echo "🔴 tunnel 已关闭" + kill "$front_pid" "$back_pid" >/dev/null 2>&1 || true + } + trap cleanup EXIT INT TERM + wait "$front_pid" "$back_pid" +} + +echo "🚀 启动公网穿透..." +if [ -f "$CONFIG_FILE" ]; then + echo "✅ 检测到命名隧道配置: ${CONFIG_FILE}" + echo "🌐 使用命名隧道运行(前台)" + exec cloudflared tunnel run --config "$CONFIG_FILE" fi -# Re-check config after potential setup -if [ -f "$CONFIG_FILE" ]; then - echo "✅ Configuration found." - echo "🌐 Starting tunnel for Frontend and Backend..." - - # Run the tunnel using the config file - cloudflared tunnel run --config "$CONFIG_FILE" -else - echo "❌ Failed to establish configuration." - exit 1 -fi +echo "⚠️ 未检测到命名隧道配置,自动切换临时隧道。" +echo " 若需固定域名,请先执行: ${SETUP_SCRIPT}" +run_quick_tunnels diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b604f3ad..8f700188 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -91,7 +91,24 @@ services: OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:?missing_OLLAMA_BASE_URL} OLLAMA_MODEL: ${OLLAMA_MODEL:?missing_OLLAMA_MODEL} PYTHONUNBUFFERED: "1" - command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "app"] + command: + [ + "uvicorn", + "app.main:app", + "--host", + "0.0.0.0", + "--port", + "8000", + "--reload", + "--reload-dir", + "app", + "--reload-exclude", + "*/__pycache__/*", + "--reload-exclude", + "*.pyc", + "--reload-exclude", + "alembic/versions/*" + ] volumes: - ./backend:/app/backend - ./.openclaw:/app/.openclaw:ro diff --git a/frontend/src/components/ai-steward/FloatingQAChat.tsx b/frontend/src/components/ai-steward/FloatingQAChat.tsx index 908c768f..040577b6 100644 --- a/frontend/src/components/ai-steward/FloatingQAChat.tsx +++ b/frontend/src/components/ai-steward/FloatingQAChat.tsx @@ -603,17 +603,17 @@ export function FloatingQAChat() {
diff --git a/frontend/src/components/inbox/InboxListItem.tsx b/frontend/src/components/inbox/InboxListItem.tsx new file mode 100644 index 00000000..bd54bfec --- /dev/null +++ b/frontend/src/components/inbox/InboxListItem.tsx @@ -0,0 +1,220 @@ +import { BellRing, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, LoaderCircle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatInboxAbsoluteTime, formatInboxTime } from "@/lib/formatInboxTime"; +import { + mapDisplayKindLabel, + mapIntentLabel, + mapStageText, + normalizeProjectLabel, + sanitizeInboxSubtitle, + sanitizeInboxTitle, +} from "@/lib/inboxTextMap"; +import { cn } from "@/lib/utils"; +import type { InboxActionDescriptor, InboxDisplayKind, InboxItemViewModel } from "@/types/inbox"; + +const STATUS_STYLES: Record = { + action_required: "border-amber-500/40 bg-amber-500/10 text-amber-700", + auto_running: "border-sky-500/40 bg-sky-500/10 text-sky-700", + auto_done: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700", + info: "border-slate-300 bg-slate-100 text-slate-600", +}; + +const STATUS_ICON_STYLES: Record = { + action_required: "bg-amber-100 text-amber-700", + auto_running: "bg-sky-100 text-sky-700", + auto_done: "bg-emerald-100 text-emerald-700", + info: "bg-slate-200 text-slate-600", +}; + +function formatTechnicalValue(value: unknown): string { + if (value === null || value === undefined) return "-"; + if (typeof value === "string") return value.trim() || "-"; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function resolveStatusIcon(displayKind: InboxDisplayKind) { + if (displayKind === "action_required") return CircleAlert; + if (displayKind === "auto_running") return LoaderCircle; + if (displayKind === "auto_done") return CheckCircle2; + return BellRing; +} + +export function buildInboxActionKey(item: InboxItemViewModel, action: InboxActionDescriptor): string { + return `${item.id}:${action.kind}:${action.label}`; +} + +interface InboxListItemProps { + item: InboxItemViewModel; + expanded: boolean; + pendingActionKey: string | null; + onToggleExpand: (itemId: string) => void; + onAction: (item: InboxItemViewModel, action: InboxActionDescriptor) => void; +} + +export default function InboxListItem({ + item, + expanded, + pendingActionKey, + onToggleExpand, + onAction, +}: InboxListItemProps) { + const isActionRow = item.display_kind === "action_required"; + const projectLabel = normalizeProjectLabel(item.project_name, item.project_id); + const title = sanitizeInboxTitle(item.title); + const subtitle = sanitizeInboxSubtitle(item.summary); + const displayTime = formatInboxTime(item.updated_at || item.created_at); + const fullTime = formatInboxAbsoluteTime(item.updated_at || item.created_at); + const StatusIcon = resolveStatusIcon(item.display_kind); + + const payload = (item.source_notification?.payload ?? {}) as Record; + const rawIntent = String(item.source_work_item?.intent || payload.intent || "").trim(); + const typedActions = payload.typed_actions ?? payload["typed actions"] ?? payload.typedActions; + const chatId = payload.chat_id ?? payload.source_chat_id ?? payload.chatId; + + const primaryActionKey = buildInboxActionKey(item, item.primary_action); + const secondaryActionKey = buildInboxActionKey(item, item.secondary_action); + const weakActionKey = buildInboxActionKey(item, item.weak_action); + + return ( +
+
onToggleExpand(item.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onToggleExpand(item.id); + } + }} + className={cn( + "w-full px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 md:px-4", + isActionRow ? "min-h-[72px]" : "min-h-[52px]" + )} + > +
+ + + + +
+
+

{title}

+
+
{projectLabel}
+
{displayTime}
+
+
+ +
+

{subtitle}

+ + {mapDisplayKindLabel(item.display_kind)} + +
+
+
+ +
+ {isActionRow && ( + + )} + +
+
+ + {expanded && ( +
+
+
+
当前阶段
+
{mapStageText(item.stage_text || mapDisplayKindLabel(item.display_kind))}
+
+
+
下一步
+
{sanitizeInboxSubtitle(item.next_step_text || item.ai_plan || "暂无")}
+
+
+ +
+
技术字段(默认折叠)
+
+
intent(可读):{mapIntentLabel(rawIntent)}
+
intent(原始):{rawIntent || "-"}
+
typed actions:{formatTechnicalValue(typedActions)}
+
chat_id:{formatTechnicalValue(chatId)}
+
重复次数:{item.dedupe_count}
+
撤销状态:{item.can_undo ? `可撤销(截止 ${item.undo_deadline || "-"})` : "不可撤销"}
+
完整时间戳:{fullTime}
+
+
+ +
+ {!isActionRow && item.primary_action.kind !== "none" && ( + + )} + + {item.secondary_action.kind !== "none" && ( + + )} + + {item.weak_action.kind === "none" ? ( + {item.weak_action.label} + ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index a6518728..9050897f 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Outlet, useLocation } from "react-router-dom"; import { Sidebar } from "./Sidebar"; import { TopBar } from "./TopBar"; +import { FloatingQAChat } from "@/components/ai-steward/FloatingQAChat"; export default function Layout() { const location = useLocation(); @@ -28,6 +29,7 @@ export default function Layout() {
+ ); } diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 8b235f5f..e89bac0d 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -1,11 +1,14 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Bell, CheckCircle2, LoaderCircle, LogOut, Menu, ShieldAlert, User } from "lucide-react"; import { Link, useLocation, useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo"; +import { sstcApi, type SSTCRisks } from "@/api/sstc"; import { useAuthStore } from "@/stores/auth"; import { Button } from "@/components/ui/button"; import { useToast } from "@/components/ui/toast"; import { notificationsApi, type NotificationListPayload } from "@/api/notifications"; +import { SSTCRiskTopbarEntry } from "@/components/sstc/SSTCRiskFloatPanel"; import { Badge } from "@/components/ui/badge"; import { parseBackendDate } from "@/lib/datetime"; import { zhCN } from "@/lib/zhCN"; @@ -27,6 +30,16 @@ const EMPTY_NOTIFICATIONS: NotificationListPayload = { const EMPTY_WORK_ITEMS: AIPMOWorkItemListPayload = { items: [], }; +const EMPTY_PROJECT_RISKS: SSTCRisks = { + conflicts: [], + risks: [], + summary: { + conflict_count: 0, + risk_count: 0, + blocked_task_count: 0, + overdue_task_count: 0, + }, +}; const SECTION_STYLES: Record = { action_required: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300", @@ -83,6 +96,14 @@ export function TopBar({ onMenuToggle }: TopBarProps) { const inboxData = useMemo(() => buildInboxView({ notifications, workItems }), [notifications, workItems]); const currentProjectId = location.pathname.match(/^\/projects\/(\d+)/)?.[1] || null; const inboxHref = currentProjectId ? `/inbox?project_id=${currentProjectId}` : "/inbox"; + const { data: boardOverviewPayload } = useQuery({ + queryKey: ["sstc-topbar-risk-overview", currentProjectId], + queryFn: () => sstcApi.getBoardOverview(currentProjectId!), + enabled: !!currentProjectId, + refetchInterval: POLL_INTERVAL_MS, + refetchOnWindowFocus: true, + }); + const currentProjectRisks = boardOverviewPayload?.risks || EMPTY_PROJECT_RISKS; const fetchBellData = async (notifyOnNew: boolean) => { const [notificationsResult, workItemsResult] = await Promise.allSettled([ @@ -300,6 +321,7 @@ export function TopBar({ onMenuToggle }: TopBarProps) { + {currentProjectId && } - - ); - } - return ( -
+

项目预警中心

- {total} + {formatBadgeCount(total)} -
@@ -108,3 +92,31 @@ export function SSTCRiskFloatPanel({ risks, onJump }: SSTCRiskFloatPanelProps) {
); } + +export function SSTCRiskTopbarEntry({ risks, onJump }: SSTCRiskPanelProps) { + const total = useMemo(() => computeRiskTotal(risks), [risks]); + + return ( + + + + + + + + + ); +} diff --git a/frontend/src/pages/InboxPage.tsx b/frontend/src/pages/InboxPage.tsx index 6a52307b..e922f8f3 100644 --- a/frontend/src/pages/InboxPage.tsx +++ b/frontend/src/pages/InboxPage.tsx @@ -1,264 +1,56 @@ import { useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { - BellRing, - CheckCircle2, - Filter, - LoaderCircle, - ShieldAlert, - SlidersHorizontal, - Sparkles, -} from "lucide-react"; +import { BellRing } from "lucide-react"; import { aiPmoApi } from "@/api/aiPmo"; import { notificationsApi } from "@/api/notifications"; -import { Badge } from "@/components/ui/badge"; +import InboxListItem, { buildInboxActionKey } from "@/components/inbox/InboxListItem"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useToast } from "@/components/ui/toast"; -import { buildInboxView, getInboxSectionLabel } from "@/lib/inbox"; import { getApiErrorMessage } from "@/lib/apiError"; -import { cn } from "@/lib/utils"; -import { toZhPriority } from "@/lib/display"; import { parseBackendDate } from "@/lib/datetime"; -import type { InboxActionDescriptor, InboxDisplayKind, InboxItemViewModel, InboxSectionKey } from "@/types/inbox"; +import { buildInboxView } from "@/lib/inbox"; +import { cn } from "@/lib/utils"; +import type { InboxActionDescriptor, InboxDisplayKind, InboxItemViewModel } from "@/types/inbox"; const POLL_INTERVAL_MS = 15000; -const STATUS_LABELS: Record = { - action_required: "需你决策", - auto_running: "AI 已接手", - auto_done: "AI 执行完成", - info: "仅通知", +type InboxTabKey = "all" | "needs_action" | "in_progress" | "done"; + +const TAB_LABELS: Record = { + all: "全部", + needs_action: "待处理", + in_progress: "进行中", + done: "已完成", }; -const STATUS_STYLES: Record = { - action_required: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300", - auto_running: "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300", - auto_done: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", - info: "border-slate-400/40 bg-slate-500/10 text-slate-700 dark:text-slate-300", +const TAB_TO_DISPLAY_KIND: Partial> = { + needs_action: "action_required", + in_progress: "auto_running", + done: "auto_done", }; -const METRIC_CARD_STYLES = [ - "bg-blue-50 text-blue-700", - "bg-sky-50 text-sky-700", - "bg-emerald-50 text-emerald-700", - "bg-amber-50 text-amber-700", - "bg-violet-50 text-violet-700", -]; - -const PRIORITY_OPTIONS = [ - { value: "all", label: "全部优先级" }, - { value: "high", label: "高优先级" }, - { value: "medium", label: "中优先级" }, - { value: "low", label: "低优先级" }, -]; - -function matchesPriority(item: InboxItemViewModel, priorityFilter: string): boolean { - if (priorityFilter === "all") return true; - if (priorityFilter === "high") return item.priority_rank >= 3; - if (priorityFilter === "medium") return item.priority_rank === 2; - return item.priority_rank <= 1; +function normalizeTabKey(raw?: string | null): InboxTabKey { + const value = String(raw || "").trim(); + if (value === "action_required" || value === "needs_action") return "needs_action"; + if (value === "auto_running" || value === "in_progress") return "in_progress"; + if (value === "auto_done" || value === "done") return "done"; + return "all"; } -function buildScopedSections(items: InboxItemViewModel[]) { - const sectionKeys: InboxSectionKey[] = ["all", "action_required", "auto_running", "auto_done", "info"]; - return sectionKeys.map((key) => ({ - key, - label: getInboxSectionLabel(key), - count: key === "all" ? items.length : items.filter((item) => item.display_kind === key).length, - })); +function sortByLatest(items: InboxItemViewModel[]): InboxItemViewModel[] { + const toTimestamp = (item: InboxItemViewModel): number => { + const date = parseBackendDate(item.updated_at || item.created_at); + return date ? date.getTime() : -1; + }; + + return [...items].sort((a, b) => toTimestamp(b) - toTimestamp(a)); } -function isToday(value?: string | null) { - const date = parseBackendDate(value); - if (!date) return false; - const now = new Date(); - return ( - date.getFullYear() === now.getFullYear() && - date.getMonth() === now.getMonth() && - date.getDate() === now.getDate() - ); -} - -function formatDetailTime(value?: string | null) { - const date = parseBackendDate(value); - if (!date) return value || "-"; - return date.toLocaleString("zh-CN", { hour12: false }); -} - -function formatJsonBlock(value: unknown): string { - if (!value) return "暂无"; - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - -function buildActionKey(item: InboxItemViewModel, action: InboxActionDescriptor): string { - return `${item.id}:${action.kind}:${action.label}`; -} - -function renderMiniList(title: string, description: string, items: InboxItemViewModel[], onOpen: (item: InboxItemViewModel) => void) { - return ( - - - {title} - {description} - - - {items.length === 0 ? ( -
- 当前为空 -
- ) : ( - items.map((item) => ( - - )) - )} -
-
- ); -} - -function InboxCard({ - item, - onAction, - pendingActionKey, -}: { - item: InboxItemViewModel; - onAction: (item: InboxItemViewModel, action: InboxActionDescriptor) => void; - pendingActionKey: string | null; -}) { - const stageCards = [ - { label: "当前阶段", value: item.stage_text || STATUS_LABELS[item.display_kind] }, - { label: "预计下一步", value: item.next_step_text || item.ai_plan || "-" }, - { label: "撤销状态", value: item.can_undo ? `可撤销,截止 ${item.undo_deadline || "-"}` : "不可撤销" }, - ]; - const primaryActionKey = buildActionKey(item, item.primary_action); - const secondaryActionKey = buildActionKey(item, item.secondary_action); - const weakActionKey = buildActionKey(item, item.weak_action); - const primaryPending = pendingActionKey === primaryActionKey; - const secondaryPending = pendingActionKey === secondaryActionKey; - const weakPending = pendingActionKey === weakActionKey; - - return ( - - -
-
- - {STATUS_LABELS[item.display_kind]} - - - {toZhPriority(item.priority)} - - {item.project_id && ( - - {item.project_name || `项目 #${item.project_id}`} - - )} - {item.dedupe_count > 1 && ( - - 重复 {item.dedupe_count} 次 - - )} -
-
-
{item.updated_at || item.created_at || "-"}
-
-
-
-
- {item.title} - {item.summary} -
- -
-
- - {item.ai_plan && ( -
-
AI 已做/将做什么
-

{item.ai_plan}

-
- )} - - {item.blocking_reason && ( -
-
为什么需要你
-

{item.blocking_reason}

-
- )} - - {(item.stage_text || item.next_step_text || item.can_undo) && ( -
- {stageCards.map((stage) => ( -
-
{stage.label}
-

{stage.value}

-
- ))} -
- )} - -
- - - {item.weak_action.kind === "none" ? ( - {item.weak_action.label} - ) : ( - - )} -
-
-
- ); +function matchesTab(item: InboxItemViewModel, tab: InboxTabKey): boolean { + if (tab === "all") return true; + return item.display_kind === TAB_TO_DISPLAY_KIND[tab]; } export default function InboxPage() { @@ -266,34 +58,25 @@ export default function InboxPage() { const queryClient = useQueryClient(); const { toast } = useToast(); const [searchParams, setSearchParams] = useSearchParams(); - const [notificationDetailItem, setNotificationDetailItem] = useState(null); - const [workItemDetailItem, setWorkItemDetailItem] = useState(null); const [pendingActionKey, setPendingActionKey] = useState(null); - const [priorityFilter, setPriorityFilter] = useState("all"); - const [onlyDecision, setOnlyDecision] = useState(false); - const [onlyUnread, setOnlyUnread] = useState(false); - const activeSection = (searchParams.get("section") as InboxSectionKey) || "all"; + const [expandedItemId, setExpandedItemId] = useState(null); + + const activeTab = normalizeTabKey(searchParams.get("section")); const projectFilter = searchParams.get("project_id") || "all"; const searchValue = searchParams.get("q") || ""; const notificationsQuery = useQuery({ queryKey: ["notifications", "inbox"], - queryFn: () => notificationsApi.list({ limit: 60 }), + queryFn: () => notificationsApi.list({ limit: 80 }), refetchInterval: POLL_INTERVAL_MS, }); const workItemsQuery = useQuery({ queryKey: ["ai-pmo-work-items", "inbox"], - queryFn: () => aiPmoApi.listWorkItems({ limit: 60 }), + queryFn: () => aiPmoApi.listWorkItems({ limit: 80 }), refetchInterval: POLL_INTERVAL_MS, }); - const workItemDetailQuery = useQuery({ - queryKey: ["ai-pmo-work-item-detail", workItemDetailItem?.source_work_item?.id || null], - queryFn: () => aiPmoApi.getWorkItem(Number(workItemDetailItem?.source_work_item?.id)), - enabled: Boolean(workItemDetailItem?.source_work_item?.id), - }); - const inboxData = useMemo(() => { return buildInboxView({ notifications: notificationsQuery.data || { items: [], unread_count: 0 }, @@ -317,42 +100,35 @@ export default function InboxPage() { }); }, [inboxData.items, projectFilter]); - const filteredItems = useMemo(() => { - const query = searchValue.trim().toLowerCase(); - return projectScopedItems.filter((item) => { - if (activeSection !== "all" && item.display_kind !== activeSection) return false; - if (onlyDecision && item.display_kind !== "action_required") return false; - if (onlyUnread && item.is_read) return false; - if (!matchesPriority(item, priorityFilter)) return false; - if (!query) return true; - return `${item.title} ${item.summary} ${item.ai_plan || ""} ${item.project_name || ""}`.toLowerCase().includes(query); - }); - }, [activeSection, onlyDecision, onlyUnread, priorityFilter, projectScopedItems, searchValue]); - - const scopedSections = useMemo(() => buildScopedSections(projectScopedItems), [projectScopedItems]); - const scopedLeaderSummary = useMemo(() => { + const tabCounts = useMemo(() => { return { - decision_count: projectScopedItems.filter((item) => item.display_kind === "action_required").length, - auto_running_count: projectScopedItems.filter((item) => item.display_kind === "auto_running").length, - auto_done_today: projectScopedItems.filter( - (item) => item.display_kind === "auto_done" && isToday(item.updated_at || item.created_at) - ).length, - high_priority_blocked_count: projectScopedItems.filter( - (item) => item.display_kind === "action_required" && item.priority_rank >= 3 && Boolean(item.blocking_reason) - ).length, - external_authorization_count: projectScopedItems.filter( - (item) => - item.display_kind === "action_required" && - /(github|gitlab|jira|接入|授权|跨系统|外部系统)/i.test(`${item.title} ${item.summary} ${item.blocking_reason || ""}`) - ).length, - top_decisions: projectScopedItems.filter((item) => item.display_kind === "action_required").slice(0, 3), - recent_completions: projectScopedItems.filter((item) => item.display_kind === "auto_done").slice(0, 3), - }; + all: projectScopedItems.length, + needs_action: projectScopedItems.filter((item) => item.display_kind === "action_required").length, + in_progress: projectScopedItems.filter((item) => item.display_kind === "auto_running").length, + done: projectScopedItems.filter((item) => item.display_kind === "auto_done").length, + } satisfies Record; }, [projectScopedItems]); - const setSection = (section: string) => { + const searchedItems = useMemo(() => { + const query = searchValue.trim().toLowerCase(); + if (!query) return projectScopedItems; + + return projectScopedItems.filter((item) => { + return `${item.title} ${item.summary} ${item.project_name || ""} ${item.stage_text || ""}` + .toLowerCase() + .includes(query); + }); + }, [projectScopedItems, searchValue]); + + const feedItems = useMemo(() => { + const tabItems = searchedItems.filter((item) => matchesTab(item, activeTab)); + return sortByLatest(tabItems); + }, [searchedItems, activeTab]); + + const setTab = (tab: string) => { + const normalized = normalizeTabKey(tab); const next = new URLSearchParams(searchParams); - next.set("section", section); + next.set("section", normalized); setSearchParams(next); }; @@ -370,6 +146,10 @@ export default function InboxPage() { setSearchParams(next); }; + const toggleExpand = (itemId: string) => { + setExpandedItemId((current) => (current === itemId ? null : itemId)); + }; + const refreshAll = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["notifications", "inbox"] }), @@ -382,28 +162,12 @@ export default function InboxPage() { try { await notificationsApi.markRead(Number(item.source_notification.id)); } catch { - // 不阻断后续跳转。 + // 不阻断后续操作。 } } }; - const openNotificationDetail = async (item: InboxItemViewModel) => { - await markNotificationRead(item); - setNotificationDetailItem(item); - await refreshAll(); - }; - - const openWorkItemDetail = async (item: InboxItemViewModel) => { - setWorkItemDetailItem(item); - await refreshAll(); - }; - - const openTarget = async (item: InboxItemViewModel, options?: { forceDirect?: boolean }) => { - if (!options?.forceDirect && item.source_kind === "notification" && item.display_kind === "info") { - await openNotificationDetail(item); - return; - } - + const openTarget = async (item: InboxItemViewModel) => { await markNotificationRead(item); if (item.target_url) { @@ -412,60 +176,46 @@ export default function InboxPage() { await refreshAll(); return; } - if (item.source_kind === "ai_pmo_work_item") { - await openWorkItemDetail(item); - return; - } - toast({ title: "当前卡片暂无可打开的详情入口", variant: "error" }); + + toast({ title: "当前消息暂无可打开的目标入口", variant: "default" }); }; const handleAction = async (item: InboxItemViewModel, action: InboxActionDescriptor) => { - const actionKey = buildActionKey(item, action); + const actionKey = buildInboxActionKey(item, action); setPendingActionKey(actionKey); + try { - if (item.source_kind === "notification" && action.label === "查看详情") { - await openNotificationDetail(item); - return; - } if (action.kind === "open_target") { await openTarget(item); return; } + if (action.kind === "open_detail") { - if (item.source_kind === "notification") { - await openNotificationDetail(item); - return; - } - await openWorkItemDetail(item); + await markNotificationRead(item); + toggleExpand(item.id); + await refreshAll(); return; } + if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, { action: "approve", project_id: item.project_id ?? undefined, }); await refreshAll(); + const followupReply = String(result.followup_reply || "").trim(); if (result.status === "succeeded") { - toast({ title: followupReply || "AI PMO 已继续执行并完成该工作项", variant: "success" }); + toast({ title: followupReply || "已继续执行并完成", variant: "success" }); } else if (result.status === "pending_manual") { - toast({ title: followupReply || "继续执行后仍需补充信息或人工确认", variant: "default" }); + toast({ title: followupReply || "继续执行后仍需人工确认", variant: "default" }); } else { toast({ title: followupReply || "继续执行失败,请查看详情", variant: "error" }); } - setWorkItemDetailItem({ - ...item, - display_kind: - result.status === "succeeded" - ? "auto_done" - : result.status === "pending_manual" - ? "action_required" - : "info", - stage_text: String(result.target_state || item.stage_text || "").trim() || item.stage_text, - next_step_text: String(result.followup_reply || item.next_step_text || "").trim() || item.next_step_text, - }); + setExpandedItemId(item.id); return; } + if (action.kind === "reject" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, { action: "reject", @@ -474,22 +224,17 @@ export default function InboxPage() { await refreshAll(); const followupReply = String(result.followup_reply || "").trim(); toast({ title: followupReply || "已标记失效,不再继续执行", variant: "default" }); - if (workItemDetailItem?.id === item.id) { - setWorkItemDetailItem(null); - } return; } + if (action.kind === "mark_read" && item.source_kind === "notification" && item.source_notification) { await notificationsApi.markRead(item.source_notification.id); await refreshAll(); return; } + if (action.kind === "none") { - if (item.source_kind === "ai_pmo_work_item") { - await openWorkItemDetail(item); - return; - } - toast({ title: action.label, variant: "default" }); + toggleExpand(item.id); } } catch (error) { toast({ title: getApiErrorMessage(error), variant: "error" }); @@ -501,8 +246,8 @@ export default function InboxPage() { const markAllRead = async () => { try { await notificationsApi.markAllRead(); - toast({ title: "站内通知已全部设为已读", variant: "success" }); await refreshAll(); + toast({ title: "站内通知已全部设为已读", variant: "success" }); } catch (error) { toast({ title: getApiErrorMessage(error, "全部已读失败"), variant: "error" }); } @@ -511,403 +256,102 @@ export default function InboxPage() { const isLoading = notificationsQuery.isLoading || workItemsQuery.isLoading; return ( - <> -
-
-
-
-

消息台

-

AI 决策收件箱 · 先看摘要,再处理真正需要你拍板的事项

-

- 当前结果:{filteredItems.length} 条 - {projectFilter !== "all" ? `(已筛选项目 #${projectFilter})` : "(全租户汇总)"} -

-
-
- - -
+
+
+
+
+

消息台

+

列表 Feed · 最新在上 · 支持移动端

+

当前结果:{feedItems.length} 条

-
-
- - AI 决策收件箱 - -

铃铛只看摘要,完整工作流统一沉到 Inbox

-
- - 只保留真正可执行的事项;查询、闲聊、健康提示默认不进入消息台。 - -
- {[ - { icon: ShieldAlert, label: "需你决策", value: scopedLeaderSummary.decision_count }, - { icon: LoaderCircle, label: "AI处理中", value: scopedLeaderSummary.auto_running_count }, - { icon: CheckCircle2, label: "今日自动完成", value: scopedLeaderSummary.auto_done_today }, - { icon: Sparkles, label: "高优先级阻塞", value: scopedLeaderSummary.high_priority_blocked_count }, - { icon: Filter, label: "待授权外部接入", value: scopedLeaderSummary.external_authorization_count }, - ].map(({ icon: Icon, label, value }, index) => ( -
-
-
-
{label}
-
{value}
-
- - - -
-
- ))} -
+ +
+ +
+ setSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") setSearch((event.target as HTMLInputElement).value); + }} + /> + + +
+ +
+
信息流筛选(单列时间流,不分栏)
+
+ {(Object.keys(TAB_LABELS) as InboxTabKey[]).map((tabKey) => ( + + ))}
-
- {renderMiniList("最需要你拍板的 3 件事", "优先看高风险、上下文不完整或需要授权的事项。", scopedLeaderSummary.top_decisions, (item) => - void handleAction(item, item.primary_action) - )} - {renderMiniList("AI 刚完成的 3 件事", "自动结果会优先给出产物和影响范围。", scopedLeaderSummary.recent_completions, (item) => - void handleAction(item, item.primary_action) - )} +
+
+ + {isLoading ? ( +
+
+ + 加载消息台中…
- -
-
-
-
- setSearch(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") setSearch((event.target as HTMLInputElement).value); - }} - /> -
- - - - -
- - - - {scopedSections.map((section) => ( - - {section.label} {section.count} - - ))} - - + ) : feedItems.length === 0 ? ( +
+
+ +

当前没有匹配收件项

+

请调整筛选条件后重试

- - {isLoading ? ( -
-
- - 加载消息台中… -
-
- ) : filteredItems.length === 0 ? ( -
- - - -

当前没有匹配收件项

-

请调整筛选条件后重试

-
-
-
- ) : ( -
-
- {filteredItems.map((item) => ( - void handleAction(target, action)} - /> - ))} -
-
- )} -
- - { - if (!open) setWorkItemDetailItem(null); - }} - > - - - {workItemDetailItem?.title || "工作项详情"} - - {workItemDetailItem?.project_name || (workItemDetailItem?.project_id ? `项目 #${workItemDetailItem.project_id}` : "未绑定项目")} - {" · "} - {formatDetailTime(workItemDetailItem?.updated_at || workItemDetailItem?.created_at)} - - - - {workItemDetailItem && ( -
-
- - {STATUS_LABELS[workItemDetailItem.display_kind]} - - - {toZhPriority(workItemDetailItem.priority)} - - {workItemDetailItem.source_work_item?.intent && ( - - {workItemDetailItem.source_work_item.intent} - - )} - {workItemDetailItem.source_work_item?.source_system && ( - - {workItemDetailItem.source_work_item.source_system} - - )} -
- -
-
工作项摘要
-

- {workItemDetailItem.summary || "暂无摘要"} -

-
- - {workItemDetailItem.source_work_item?.blocking_reason && ( -
-
阻塞原因
-

- {workItemDetailItem.source_work_item.blocking_reason} -

-
- )} - - {workItemDetailItem.source_work_item?.ai_plan && ( -
-
AI 执行计划
-

- {workItemDetailItem.source_work_item.ai_plan} -

-
- )} - -
-
-
当前阶段
-

- {workItemDetailItem.source_work_item?.stage_text || STATUS_LABELS[workItemDetailItem.display_kind]} -

-
-
-
下一步
-

- {workItemDetailItem.source_work_item?.next_step_text || "暂无"} -

-
-
- - {workItemDetailQuery.isLoading ? ( -
- 加载工作项详情中… -
- ) : workItemDetailQuery.data ? ( - <> -
-
-
决策记录
-

{workItemDetailQuery.data.decision_records.length} 条

-
-
-
执行计划
-

{workItemDetailQuery.data.plan_bundles.length} 条

-
-
-
回执记录
-

{workItemDetailQuery.data.reply_receipts.length} 条

-
-
- -
-
执行结果
-
-                      {formatJsonBlock(workItemDetailQuery.data.execution_result)}
-                    
-
- -
-
复盘摘要
-
-                      {formatJsonBlock(workItemDetailQuery.data.review_summary)}
-                    
-
- - ) : ( -
- 工作项详情加载失败,请稍后重试。 -
- )} -
- )} - - - - {workItemDetailItem?.target_url && ( - - )} - -
-
- - { - if (!open) setNotificationDetailItem(null); - }} - > - - - {notificationDetailItem?.title || "通知详情"} - - {notificationDetailItem?.project_name || (notificationDetailItem?.project_id ? `项目 #${notificationDetailItem.project_id}` : "未绑定项目")} - {" · "} - {formatDetailTime(notificationDetailItem?.updated_at || notificationDetailItem?.created_at)} - - - - {notificationDetailItem && ( -
-
- - {STATUS_LABELS[notificationDetailItem.display_kind]} - - - {toZhPriority(notificationDetailItem.priority)} - - {notificationDetailItem.source_notification?.source && ( - - {notificationDetailItem.source_notification.source} - - )} - {notificationDetailItem.source_work_item?.source_system && ( - - {notificationDetailItem.source_work_item.source_system} - - )} -
- -
-
通知摘要
-

- {notificationDetailItem.source_notification?.content || notificationDetailItem.summary || "暂无摘要"} -

-
- - {notificationDetailItem.ai_plan && ( -
-
AI 已做/将做什么
-

{notificationDetailItem.ai_plan}

-
- )} - - {notificationDetailItem.blocking_reason && ( -
-
当前阻塞
-

{notificationDetailItem.blocking_reason}

-
- )} - -
-
-
当前阶段
-

{notificationDetailItem.stage_text || STATUS_LABELS[notificationDetailItem.display_kind]}

-
-
-
建议下一步
-

{notificationDetailItem.next_step_text || "先确认通知内容,再进入结果页处理。"}

-
-
-
- )} - - - - {notificationDetailItem?.target_url && ( - - )} - -
-
- + ) : ( +
+ {feedItems.map((item) => ( + void handleAction(target, action)} + /> + ))} +
+ )} +
); } diff --git a/frontend/src/pages/ProjectBoardPage.tsx b/frontend/src/pages/ProjectBoardPage.tsx index ab7af829..6d42c375 100644 --- a/frontend/src/pages/ProjectBoardPage.tsx +++ b/frontend/src/pages/ProjectBoardPage.tsx @@ -2,7 +2,7 @@ import { useDeferredValue, useEffect, useMemo, useState } from "react"; import { useParams, Link, useNavigate } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Plus } from "lucide-react"; -import { aiPmoApi } from "@/api/aiPmo"; +import { aiPmoApi, type AIPMOWorkItem } from "@/api/aiPmo"; import { sstcApi, type AIRoutingConfig, type SSTCSignal, type StageKey } from "@/api/sstc"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -23,7 +23,6 @@ import { BudgetControlPanel } from "@/components/budget/BudgetControlPanel"; import { SSTCGlobalHeader } from "@/components/sstc/SSTCGlobalHeader"; import { SSTCStageNav } from "@/components/sstc/SSTCStageNav"; import { SSTCWorkspace } from "@/components/sstc/SSTCWorkspace"; -import { SSTCRiskFloatPanel } from "@/components/sstc/SSTCRiskFloatPanel"; import { useToast } from "@/components/ui/toast"; import { formatProjectNameWithId, toZhPriority } from "@/lib/display"; import { getApiErrorMessage } from "@/lib/apiError"; @@ -47,6 +46,70 @@ const DEFAULT_SIGNAL_FILTERS: SignalFilterState = { created_from: "", created_to: "", }; +const REGISTRY_REPAIR_SOURCE_MESSAGE_ID_RE = /^om_registry_repair_(\d+)_(\d+)_(base|explicit)$/i; +const REGISTRY_REPAIR_TEXT_RE = /补录历史接入记录\s*registry#(\d+)(?:(([^)]+)))?/i; + +function normalizeTechText(value?: string | null): string { + const raw = String(value || "").trim(); + if (!raw) return ""; + return raw + .replace(/补录历史接入记录/g, "补全历史接入信息") + .replace(/registry#(\d+)/gi, "记录#$1") + .replace(/请仅执行入库,不需要二次处理。?/g, "请确认后继续,系统会自动完成补录。"); +} + +function dedupeWorkItemsBySemanticKey(items: AIPMOWorkItem[]): AIPMOWorkItem[] { + const seen = new Set(); + const deduped: AIPMOWorkItem[] = []; + for (const item of items) { + const sourceMessageId = String(item.source_message_id || "").trim(); + const sourceMatch = sourceMessageId.match(REGISTRY_REPAIR_SOURCE_MESSAGE_ID_RE); + let key = `work_item:${item.id}`; + if (sourceMatch) { + key = `registry_repair:${sourceMatch[1]}:${sourceMatch[2]}`; + } else { + const merged = [item.title, item.summary, item.next_step_text].filter(Boolean).join(" "); + const textMatch = merged.match(REGISTRY_REPAIR_TEXT_RE); + if (textMatch) { + key = `registry_repair:${item.project_id || "na"}:${textMatch[1]}`; + } + } + if (seen.has(key)) continue; + seen.add(key); + deduped.push(item); + } + return deduped; +} + +function toReadableWorkItemTitle(item: AIPMOWorkItem): string { + const sourceMessageId = String(item.source_message_id || "").trim(); + const sourceMatch = sourceMessageId.match(REGISTRY_REPAIR_SOURCE_MESSAGE_ID_RE); + if (sourceMatch) { + return `补全历史接入记录(#${sourceMatch[2]})`; + } + const titleMatch = String(item.title || "").match(REGISTRY_REPAIR_TEXT_RE); + if (titleMatch) { + return `补全历史接入记录(#${titleMatch[1]})`; + } + return normalizeTechText(item.title) || `工作项#${item.id}`; +} + +function toReadableWorkItemSummary(item: AIPMOWorkItem): string { + const merged = [item.title, item.summary, item.next_step_text].filter(Boolean).join(" "); + const textMatch = merged.match(REGISTRY_REPAIR_TEXT_RE); + if (textMatch) { + const sourceName = String(textMatch[2] || "").trim(); + if (sourceName) { + return `检测到「${sourceName}」存在历史接入信息缺失,请确认后继续,系统将自动入库并回填关联。`; + } + return "检测到历史接入信息缺失,请确认后继续,系统将自动入库并回填关联。"; + } + return ( + normalizeTechText(item.next_step_text) + || normalizeTechText(item.summary) + || "请进入收件箱查看详情并选择下一步处理。" + ); +} export default function ProjectBoardPage() { const SIGNAL_PAGE_SIZE = 10; @@ -322,7 +385,14 @@ export default function ProjectBoardPage() { }), 24 ); - const pmoWorkItems = pmoWorkItemsPayload?.items || []; + const pmoWorkItems = useMemo( + () => dedupeWorkItemsBySemanticKey(pmoWorkItemsPayload?.items || []), + [pmoWorkItemsPayload?.items] + ); + const pendingManualWorkItemCount = useMemo( + () => pmoWorkItems.filter((item) => item.state === "waiting_manual").length, + [pmoWorkItems] + ); const riskSummary = (pmoRiskSnapshot?.summary || {}) as Record; const dailyPayload = (pmoDailySnapshot?.payload || {}) as Record; const riskActions = Array.isArray(dailyPayload.focus_actions) @@ -525,14 +595,14 @@ export default function ProjectBoardPage() {
- {[ - { label: "Blocked Tasks", value: Number(riskSummary.blocked_task_count || 0) }, - { label: "待确认信号源", value: Number(riskSummary.pending_signal_source_count || 0) }, - { label: "风险通知", value: Number(riskSummary.unread_risk_notification_count || 0) }, - { label: "待确认工作项", value: pmoWorkItems.filter((item) => item.state === "waiting_manual").length }, - ].map((metric) => ( -
-
{metric.label}
+ {[ + { label: "Blocked Tasks", value: Number(riskSummary.blocked_task_count || 0) }, + { label: "待确认信号源", value: Number(riskSummary.pending_signal_source_count || 0) }, + { label: "风险通知", value: Number(riskSummary.unread_risk_notification_count || 0) }, + { label: "待确认工作项", value: pendingManualWorkItemCount }, + ].map((metric) => ( +
+
{metric.label}
{metric.value}
))} @@ -563,8 +633,19 @@ export default function ProjectBoardPage() {
{pmoWorkItems.length === 0 ? ( -
- 当前项目暂无 PMO 工作项。 +
+
当前项目暂无 PMO 工作项。
+
+ 建议下一步:先在任务阶段创建首批任务,或前往消息台确认待处理信号。 +
+
+ + +
) : ( pmoWorkItems.map((item) => ( @@ -575,11 +656,11 @@ export default function ProjectBoardPage() { onClick={() => navigate(`/inbox?project_id=${id}&q=${encodeURIComponent(item.title)}`)} >
-
{item.title}
+
{toReadableWorkItemTitle(item)}
{item.state === "waiting_manual" && }
{item.stage_text || item.state}
-
{item.next_step_text || item.summary}
+
{toReadableWorkItemSummary(item)}
)) )} @@ -642,8 +723,6 @@ export default function ProjectBoardPage() { - {risks && } - diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index ec82d20a..de3c47b4 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/openclawcommanderpanel.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/inbox.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/loginpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/openclawcommanderpanel.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/loginpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 20bbc895..043a5ace 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,6 +6,7 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); const tunnelDomain = env.VITE_APP_DOMAIN; const proxyTarget = env.VITE_DEV_API_PROXY_TARGET || "http://127.0.0.1:8000"; + const usePolling = String(env.CHOKIDAR_USEPOLLING || "false").toLowerCase() === "true"; return { plugins: [react()], @@ -17,6 +18,10 @@ export default defineConfig(({ mode }) => { server: { port: 5174, allowedHosts: true, + watch: usePolling ? { + usePolling: true, + interval: 150, + } : undefined, hmr: tunnelDomain ? { host: tunnelDomain, clientPort: 443, diff --git a/mdproject/inbox-feed-list-refactor-task-zh.md b/mdproject/inbox-feed-list-refactor-task-zh.md new file mode 100644 index 00000000..b16e5f5e --- /dev/null +++ b/mdproject/inbox-feed-list-refactor-task-zh.md @@ -0,0 +1,193 @@ +# 消息台重构任务单(卡片改列表 Feed) + +> 目标:把消息台从双列卡片重构为高密度列表 Feed,保证用户可读、可操作、可追溯。 +> 范围:仅前端(不改后端接口)。 +> 新增硬约束:信息流严格按时间倒序,最新在上。 + +--- + +## 0. 实施原则(先定边界) + +- 不改 `backend` 接口与字段,仅做前端映射/渲染重构。 +- 保持 `buildInboxView` 领域聚合能力不变,避免影响 TopBar 与其他入口。 +- 用户视角文案与技术字段解耦:技术字段可在行内展开区可见,默认隐藏。 +- 主列表排序规则统一:`updated_at || created_at` 降序(最新在上)。 + +--- + +## 1. 现状定位(SCOUT) + +- [x] 定位页面入口:`frontend/src/pages/InboxPage.tsx` +- [x] 定位数据聚合:`frontend/src/lib/inbox.ts` +- [x] 定位类型定义:`frontend/src/types/inbox.ts` +- [x] 定位顶部铃铛预览依赖:`frontend/src/components/layout/TopBar.tsx` +- [x] 确认以下信息: + - [x] 列表数据来源接口(notifications + ai-pmo work-items) + - [x] 当前卡片组件边界(`InboxCard`) + - [x] Tab 分类逻辑位置(`activeSection` + `display_kind`) + +--- + +## 2. 爆炸半径评估(TRACE) + +- [x] 执行 `FIND_USAGE buildInboxView` +- [x] 执行 `FIND_USAGE InboxDisplayKind` +- [x] 执行 `FIND_USAGE InboxSectionKey` +- [x] 执行 `FIND_USAGE getInboxSectionLabel` +- [x] 评估结论记录: + - [x] `InboxPage` 改造是否会影响 `TopBar` 预览行为 + - [x] 是否需要保持 `action_required|auto_running|auto_done|info` 内部枚举不变 + +> 说明:本次推荐保持内部枚举不变,仅在 UI 层映射为“待处理/进行中/已完成/全部”。 + +--- + +## 3. 组件与文件改造清单(SNIPER) + +### 3.1 新增文件 + +- [x] `frontend/src/components/inbox/InboxListItem.tsx` +- [x] `frontend/src/lib/inboxTextMap.ts` +- [x] `frontend/src/lib/formatInboxTime.ts` + +### 3.2 修改文件 + +- [x] `frontend/src/pages/InboxPage.tsx` +- [x] `frontend/src/components/layout/TopBar.tsx`(复用统一时间格式函数,可选;本次保持现状未改) +- [x] `CLAUDE.md`(目录结构变更同步) + +--- + +## 4. 列表行设计规范 + +### 4.1 两种行高 + +- 需要操作行:72px + - 左:状态图标 + 标题 + 副标题 + - 右:项目名、时间、`主操作`、`详情` +- 纯通知行:52px + - 左:状态图标 + 标题 + 副标题 + - 右:项目名、时间、`详情` + +### 4.2 行内交互 + +- [x] 点击行本体:展开/收起详情(inline,不跳页) +- [x] 点击主操作按钮:执行动作,不触发行展开 +- [x] 点击详情按钮:仅展开详情(或打开详情弹窗,二选一,需统一) + +### 4.3 详情展开区(默认折叠) + +- [x] 显示技术字段:`intent` 原始值、typed actions、`chat_id`、完整时间戳、重复次数、撤销信息 +- [x] 默认主行不出现技术字段 + +--- + +## 5. 文案映射策略(用户视角) + +### 5.1 intent 映射(显示层) + +- `signal_source.onboard` → `信号源接入` +- `signal.create` → `新信号识别` +- `project.create` → `项目已创建` +- `query.read_only` → `信息查询` +- `followup.confirm` → `确认执行` +- `followup.continue` → `跟进处理` +- `ignore` → `系统忽略` + +### 5.2 状态映射(显示层) + +- `action_required` → `待处理` +- `auto_running` → `进行中` +- `auto_done` → `已完成` +- `info` → `通知` + +### 5.3 标题清洗规则 + +- [x] 优先使用 `notification.title` / `work_item.title` +- [x] 包含 `给项目ID:` 前缀时仅保留后半段 +- [x] `registry#` 替换为 `历史记录` +- [x] 去除明显技术噪音字段展示(默认行) + +--- + +## 6. Tab 重构(不改底层枚举) + +## 用户 Tab(展示) + +- `待处理`(映射 `action_required`) +- `进行中`(映射 `auto_running`) +- `已完成`(映射 `auto_done`) +- `全部`(映射 `all`) + +## 计数规则 + +- [x] 每个 Tab 角标来自当前项目过滤后的实时集合 +- [x] `全部 = 待处理 + 进行中 + 已完成 + 通知`(若通知不单列则并入全部但不单独显示) + +--- + +## 7. 时间与排序规则(强制) + +### 7.1 时间显示 + +- 1 分钟内:`刚刚` +- 1 小时内:`X 分钟前` +- 当天:`HH:mm` +- 昨天:`昨天 HH:mm` +- 更早:`MM-DD HH:mm` + +### 7.2 排序(硬约束) + +- 主 Feed 严格按 `updated_at || created_at` 降序。 +- 最新消息永远在最上方。 +- Tab 切换、搜索、项目筛选后仍保持同一倒序规则。 +- 如时间缺失:降级到 `created_at`;仍缺失时排到分组末尾。 + +--- + +## 8. 首屏密度与布局调整 + +- [x] 折叠/压缩顶部指标区(避免占用过高首屏) +- [x] 列表单列展示(移动端与桌面统一 Feed 心智) +- [x] 状态筛选仅作为过滤器,不采用任何看板分栏布局 +- [x] 验证 1080p 下首屏可见条数 `>= 8` + +--- + +## 9. 验收标准(Definition of Done) + +- [x] 一屏可见条数 `>= 8` +- [x] 主列表默认无技术字段暴露(展开区除外) +- [x] Tab 筛选准确:待处理/进行中/已完成/全部 +- [x] 时间显示全部为相对/友好格式 +- [x] 点击行可展开/收起详情 +- [x] 主 Feed 严格“最新在上” +- [x] `npm run build` 通过 +- [x] `tsc --noEmit` 零错误 + +--- + +## 10. 自检命令(前端) + +```bash +cd frontend +npm run build +npx tsc --noEmit +``` + +技术词裸露扫描(渲染主行): + +```bash +rg -n "typed actions|read_only|registry#|chat_id" frontend/src/pages frontend/src/components/inbox +``` + +> 期望:仅允许出现在“展开详情区”相关代码路径,不允许出现在默认主行渲染文本。 + +--- + +## 11. 交付物 + +- [x] 变更文件清单 +- [x] 首屏可见条数描述(含测试分辨率) +- [x] 排序规则验证说明(最新在上) +- [x] 构建与类型检查结果 diff --git a/scripts/docker_stack.sh b/scripts/docker_stack.sh index 97e918c7..9ec85c05 100755 --- a/scripts/docker_stack.sh +++ b/scripts/docker_stack.sh @@ -10,7 +10,7 @@ COMPOSE_FILE="${COMPOSE_FILE_PATH:-docker-compose.yml}" require_cmd() { command -v "$1" >/dev/null 2>&1 || { - echo "[ERROR] missing command: $1" + echo "[错误] 缺少命令: $1" exit 1 } } @@ -19,19 +19,31 @@ require_cmd docker require_cmd curl if ! docker info >/dev/null 2>&1; then - echo "[ERROR] Docker daemon is not running." + echo "[错误] Docker 服务未启动。" exit 1 fi if [[ ! -f "$ENV_FILE" && -f "${PROJECT_DIR}/.env.example" ]]; then cp "${PROJECT_DIR}/.env.example" "$ENV_FILE" - echo "[INFO] .env created from .env.example" + echo "[信息] 已根据 .env.example 生成 .env" fi compose() { docker compose -f "$COMPOSE_FILE" "$@" } +compose_quiet() { + local tmp_log + tmp_log="$(mktemp)" + if compose "$@" >"$tmp_log" 2>&1; then + rm -f "$tmp_log" + return 0 + fi + cat "$tmp_log" + rm -f "$tmp_log" + return 1 +} + backend_health() { curl -fsS "http://127.0.0.1:${BACKEND_PORT:-8000}/healthz" >/dev/null } @@ -51,71 +63,71 @@ db_health() { case "$ACTION" in start) - echo "[INFO] compose file: ${COMPOSE_FILE}" - compose up -d - echo "[OK] stack started" + echo "[信息] 编排文件: ${COMPOSE_FILE}" + compose_quiet up -d + echo "[完成] 服务栈已启动" ;; restart) - echo "[INFO] compose file: ${COMPOSE_FILE}" - compose down - compose up -d - echo "[OK] stack restarted" + echo "[信息] 编排文件: ${COMPOSE_FILE}" + compose_quiet down + compose_quiet up -d + echo "[完成] 服务栈已重启" ;; stop) - echo "[INFO] compose file: ${COMPOSE_FILE}" - compose down - echo "[OK] stack stopped" + echo "[信息] 编排文件: ${COMPOSE_FILE}" + compose_quiet down + echo "[完成] 服务栈已停止" ;; status|ps) - echo "[INFO] compose file: ${COMPOSE_FILE}" + echo "[信息] 编排文件: ${COMPOSE_FILE}" compose ps ;; logs) - echo "[INFO] compose file: ${COMPOSE_FILE}" + echo "[信息] 编排文件: ${COMPOSE_FILE}" compose logs --tail=200 "${2:-}" ;; health) - backend_health && echo "[OK] backend healthz" - db_health && echo "[OK] db pg_isready" - redis_health && echo "[OK] redis ping" + backend_health && echo "[完成] 后端健康检查通过(/healthz)" + db_health && echo "[完成] 数据库健康检查通过(pg_isready)" + redis_health && echo "[完成] Redis 健康检查通过(PING)" ;; dev-start) COMPOSE_FILE="docker-compose.dev.yml" - echo "[INFO] compose file: ${COMPOSE_FILE}" - compose up -d - echo "[OK] dev stack started" + echo "[信息] 编排文件: ${COMPOSE_FILE}" + compose_quiet up -d + echo "[完成] 开发环境服务栈已启动" ;; dev-restart) COMPOSE_FILE="docker-compose.dev.yml" - echo "[INFO] compose file: ${COMPOSE_FILE}" - compose down - compose up -d - echo "[OK] dev stack restarted" + echo "[信息] 编排文件: ${COMPOSE_FILE}" + compose_quiet down + compose_quiet up -d + echo "[完成] 开发环境服务栈已重启" ;; dev-stop) COMPOSE_FILE="docker-compose.dev.yml" - echo "[INFO] compose file: ${COMPOSE_FILE}" - compose down - echo "[OK] dev stack stopped" + echo "[信息] 编排文件: ${COMPOSE_FILE}" + compose_quiet down + echo "[完成] 开发环境服务栈已停止" ;; dev-status|dev-ps) COMPOSE_FILE="docker-compose.dev.yml" - echo "[INFO] compose file: ${COMPOSE_FILE}" + echo "[信息] 编排文件: ${COMPOSE_FILE}" compose ps ;; dev-logs) COMPOSE_FILE="docker-compose.dev.yml" - echo "[INFO] compose file: ${COMPOSE_FILE}" + echo "[信息] 编排文件: ${COMPOSE_FILE}" compose logs --tail=200 "${2:-}" ;; dev-health) COMPOSE_FILE="docker-compose.dev.yml" - backend_health && echo "[OK] backend healthz" - db_health && echo "[OK] db pg_isready" - redis_health && echo "[OK] redis ping" + backend_health && echo "[完成] 后端健康检查通过(/healthz)" + db_health && echo "[完成] 数据库健康检查通过(pg_isready)" + redis_health && echo "[完成] Redis 健康检查通过(PING)" ;; *) - echo "Usage: $0 {start|restart|stop|status|ps|logs [service]|health|dev-start|dev-restart|dev-stop|dev-status|dev-ps|dev-logs [service]|dev-health}" + echo "用法: $0 {start|restart|stop|status|ps|logs [service]|health|dev-start|dev-restart|dev-stop|dev-status|dev-ps|dev-logs [service]|dev-health}" exit 1 ;; esac diff --git a/scripts/model_probe.py b/scripts/model_probe.py new file mode 100644 index 00000000..4293330d --- /dev/null +++ b/scripts/model_probe.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""独立模型连通性检测脚本(中文日志输出)。""" + +from __future__ import annotations + +import argparse +import json +import os +import ssl +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime +from pathlib import Path + + +def load_env_file(env_path: Path) -> None: + if not env_path.exists(): + return + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if len(value) >= 2 and ((value[0] == value[-1] == '"') or (value[0] == value[-1] == "'")): + value = value[1:-1] + os.environ.setdefault(key, value) + + +def provider_runtime(provider: str) -> dict[str, str]: + p = provider.strip().lower() + if p in {"glm", "zhipu"}: + return { + "provider": p, + "base_url": str(os.getenv("GLM_BASE_URL", "")).strip(), + "api_key": str(os.getenv("GLM_API_KEY", "")).strip(), + } + if p == "openai": + return { + "provider": p, + "base_url": str(os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")).strip(), + "api_key": str(os.getenv("OPENAI_API_KEY", "")).strip(), + } + if p == "deepseek": + return { + "provider": p, + "base_url": str(os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")).strip(), + "api_key": str(os.getenv("DEEPSEEK_API_KEY", "")).strip(), + } + if p == "ollama": + return { + "provider": p, + "base_url": str(os.getenv("OLLAMA_BASE_URL", os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434"))).strip(), + "api_key": "", + } + return {"provider": p, "base_url": "", "api_key": ""} + + +def extract_json_detail(text: str) -> dict[str, str]: + detail = { + "content": "", + "reasoning_content": "", + "error_message": "", + "body_preview": text[:180].replace("\n", " "), + } + try: + data = json.loads(text) + except Exception: + return detail + + if isinstance(data, dict): + choices = data.get("choices") or [] + if isinstance(choices, list) and choices: + first = choices[0] if isinstance(choices[0], dict) else {} + msg = first.get("message") if isinstance(first, dict) else {} + if isinstance(msg, dict): + detail["content"] = str(msg.get("content") or "").strip() + detail["reasoning_content"] = str(msg.get("reasoning_content") or "").strip() + + if not detail["error_message"]: + err = data.get("error") + if isinstance(err, dict): + detail["error_message"] = str(err.get("message") or "").strip() + elif err: + detail["error_message"] = str(err).strip() + + if not detail["error_message"]: + if data.get("message"): + detail["error_message"] = str(data.get("message")).strip() + elif data.get("reason"): + detail["error_message"] = str(data.get("reason")).strip() + return detail + + +def probe_model( + level: str, + provider: str, + model: str, + timeout: float, + insecure: bool, +) -> dict[str, object]: + runtime = provider_runtime(provider) + base_url = runtime["base_url"].rstrip("/") + api_key = runtime["api_key"] + started = time.perf_counter() + + if not base_url: + elapsed = time.perf_counter() - started + return { + "level": level, + "provider": provider, + "model": model, + "ok": False, + "elapsed": elapsed, + "reason": f"provider={provider} 缺少 base_url 配置", + "http_status": 0, + } + + url = f"{base_url}/chat/completions" + payload = { + "model": model, + "messages": [ + {"role": "system", "content": "你是连通性检测助手,只回复OK。"}, + {"role": "user", "content": "ping"}, + ], + "temperature": 0, + "max_tokens": 16, + } + req = urllib.request.Request( + url=url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + if api_key: + req.add_header("Authorization", f"Bearer {api_key}") + + status_code = 0 + body = "" + error_text = "" + ssl_context = ssl._create_unverified_context() if insecure else None + try: + with urllib.request.urlopen(req, timeout=timeout, context=ssl_context) as resp: + status_code = int(resp.getcode() or 0) + body = resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + status_code = int(exc.code or 0) + body = exc.read().decode("utf-8", errors="replace") + error_text = str(exc) + except Exception as exc: # pragma: no cover + error_text = str(exc) + + elapsed = time.perf_counter() - started + detail = extract_json_detail(body) + content = detail["content"] + reasoning = detail["reasoning_content"] + has_text = bool(content) + has_reasoning = bool(reasoning) + http_ok = 200 <= status_code < 300 + + if http_ok and (has_text or has_reasoning): + note = "文本返回" if has_text else "仅推理内容" + return { + "level": level, + "provider": provider, + "model": model, + "ok": True, + "elapsed": elapsed, + "reason": note, + "http_status": status_code, + } + + reason_parts: list[str] = [] + if status_code: + reason_parts.append(f"HTTP {status_code}") + if detail["error_message"]: + reason_parts.append(str(detail["error_message"])) + elif error_text: + reason_parts.append(error_text) + elif http_ok: + reason_parts.append("响应为空(content/reasoning_content 均为空)") + else: + reason_parts.append(detail["body_preview"] or "请求失败") + + return { + "level": level, + "provider": provider, + "model": model, + "ok": False, + "elapsed": elapsed, + "reason": " | ".join(reason_parts), + "http_status": status_code, + } + + +def render_result_line(item: dict[str, object]) -> str: + status_text = "✓ 正常" if bool(item["ok"]) else "✗ 失败" + elapsed_text = f"{float(item['elapsed']):.2f}s" + return ( + f"[{item['level']}] {item['model']} ({item['provider']}) " + f"{status_text} ({elapsed_text})" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description="PMFlow 模型连通性独立检测") + parser.add_argument( + "--env-file", + default=str(Path(__file__).resolve().parents[1] / ".env"), + help="环境变量文件路径(默认根目录 .env)", + ) + parser.add_argument( + "--timeout", + type=float, + default=float(os.getenv("PMFLOW_MODEL_PROBE_TIMEOUT", "20")), + help="单次请求超时时间(秒)", + ) + parser.add_argument( + "--insecure", + action="store_true", + default=str(os.getenv("PMFLOW_MODEL_PROBE_INSECURE", "1")).strip().lower() in {"1", "true", "yes"}, + help="关闭 TLS 证书校验(默认开启,可用 PMFLOW_MODEL_PROBE_INSECURE=0 关闭)", + ) + parser.add_argument( + "--strict-tls", + action="store_true", + default=False, + help="强制开启 TLS 证书校验(优先级高于 --insecure)", + ) + args = parser.parse_args() + + load_env_file(Path(args.env_file)) + + insecure = bool(args.insecure and not args.strict_tls) + + low_provider = str(os.getenv("LLM_ROUTING_LOW_PROVIDER", os.getenv("LLM_PROVIDER", "glm")) or "").strip() or "glm" + low_model = str(os.getenv("LLM_ROUTING_LOW_MODEL", os.getenv("GLM_MODEL", "GLM-4.7")) or "").strip() or "GLM-4.7" + high_provider = str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", os.getenv("LLM_PROVIDER", "openai")) or "").strip() or "openai" + high_model = str(os.getenv("LLM_ROUTING_HIGH_MODEL", os.getenv("OPENAI_MODEL", "GLM-5")) or "").strip() or "GLM-5" + + targets: list[tuple[str, str, str]] = [("LOW", low_provider, low_model), ("HIGH", high_provider, high_model)] + deduped: list[tuple[str, str, str]] = [] + seen: set[tuple[str, str]] = set() + for level, provider, model in targets: + key = (provider.lower(), model) + if key in seen: + continue + seen.add(key) + deduped.append((level, provider, model)) + + started_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print("┌────────────────────────────────────────┐") + print("│ PMFlow 模型连通性独立检测 │") + print(f"│ 启动时间: {started_at} │") + print(f"│ TLS校验: {'关闭' if insecure else '开启'} │") + print("├────────────────────────────────────────┤") + + results: list[dict[str, object]] = [] + for level, provider, model in deduped: + result = probe_model( + level=level, + provider=provider, + model=model, + timeout=args.timeout, + insecure=insecure, + ) + results.append(result) + print(f"│ {render_result_line(result)}") + print(f"│ 说明: {result['reason']}") + + print("└────────────────────────────────────────┘") + + failed = [item for item in results if not bool(item["ok"])] + if failed: + print(f"[结果] 检测完成:{len(failed)} 个模型异常。") + return 1 + print("[结果] 检测完成:全部模型连通正常。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/start_legacy_local.sh b/scripts/start_legacy_local.sh deleted file mode 100755 index b69e157e..00000000 --- a/scripts/start_legacy_local.sh +++ /dev/null @@ -1,163 +0,0 @@ -#!/bin/bash -# PMFlow 启动器:后端 + 前端 + Cloudflare Quick Tunnel(自动输出临时公网地址) - -set -euo pipefail - -PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$PROJECT_DIR" - -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' - -die() { - echo -e "${RED}❌ $1${NC}" - exit 1 -} - -kill_port() { - local port="$1" - lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true -} - -wait_http_ok() { - local url="$1" - local max_retry="$2" - local retry=1 - while [ "$retry" -le "$max_retry" ]; do - if curl -fsS "$url" >/dev/null 2>&1; then - return 0 - fi - sleep 1 - retry=$((retry + 1)) - done - return 1 -} - -wait_remote_ok() { - local url="$1" - local max_retry="$2" - local retry=1 - while [ "$retry" -le "$max_retry" ]; do - if curl -kfsS --max-time 8 "$url" >/dev/null 2>&1; then - return 0 - fi - sleep 2 - retry=$((retry + 1)) - done - return 1 -} - -echo "🚀 PMFlow 启动器" -echo "====================" - -[ -d "venv" ] || die "虚拟环境不存在,请先创建: python -m venv venv" -[ -d "backend" ] || die "缺少 backend 目录" -[ -d "frontend" ] || die "缺少 frontend 目录" -command -v cloudflared >/dev/null 2>&1 || die "未安装 cloudflared" -command -v npm >/dev/null 2>&1 || die "未安装 npm" - -mkdir -p logs -rm -f logs/backend.pid logs/frontend.pid logs/tunnel.pid logs/tunnel-frontend.pid logs/tunnel-backend.pid logs/public_url.txt -rm -f logs/tunnel.log logs/tunnel-backend.log logs/tunnel-frontend.log - -echo "🧹 清理旧进程与端口..." -pkill -f "cloudflared tunnel --url http://127.0.0.1:5174" >/dev/null 2>&1 || true -pkill -f "cloudflared tunnel --url http://localhost:5174" >/dev/null 2>&1 || true -pkill -f "cloudflared tunnel --url http://127.0.0.1:8000" >/dev/null 2>&1 || true -pkill -f "cloudflared tunnel --url http://localhost:8000" >/dev/null 2>&1 || true -kill_port 8000 -kill_port 5173 -kill_port 5174 -sleep 1 - -echo -e "${GREEN}▶️ 启动后端 (8000)...${NC}" -( - cd backend - nohup "$PROJECT_DIR/venv/bin/uvicorn" app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir app > "$PROJECT_DIR/logs/backend.log" 2>&1 & - echo $! > "$PROJECT_DIR/logs/backend.pid" -) -BACKEND_PID="$(cat logs/backend.pid)" -wait_http_ok "http://127.0.0.1:8000/healthz" 15 || die "后端启动失败,查看 logs/backend.log" -echo -e "${GREEN}✅ 后端已启动 (PID: ${BACKEND_PID})${NC}" - -echo -e "${GREEN}▶️ 启动前端 (5174)...${NC}" -( - cd frontend - nohup npm run dev -- --host 0.0.0.0 --port 5174 > "$PROJECT_DIR/logs/frontend.log" 2>&1 & - echo $! > "$PROJECT_DIR/logs/frontend.pid" -) -FRONTEND_PID="$(cat logs/frontend.pid)" -wait_http_ok "http://127.0.0.1:5174/login" 20 || wait_http_ok "http://127.0.0.1:5174" 10 || die "前端启动失败,查看 logs/frontend.log" -echo -e "${GREEN}✅ 前端已启动 (PID: ${FRONTEND_PID})${NC}" - -echo -e "${GREEN}▶️ 启动 Cloudflare Quick Tunnels...${NC}" -# Start frontend tunnel -nohup cloudflared tunnel --url http://127.0.0.1:5174 > logs/tunnel-frontend.log 2>&1 & -TUNNEL_FRONT_PID=$! -echo "$TUNNEL_FRONT_PID" > logs/tunnel-frontend.pid - -# Start backend tunnel -nohup cloudflared tunnel --url http://127.0.0.1:8000 > logs/tunnel-backend.log 2>&1 & -TUNNEL_BACK_PID=$! -echo "$TUNNEL_BACK_PID" > logs/tunnel-backend.pid - -TEMP_URL_FRONT="" -TEMP_URL_BACK="" - -for _ in {1..25}; do - if [ -z "$TEMP_URL_FRONT" ]; then - TEMP_URL_FRONT="$(grep -aEo 'https://[a-z0-9-]+\.trycloudflare\.com' logs/tunnel-frontend.log | head -n 1 || true)" - fi - if [ -z "$TEMP_URL_BACK" ]; then - TEMP_URL_BACK="$(grep -aEo 'https://[a-z0-9-]+\.trycloudflare\.com' logs/tunnel-backend.log | head -n 1 || true)" - fi - - if [ -n "$TEMP_URL_FRONT" ] && [ -n "$TEMP_URL_BACK" ]; then - break - fi - sleep 1 -done - -if [ -n "$TEMP_URL_FRONT" ] && [ -n "$TEMP_URL_BACK" ]; then - FRONT_OK=false - BACK_OK=false - if wait_remote_ok "${TEMP_URL_FRONT}/login" 25 || wait_remote_ok "${TEMP_URL_FRONT}" 25; then - FRONT_OK=true - fi - if wait_remote_ok "${TEMP_URL_BACK}/healthz" 25; then - BACK_OK=true - fi - - echo "$TEMP_URL_FRONT" > logs/public_url.txt - echo "$TEMP_URL_BACK" >> logs/public_url.txt - - if [ "$FRONT_OK" = true ] && [ "$BACK_OK" = true ]; then - echo -e "${GREEN}✅ Tunnels 已启动且公网可访问${NC}" - else - echo -e "${YELLOW}⚠️ Tunnel 已启动,但公网健康检查未全部通过(请稍后再试)${NC}" - fi - echo -e "🌐 前端公网地址(临时): ${YELLOW}${TEMP_URL_FRONT}${NC}" - echo -e "🌐 后端公网地址(临时): ${YELLOW}${TEMP_URL_BACK}${NC}" - echo -e "${YELLOW}ℹ️ 前端公网默认通过 /api 代理访问本地后端,无需手动修改 VITE_API_BASE_URL。${NC}" -else - echo -e "${YELLOW}⚠️ Tunnel 已启动但部分地址未解析,请查看 logs/tunnel-*.log${NC}" -fi - -LAN_IP="$(ipconfig getifaddr en0 2>/dev/null || echo '本机IP')" -echo -echo -e "${GREEN}🎉 启动完成${NC}" -echo "====================" -echo "本地前端: http://127.0.0.1:5174" -echo "本地后端: http://127.0.0.1:8000" -echo "局域网前端: http://${LAN_IP}:5174" -echo "API 文档: http://127.0.0.1:8000/docs" -if [ -n "$TEMP_URL_FRONT" ]; then - echo "前端公网: ${TEMP_URL_FRONT}" -fi -if [ -n "$TEMP_URL_BACK" ]; then - echo "后端公网: ${TEMP_URL_BACK}" -fi -echo "日志: logs/backend.log | logs/frontend.log | logs/tunnel-*.log" -echo "停止: ./stop.sh" diff --git a/scripts/start_model_probe.sh b/scripts/start_model_probe.sh new file mode 100755 index 00000000..d04b6816 --- /dev/null +++ b/scripts/start_model_probe.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOG_DIR="${ROOT_DIR}/logs" +mkdir -p "$LOG_DIR" + +TIMESTAMP="$(date '+%Y%m%d_%H%M%S')" +LOG_FILE="${LOG_DIR}/model_probe_${TIMESTAMP}.log" +ENV_FILE="${PMFLOW_ENV_FILE:-${ROOT_DIR}/.env}" + +echo "[模型检测] 开始执行独立连通性检测" +echo "[模型检测] 环境文件: ${ENV_FILE}" +echo "[模型检测] 日志文件: ${LOG_FILE}" + +python3 "${ROOT_DIR}/scripts/model_probe.py" --env-file "${ENV_FILE}" "$@" 2>&1 | tee "${LOG_FILE}" +STATUS=${PIPESTATUS[0]} + +if [[ "${STATUS}" -eq 0 ]]; then + echo "[模型检测] 完成:全部模型连通正常" +else + echo "[模型检测] 完成:存在连通性异常,请查看 ${LOG_FILE}" +fi + +exit "${STATUS}" diff --git a/scripts/stop_legacy_local.sh b/scripts/stop_legacy_local.sh deleted file mode 100755 index 6f3ace82..00000000 --- a/scripts/stop_legacy_local.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/bin/bash -# Neural Sync 停止器 - 停止所有服务 - -PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$PROJECT_DIR" - -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -echo "🛑 停止 Neural Sync 服务" -echo "====================" - -# 从 PID 文件读取并停止 -if [ -f "logs/backend.pid" ]; then - BACKEND_PID=$(cat logs/backend.pid) - if ps -p $BACKEND_PID > /dev/null 2>&1; then - kill $BACKEND_PID 2>/dev/null - echo -e "${GREEN}✅ 后端已停止 (PID: $BACKEND_PID)${NC}" - fi - rm logs/backend.pid -fi - -if [ -f "logs/frontend.pid" ]; then - FRONTEND_PID=$(cat logs/frontend.pid) - if ps -p $FRONTEND_PID > /dev/null 2>&1; then - kill $FRONTEND_PID 2>/dev/null - echo -e "${GREEN}✅ 前端已停止 (PID: $FRONTEND_PID)${NC}" - fi - rm logs/frontend.pid -fi - -if [ -f "logs/tunnel.pid" ]; then - TUNNEL_PID=$(cat logs/tunnel.pid) - if ps -p $TUNNEL_PID > /dev/null 2>&1; then - kill $TUNNEL_PID 2>/dev/null - echo -e "${GREEN}✅ Cloudflare Tunnel 已停止 (PID: $TUNNEL_PID)${NC}" - fi - rm logs/tunnel.pid -fi - -if [ -f "logs/tunnel-frontend.pid" ]; then - TUNNEL_FRONT_PID=$(cat logs/tunnel-frontend.pid) - if ps -p $TUNNEL_FRONT_PID > /dev/null 2>&1; then - kill $TUNNEL_FRONT_PID 2>/dev/null - echo -e "${GREEN}✅ 前端 Tunnel 已停止 (PID: $TUNNEL_FRONT_PID)${NC}" - fi - rm logs/tunnel-frontend.pid -fi - -if [ -f "logs/tunnel-backend.pid" ]; then - TUNNEL_BACK_PID=$(cat logs/tunnel-backend.pid) - if ps -p $TUNNEL_BACK_PID > /dev/null 2>&1; then - kill $TUNNEL_BACK_PID 2>/dev/null - echo -e "${GREEN}✅ 后端 Tunnel 已停止 (PID: $TUNNEL_BACK_PID)${NC}" - fi - rm logs/tunnel-backend.pid -fi - -# Fallback: kill by name just in case -pkill -f "cloudflared tunnel --url http://127.0.0.1:5174" >/dev/null 2>&1 || true -pkill -f "cloudflared tunnel --url http://localhost:5174" >/dev/null 2>&1 || true -pkill -f "cloudflared tunnel --url http://127.0.0.1:8000" >/dev/null 2>&1 || true -pkill -f "cloudflared tunnel --url http://localhost:8000" >/dev/null 2>&1 || true -pkill -f "cloudflared tunnel run" >/dev/null 2>&1 || true - -# 清理端口 (8000 后端, 5173/5174 前端) -lsof -ti:8000 2>/dev/null | xargs kill -9 2>/dev/null || true -lsof -ti:5173 2>/dev/null | xargs kill -9 2>/dev/null || true -lsof -ti:5174 2>/dev/null | xargs kill -9 2>/dev/null || true - -# 停止 Ollama(可选) -read -p "是否同时停止 Ollama? (y/N) " -n 1 -r -echo -if [[ $REPLY =~ ^[Yy]$ ]]; then - pkill -9 -f "ollama serve" 2>/dev/null && echo -e "${GREEN}✅ Ollama 已停止${NC}" || true -fi - -echo -e "${GREEN}🎉 所有服务已停止${NC}" diff --git a/start.sh b/start.sh index cc577062..ad00e959 100755 --- a/start.sh +++ b/start.sh @@ -9,7 +9,7 @@ MODE="${1:-dev}" stop_legacy_conflicts() { local pid cmd if [[ "${PMFLOW_KEEP_LEGACY:-0}" == "1" ]]; then - echo "[PMFlow] skip legacy conflict cleanup (PMFLOW_KEEP_LEGACY=1)" + echo "[PMFlow] 跳过旧链路冲突清理(PMFLOW_KEEP_LEGACY=1)" return fi @@ -18,7 +18,7 @@ stop_legacy_conflicts() { cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)" if [[ "$cmd" == *"uvicorn app.main:app"* ]]; then kill "$pid" 2>/dev/null || true - echo "[PMFlow] stopped legacy backend pid=$pid" + echo "[PMFlow] 已停止旧链路后端 pid=$pid" fi done < <(lsof -tiTCP:8000 -sTCP:LISTEN 2>/dev/null || true) @@ -27,7 +27,7 @@ stop_legacy_conflicts() { cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)" if [[ "$cmd" == *"npm run dev -- --host 0.0.0.0 --port 5174"* ]] || [[ "$cmd" == *"vite --host 0.0.0.0 --port 5174"* ]]; then kill "$pid" 2>/dev/null || true - echo "[PMFlow] stopped legacy frontend pid=$pid" + echo "[PMFlow] 已停止旧链路前端 pid=$pid" fi done < <(lsof -tiTCP:5174 -sTCP:LISTEN 2>/dev/null || true) } @@ -160,11 +160,11 @@ start_or_reuse_tunnel() { print_temp_public_urls() { if [[ "${PMFLOW_NO_TUNNEL:-0}" == "1" ]]; then - echo "[PMFlow] skip temporary tunnel (PMFLOW_NO_TUNNEL=1)" + echo "[PMFlow] 跳过临时公网隧道(PMFLOW_NO_TUNNEL=1)" return fi if ! command -v cloudflared >/dev/null 2>&1; then - echo "[PMFlow] cloudflared not found, skip temporary public urls" + echo "[PMFlow] 未检测到 cloudflared,跳过临时公网地址生成" return fi @@ -173,9 +173,9 @@ print_temp_public_urls() { local expose_backend="${PMFLOW_EXPOSE_BACKEND_TUNNEL:-0}" local protocol="${PMFLOW_TUNNEL_PROTOCOL:-http2}" local frontend_watch="${PMFLOW_FRONTEND_TUNNEL_WATCH:-1}" - echo "[PMFlow] tunnel protocol: ${protocol}" + echo "[PMFlow] 隧道协议: ${protocol}" frontend_port="$(resolve_frontend_tunnel_port)" - echo "[PMFlow] tunnel frontend origin: http://127.0.0.1:${frontend_port}" + echo "[PMFlow] 隧道前端源站: http://127.0.0.1:${frontend_port}" front_url="" if [[ "$frontend_watch" == "1" ]]; then bash scripts/tunnel_frontend_watch.sh start >/dev/null 2>&1 || true @@ -200,44 +200,40 @@ print_temp_public_urls() { : > logs/public_url.txt if [[ -n "$front_url" ]]; then echo "frontend=$front_url" >> logs/public_url.txt - echo "[PMFlow] temp frontend: $front_url" + echo "[PMFlow] 临时前端地址: $front_url" else - echo "[PMFlow] temp frontend: (pending, check logs/tunnel-frontend.log)" + echo "[PMFlow] 临时前端地址: (等待中,请查看 logs/tunnel-frontend.log)" fi if [[ "$expose_backend" != "1" ]]; then - echo "[PMFlow] temp backend: (skip, PMFLOW_EXPOSE_BACKEND_TUNNEL=0)" + echo "[PMFlow] 临时后端地址: (已跳过,PMFLOW_EXPOSE_BACKEND_TUNNEL=0)" else if [[ -n "$back_url" ]]; then echo "backend=$back_url" >> logs/public_url.txt - echo "[PMFlow] temp backend: $back_url" + echo "[PMFlow] 临时后端地址: $back_url" else - echo "[PMFlow] temp backend: (pending, check logs/tunnel-backend.log)" + echo "[PMFlow] 临时后端地址: (等待中,请查看 logs/tunnel-backend.log)" fi fi - echo "[PMFlow] public urls saved: logs/public_url.txt" + echo "[PMFlow] 公网地址已写入: logs/public_url.txt" } case "$MODE" in dev|"") - echo "[PMFlow] start mode: docker-dev hot reload" + echo "[PMFlow] 启动模式: 开发环境(Docker 热重载)" stop_legacy_conflicts bash scripts/docker_stack.sh dev-start - echo "[PMFlow] frontend: http://127.0.0.1:5174" - echo "[PMFlow] backend: http://127.0.0.1:8000" + echo "[PMFlow] 前端地址: http://127.0.0.1:5174" + echo "[PMFlow] 后端地址: http://127.0.0.1:8000" print_temp_public_urls ;; prod) - echo "[PMFlow] start mode: docker prod compose" + echo "[PMFlow] 启动模式: 生产环境(Docker Compose)" stop_legacy_conflicts bash scripts/docker_stack.sh start - echo "[PMFlow] frontend: http://127.0.0.1:5174" - echo "[PMFlow] backend: http://127.0.0.1:8000" + echo "[PMFlow] 前端地址: http://127.0.0.1:5174" + echo "[PMFlow] 后端地址: http://127.0.0.1:8000" print_temp_public_urls ;; - legacy) - echo "[PMFlow] start mode: legacy local (venv + npm + tunnel)" - bash scripts/start_legacy_local.sh - ;; urls) if [[ -s logs/public_url.txt ]]; then local_front_url="$(rg '^frontend=' logs/public_url.txt | tail -n 1 | sed 's/^frontend=//' || true)" @@ -252,12 +248,12 @@ case "$MODE" in echo "前端: ${local_front_url:-未就绪}" echo "后端: ${local_back_url:-未暴露}" else - echo "[PMFlow] no public url record found (logs/public_url.txt)" + echo "[PMFlow] 未找到公网地址记录(logs/public_url.txt)" exit 1 fi ;; *) - echo "Usage: $0 [dev|prod|legacy|urls]" + echo "用法: $0 [dev|prod|urls]" exit 1 ;; esac diff --git a/stop.sh b/stop.sh index 8b5878a6..dc8290cb 100755 --- a/stop.sh +++ b/stop.sh @@ -15,12 +15,8 @@ case "$MODE" in echo "[PMFlow] stop mode: docker prod compose" bash scripts/docker_stack.sh stop ;; - legacy) - echo "[PMFlow] stop mode: legacy local (venv + npm + tunnel)" - bash scripts/stop_legacy_local.sh - ;; *) - echo "Usage: $0 [dev|prod|legacy]" + echo "Usage: $0 [dev|prod]" exit 1 ;; esac