From fea6328ab2a83b47fe05f3bc060680e7e870628f Mon Sep 17 00:00:00 2001
From: Yuuuu <1569752928@qq.com>
Date: Thu, 12 Mar 2026 11:23:22 +0800
Subject: [PATCH] =?UTF-8?q?Add=20=E6=A8=A1=E5=9E=8B=E8=BF=9E=E9=80=9A?=
=?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=90=AF=E5=8A=A8=E5=99=A8}0987654321?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 6 +-
CLAUDE.md | 15 +
backend/app/main.py | 98 ++
devops/tunnel/start-quick.sh | 67 --
devops/tunnel/start.sh | 167 +++-
docker-compose.dev.yml | 19 +-
.../components/ai-steward/FloatingQAChat.tsx | 6 +-
.../src/components/inbox/InboxListItem.tsx | 220 +++++
frontend/src/components/layout/Layout.tsx | 2 +
frontend/src/components/layout/TopBar.tsx | 22 +
.../src/components/sstc/SSTCGlobalHeader.tsx | 8 +-
.../components/sstc/SSTCRiskFloatPanel.tsx | 76 +-
frontend/src/pages/InboxPage.tsx | 908 ++++--------------
frontend/src/pages/ProjectBoardPage.tsx | 113 ++-
frontend/tsconfig.tsbuildinfo | 2 +-
frontend/vite.config.ts | 5 +
mdproject/inbox-feed-list-refactor-task-zh.md | 193 ++++
scripts/docker_stack.sh | 80 +-
scripts/model_probe.py | 285 ++++++
scripts/start_legacy_local.sh | 163 ----
scripts/start_model_probe.sh | 25 +
scripts/stop_legacy_local.sh | 79 --
start.sh | 46 +-
stop.sh | 6 +-
24 files changed, 1409 insertions(+), 1202 deletions(-)
delete mode 100755 devops/tunnel/start-quick.sh
create mode 100644 frontend/src/components/inbox/InboxListItem.tsx
create mode 100644 mdproject/inbox-feed-list-refactor-task-zh.md
create mode 100644 scripts/model_probe.py
delete mode 100755 scripts/start_legacy_local.sh
create mode 100755 scripts/start_model_probe.sh
delete mode 100755 scripts/stop_legacy_local.sh
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 && }