Add 模型连通检测启动器}0987654321

This commit is contained in:
Yuuuu 2026-03-12 11:23:22 +08:00
parent a3a4ce61ff
commit fea6328ab2
24 changed files with 1409 additions and 1202 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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)."
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 ""
read -p "Do you have a domain and want to set up a named tunnel? (y/N): " SETUP_CHOICE
echo "🔴 tunnel 已关闭"
kill "$front_pid" "$back_pid" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
wait "$front_pid" "$back_pid"
}
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
fi
fi
# Re-check config after potential setup
echo "🚀 启动公网穿透..."
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
echo "✅ 检测到命名隧道配置: ${CONFIG_FILE}"
echo "🌐 使用命名隧道运行(前台)"
exec cloudflared tunnel run --config "$CONFIG_FILE"
fi
echo "⚠️ 未检测到命名隧道配置,自动切换临时隧道。"
echo " 若需固定域名,请先执行: ${SETUP_SCRIPT}"
run_quick_tunnels

View File

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

View File

@ -603,17 +603,17 @@ export function FloatingQAChat() {
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className={`fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-full text-white shadow-xl ring-1 ring-black/10 transition ${
className={`fixed bottom-4 right-4 z-[120] flex h-11 w-11 items-center justify-center rounded-full text-white shadow-xl ring-1 ring-black/10 transition md:bottom-6 md:right-6 ${
open ? "bg-violet-700" : "bg-violet-600 hover:bg-violet-700"
}`}
aria-label="打开 AI 聊天"
>
<Sparkles className="h-5 w-5" />
<Sparkles className="h-4 w-4" />
{unread ? <span className="absolute right-1 top-1 h-2.5 w-2.5 rounded-full bg-red-500" /> : null}
</button>
<div
className={`fixed bottom-20 left-3 right-3 z-50 h-[72vh] rounded-2xl border bg-background/95 shadow-2xl backdrop-blur-sm transition-all duration-300 sm:bottom-24 sm:left-auto sm:right-6 sm:h-[78vh] sm:w-[380px] ${
className={`fixed bottom-20 left-3 right-3 z-[121] h-[72vh] rounded-2xl border bg-background/95 shadow-2xl backdrop-blur-sm transition-all duration-300 sm:bottom-24 sm:left-auto sm:right-6 sm:h-[78vh] sm:w-[380px] ${
open ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
}`}
>

View File

@ -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<InboxDisplayKind, string> = {
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<InboxDisplayKind, string> = {
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<string, unknown>;
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 (
<article className="overflow-hidden rounded-xl border border-slate-200 bg-white">
<div
role="button"
tabIndex={0}
onClick={() => 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]"
)}
>
<div className="flex items-start gap-2">
<span className={cn("mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full", STATUS_ICON_STYLES[item.display_kind])}>
<StatusIcon className={cn("h-3.5 w-3.5", item.display_kind === "auto_running" && "animate-spin")} />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start justify-between gap-2">
<p className="line-clamp-1 text-sm font-semibold text-slate-900">{title}</p>
<div className="ml-2 shrink-0 text-right">
<div className="max-w-[120px] truncate text-[11px] text-slate-500 sm:max-w-[180px]">{projectLabel}</div>
<div className="text-[11px] text-slate-400">{displayTime}</div>
</div>
</div>
<div className="mt-1 flex items-center justify-between gap-2">
<p className="line-clamp-1 min-w-0 text-xs text-slate-500">{subtitle}</p>
<Badge variant="outline" className={cn("shrink-0 text-[11px]", STATUS_STYLES[item.display_kind])}>
{mapDisplayKindLabel(item.display_kind)}
</Badge>
</div>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center justify-end gap-2">
{isActionRow && (
<Button
size="sm"
className="h-8 bg-blue-600 px-3 text-white hover:bg-blue-700"
disabled={pendingActionKey === primaryActionKey}
onClick={(event) => {
event.stopPropagation();
onAction(item, item.primary_action);
}}
>
{pendingActionKey === primaryActionKey ? "处理中…" : item.primary_action.label}
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-8 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
onClick={(event) => {
event.stopPropagation();
onToggleExpand(item.id);
}}
>
详情
{expanded ? <ChevronUp className="ml-1 h-3.5 w-3.5" /> : <ChevronDown className="ml-1 h-3.5 w-3.5" />}
</Button>
</div>
</div>
{expanded && (
<div className="border-t border-slate-200 bg-slate-50 px-3 py-3 md:px-4">
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-lg bg-white px-3 py-2">
<div className="text-[11px] font-semibold text-slate-500">当前阶段</div>
<div className="mt-1 text-sm text-slate-700">{mapStageText(item.stage_text || mapDisplayKindLabel(item.display_kind))}</div>
</div>
<div className="rounded-lg bg-white px-3 py-2">
<div className="text-[11px] font-semibold text-slate-500">下一步</div>
<div className="mt-1 line-clamp-2 text-sm text-slate-700">{sanitizeInboxSubtitle(item.next_step_text || item.ai_plan || "暂无")}</div>
</div>
</div>
<div className="mt-2 rounded-lg border border-dashed border-slate-200 bg-white px-3 py-2">
<div className="text-[11px] font-semibold text-slate-500">技术字段(默认折叠)</div>
<div className="mt-1 grid gap-1 text-xs text-slate-600 sm:grid-cols-2">
<div>intent(可读):{mapIntentLabel(rawIntent)}</div>
<div>intent(原始):{rawIntent || "-"}</div>
<div>typed actions:{formatTechnicalValue(typedActions)}</div>
<div>chat_id:{formatTechnicalValue(chatId)}</div>
<div>重复次数:{item.dedupe_count}</div>
<div>撤销状态:{item.can_undo ? `可撤销(截止 ${item.undo_deadline || "-"})` : "不可撤销"}</div>
<div className="sm:col-span-2">完整时间戳:{fullTime}</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{!isActionRow && item.primary_action.kind !== "none" && (
<Button
size="sm"
className="h-8 bg-blue-600 px-3 text-white hover:bg-blue-700"
disabled={pendingActionKey === primaryActionKey}
onClick={() => onAction(item, item.primary_action)}
>
{pendingActionKey === primaryActionKey ? "处理中…" : item.primary_action.label}
</Button>
)}
{item.secondary_action.kind !== "none" && (
<Button
size="sm"
variant="outline"
className="h-8 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
disabled={pendingActionKey === secondaryActionKey}
onClick={() => onAction(item, item.secondary_action)}
>
{pendingActionKey === secondaryActionKey ? "处理中…" : item.secondary_action.label}
</Button>
)}
{item.weak_action.kind === "none" ? (
<span className="text-xs text-slate-500">{item.weak_action.label}</span>
) : (
<Button
size="sm"
variant="ghost"
className="h-8 text-slate-600 hover:bg-slate-100"
disabled={pendingActionKey === weakActionKey}
onClick={() => onAction(item, item.weak_action)}
>
{pendingActionKey === weakActionKey ? "处理中…" : item.weak_action.label}
</Button>
)}
</div>
</div>
)}
</article>
);
}

View File

@ -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() {
<Outlet />
</main>
</div>
<FloatingQAChat />
</div>
);
}

View File

@ -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<InboxDisplayKind, string> = {
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) {
</div>
</DropdownMenuContent>
</DropdownMenu>
{currentProjectId && <SSTCRiskTopbarEntry risks={currentProjectRisks} />}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">

View File

@ -73,6 +73,7 @@ export function SSTCGlobalHeader({
const progress = overview.task_progress.progress || 0;
const ratio = overview.workload_health.avg_ratio || 0;
const workloadMembers = overview.workload_health.members || [];
const taskCount = Number(overview.project.task_count || 0);
const projectLabel = formatProjectNameWithId({
id: overview.project.id,
title: overview.project.title,
@ -98,7 +99,12 @@ export function SSTCGlobalHeader({
<CardContent className="space-y-1 text-sm text-muted-foreground">
<p>项目描述:{overview.project.description || "暂无"}</p>
<p>创建时间(北京时间):{formatBeijingDateTime(overview.project.created_at)}</p>
<p>任务总数:{overview.project.task_count}</p>
<p>任务总数:{taskCount}</p>
{taskCount === 0 ? (
<div className="mt-2 rounded-md border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
当前暂无任务。建议进入「任务阶段」执行 AI 拆解或手动创建首批任务。
</div>
) : null}
</CardContent>
</Card>

View File

@ -1,53 +1,37 @@
import { useMemo, useState } from "react";
import { AlertTriangle, Brain, Minimize2, Maximize2 } from "lucide-react";
import { useMemo } from "react";
import { AlertTriangle, Brain, Maximize2 } from "lucide-react";
import type { SSTCRisks } from "@/api/sstc";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
interface SSTCRiskFloatPanelProps {
interface SSTCRiskPanelProps {
risks: SSTCRisks;
onJump?: (entityId: string) => void;
}
export function SSTCRiskFloatPanel({ risks, onJump }: SSTCRiskFloatPanelProps) {
const [minimized, setMinimized] = useState(true);
function formatBadgeCount(total: number): string {
return total > 99 ? "99+" : String(total);
}
function computeRiskTotal(risks: SSTCRisks): number {
return (risks.summary.conflict_count || 0) + (risks.summary.risk_count || 0);
}
export function SSTCRiskCenterPanel({ risks, onJump }: SSTCRiskPanelProps) {
const total = useMemo(
() => (risks.summary.conflict_count || 0) + (risks.summary.risk_count || 0),
() => computeRiskTotal(risks),
[risks]
);
if (minimized) {
return (
<div className="fixed bottom-4 right-2 z-40 sm:right-4">
<Button
variant="default"
className="relative w-[min(220px,calc(100vw-1rem))] justify-center"
onClick={() => setMinimized(false)}
title="打开预警中心"
>
<AlertTriangle className="mr-2 h-4 w-4" />
预警中心
{total > 0 && (
<span className="absolute -right-2 -top-2 rounded-full bg-red-600 px-1.5 text-[10px] text-white">
{total}
</span>
)}
</Button>
</div>
);
}
return (
<div className="fixed bottom-4 right-2 z-40 w-[min(320px,calc(100vw-1rem))] rounded-lg border bg-card shadow-xl sm:right-4">
<div className="w-[min(360px,calc(100vw-1rem))] rounded-lg bg-card">
<div className="flex items-center justify-between border-b px-3 py-2">
<p className="text-sm font-semibold">项目预警中心</p>
<div className="flex items-center gap-2">
<span className="rounded-full bg-red-600 px-2 py-0.5 text-[10px] text-white">
{total}
{formatBadgeCount(total)}
</span>
<button onClick={() => setMinimized(true)} title="最小化">
<Minimize2 className="h-4 w-4 text-muted-foreground" />
</button>
</div>
</div>
<div className="p-2">
@ -108,3 +92,31 @@ export function SSTCRiskFloatPanel({ risks, onJump }: SSTCRiskFloatPanelProps) {
</div>
);
}
export function SSTCRiskTopbarEntry({ risks, onJump }: SSTCRiskPanelProps) {
const total = useMemo(() => computeRiskTotal(risks), [risks]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-9 gap-1.5 rounded-full px-3 text-slate-700 hover:bg-slate-100"
title="打开预警中心"
>
<AlertTriangle className="h-4 w-4" />
<span className="text-sm font-medium">预警</span>
{total > 0 && (
<span className="absolute -right-0.5 -top-0.5 min-w-4 rounded-full bg-red-500 px-1 text-center text-[10px] leading-4 text-white">
{formatBadgeCount(total)}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[min(360px,calc(100vw-1rem))] rounded-lg border p-0">
<SSTCRiskCenterPanel risks={risks} onJump={onJump} />
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -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<InboxDisplayKind, string> = {
action_required: "需你决策",
auto_running: "AI 已接手",
auto_done: "AI 执行完成",
info: "仅通知",
type InboxTabKey = "all" | "needs_action" | "in_progress" | "done";
const TAB_LABELS: Record<InboxTabKey, string> = {
all: "全部",
needs_action: "待处理",
in_progress: "进行中",
done: "已完成",
};
const STATUS_STYLES: Record<InboxDisplayKind, string> = {
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<Record<InboxTabKey, InboxDisplayKind>> = {
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 (
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
<CardHeader className="pb-3">
<CardTitle className="text-base text-slate-900">{title}</CardTitle>
<CardDescription className="text-slate-500">{description}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{items.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
当前为空
</div>
) : (
items.map((item) => (
<button
key={item.id}
type="button"
className="w-full rounded-xl border border-slate-100 bg-slate-50 px-3 py-3 text-left transition hover:border-slate-200 hover:bg-white"
onClick={() => onOpen(item)}
>
<div className="flex items-start justify-between gap-3">
<span className="line-clamp-1 text-sm font-semibold text-slate-900">{item.title}</span>
<Badge variant="outline" className={cn("text-[11px]", STATUS_STYLES[item.display_kind])}>
{STATUS_LABELS[item.display_kind]}
</Badge>
</div>
<p className="mt-1 line-clamp-2 text-xs text-slate-500">{item.summary}</p>
</button>
))
)}
</CardContent>
</Card>
);
}
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 (
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)] transition hover:-translate-y-0.5 hover:shadow-[0_14px_30px_rgba(15,23,42,0.12)]">
<CardHeader className="space-y-3 pb-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className={cn("text-[11px]", STATUS_STYLES[item.display_kind])}>
{STATUS_LABELS[item.display_kind]}
</Badge>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
{toZhPriority(item.priority)}
</span>
{item.project_id && (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
{item.project_name || `项目 #${item.project_id}`}
</span>
)}
{item.dedupe_count > 1 && (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
重复 {item.dedupe_count} 次
</span>
)}
</div>
<div className="text-right">
<div className="text-xs text-slate-400">{item.updated_at || item.created_at || "-"}</div>
</div>
</div>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-base text-slate-900">{item.title}</CardTitle>
<CardDescription className="line-clamp-2 text-slate-500">{item.summary}</CardDescription>
</div>
<BellRing className="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
</div>
</CardHeader>
<CardContent className="space-y-4">
{item.ai_plan && (
<div className="rounded-xl bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">AI 已做/将做什么</div>
<p className="mt-2 text-sm leading-6 text-slate-700">{item.ai_plan}</p>
</div>
)}
{item.blocking_reason && (
<div className="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-amber-700">为什么需要你</div>
<p className="mt-2 text-sm leading-6 text-amber-900/80">{item.blocking_reason}</p>
</div>
)}
{(item.stage_text || item.next_step_text || item.can_undo) && (
<div className="grid gap-3 md:grid-cols-3">
{stageCards.map((stage) => (
<div key={stage.label} className="rounded-xl bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">{stage.label}</div>
<p className="mt-2 text-sm text-slate-700">{stage.value}</p>
</div>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button
className="bg-blue-600 text-white hover:bg-blue-700"
disabled={primaryPending}
onClick={() => onAction(item, item.primary_action)}
>
{primaryPending ? "处理中…" : item.primary_action.label}
</Button>
<Button
variant="outline"
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
disabled={secondaryPending}
onClick={() => onAction(item, item.secondary_action)}
>
{secondaryPending ? "加载中…" : item.secondary_action.label}
</Button>
{item.weak_action.kind === "none" ? (
<span className="px-3 py-2 text-sm text-slate-400">{item.weak_action.label}</span>
) : (
<Button
variant="ghost"
className="text-slate-500 hover:bg-slate-50 hover:text-slate-700"
disabled={weakPending}
onClick={() => onAction(item, item.weak_action)}
>
{weakPending ? "处理中…" : item.weak_action.label}
</Button>
)}
</div>
</CardContent>
</Card>
);
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<InboxItemViewModel | null>(null);
const [workItemDetailItem, setWorkItemDetailItem] = useState<InboxItemViewModel | null>(null);
const [pendingActionKey, setPendingActionKey] = useState<string | null>(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<string | null>(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<InboxTabKey, number>;
}, [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,82 +256,28 @@ export default function InboxPage() {
const isLoading = notificationsQuery.isLoading || workItemsQuery.isLoading;
return (
<>
<div className="space-y-6 pb-16">
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-4 pb-12">
<section className="rounded-2xl bg-[#F8F9FA] p-3 md:p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">消息台</h1>
<p className="mt-1 text-slate-500">AI 决策收件箱 · 先看摘要,再处理真正需要你拍板的事项</p>
<p className="mt-1 text-xs text-slate-500">
当前结果:{filteredItems.length} 条
{projectFilter !== "all" ? `(已筛选项目 #${projectFilter})` : "(全租户汇总)"}
</p>
<h1 className="text-xl font-bold tracking-tight text-slate-900 md:text-2xl">消息台</h1>
<p className="mt-1 text-xs text-slate-500 md:text-sm">列表 Feed · 最新在上 · 支持移动端</p>
<p className="mt-1 text-xs text-slate-500">当前结果:{feedItems.length} 条</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
className="border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
className="h-9 border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
onClick={markAllRead}
disabled={notificationsQuery.data?.unread_count === 0}
>
全部已读
</Button>
<Button className="bg-blue-600 text-white hover:bg-blue-700" onClick={() => setSection("action_required")}>
<SlidersHorizontal className="mr-2 h-4 w-4" />
先看待决策
</Button>
</div>
</div>
<div className="mt-4 rounded-2xl bg-white p-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
AI 决策收件箱
</span>
<p className="text-sm font-medium text-slate-700">铃铛只看摘要,完整工作流统一沉到 Inbox</p>
</div>
<CardDescription className="mt-2 text-slate-500">
只保留真正可执行的事项;查询、闲聊、健康提示默认不进入消息台。
</CardDescription>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{[
{ 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) => (
<div key={label} className="rounded-xl bg-slate-50 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-slate-500">{label}</div>
<div className="mt-2 text-2xl font-bold text-slate-900">{value}</div>
</div>
<span className={cn("inline-flex h-9 w-9 items-center justify-center rounded-full", METRIC_CARD_STYLES[index])}>
<Icon className="h-4 w-4" />
</span>
</div>
</div>
))}
</div>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
{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)
)}
</div>
</section>
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
<div className="rounded-2xl bg-white p-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
<div className="flex flex-wrap items-center gap-3">
<div className="w-full min-w-0 sm:min-w-[220px] sm:flex-1">
<div className="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_180px]">
<Input
placeholder="搜索标题、摘要、项目"
placeholder="搜索标题、副标题、项目"
defaultValue={searchValue}
className="border-slate-200 bg-white text-slate-700 placeholder:text-slate-400"
onBlur={(event) => setSearch(event.target.value)}
@ -594,11 +285,11 @@ export default function InboxPage() {
if (event.key === "Enter") setSearch((event.target as HTMLInputElement).value);
}}
/>
</div>
<select
value={projectFilter}
onChange={(event) => setProject(event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700 sm:w-auto"
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
>
<option value="all">全部项目</option>
{projectOptions.map((option) => (
@ -607,307 +298,60 @@ export default function InboxPage() {
</option>
))}
</select>
<select
value={priorityFilter}
onChange={(event) => setPriorityFilter(event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700 sm:w-auto"
>
{PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<label className="inline-flex items-center gap-2 rounded-full bg-slate-50 px-3 py-2 text-sm text-slate-600">
<Switch checked={onlyDecision} onCheckedChange={setOnlyDecision} />
仅需决策
</label>
<label className="inline-flex items-center gap-2 rounded-full bg-slate-50 px-3 py-2 text-sm text-slate-600">
<Switch checked={onlyUnread} onCheckedChange={setOnlyUnread} />
仅未读
</label>
</div>
<Tabs value={activeSection} onValueChange={setSection}>
<TabsList className="mt-4 grid h-auto w-full grid-cols-2 gap-2 rounded-2xl bg-slate-50 p-2 md:grid-cols-5">
{scopedSections.map((section) => (
<TabsTrigger
key={section.key}
value={section.key}
className="rounded-xl border border-transparent text-slate-600 data-[state=active]:border-slate-200 data-[state=active]:bg-white data-[state=active]:text-slate-900"
<div className="mt-3">
<div className="mb-2 text-xs text-slate-500">信息流筛选(单列时间流,不分栏)</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(TAB_LABELS) as InboxTabKey[]).map((tabKey) => (
<button
key={tabKey}
type="button"
onClick={() => setTab(tabKey)}
className={cn(
"inline-flex h-8 items-center gap-1 rounded-full border px-3 text-xs transition sm:text-sm",
activeTab === tabKey
? "border-slate-300 bg-white text-slate-900"
: "border-slate-200 bg-slate-50 text-slate-600 hover:bg-white"
)}
>
{section.label} {section.count}
</TabsTrigger>
<span>{TAB_LABELS[tabKey]}</span>
<span className="text-[11px] text-slate-500">{tabCounts[tabKey]}</span>
</button>
))}
</TabsList>
</Tabs>
</div>
</div>
</section>
{isLoading ? (
<section className="rounded-2xl bg-[#F8F9FA] p-6">
<div className="flex items-center gap-2 rounded-xl bg-white px-4 py-3 text-slate-500 shadow-[0_4px_14px_rgba(15,23,42,0.06)]">
<section className="rounded-2xl bg-[#F8F9FA] p-4">
<div className="flex items-center gap-2 rounded-xl bg-white px-4 py-3 text-slate-500">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
加载消息台中…
</div>
</section>
) : filteredItems.length === 0 ? (
<section className="rounded-2xl bg-[#F8F9FA] p-6">
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)]">
<CardContent className="py-12 text-center text-slate-500">
<BellRing className="mx-auto mb-3 h-12 w-12 opacity-50" />
) : feedItems.length === 0 ? (
<section className="rounded-2xl bg-[#F8F9FA] p-4">
<div className="rounded-xl bg-white px-4 py-10 text-center text-slate-500">
<BellRing className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="font-medium text-slate-700">当前没有匹配收件项</p>
<p className="mt-1 text-sm">请调整筛选条件后重试</p>
</CardContent>
</Card>
</div>
</section>
) : (
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5">
<div className="grid gap-4 xl:grid-cols-2">
{filteredItems.map((item) => (
<InboxCard
<section className="space-y-2 rounded-2xl bg-[#F8F9FA] p-2 md:p-3">
{feedItems.map((item) => (
<InboxListItem
key={item.id}
item={item}
expanded={expandedItemId === item.id}
pendingActionKey={pendingActionKey}
onToggleExpand={toggleExpand}
onAction={(target, action) => void handleAction(target, action)}
/>
))}
</div>
</section>
)}
</div>
<Dialog
open={Boolean(workItemDetailItem)}
onOpenChange={(open) => {
if (!open) setWorkItemDetailItem(null);
}}
>
<DialogContent className="max-h-[90vh] w-[min(720px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] overflow-y-auto">
<DialogHeader>
<DialogTitle>{workItemDetailItem?.title || "工作项详情"}</DialogTitle>
<DialogDescription>
{workItemDetailItem?.project_name || (workItemDetailItem?.project_id ? `项目 #${workItemDetailItem.project_id}` : "未绑定项目")}
{" · "}
{formatDetailTime(workItemDetailItem?.updated_at || workItemDetailItem?.created_at)}
</DialogDescription>
</DialogHeader>
{workItemDetailItem && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className={cn("text-[11px]", STATUS_STYLES[workItemDetailItem.display_kind])}>
{STATUS_LABELS[workItemDetailItem.display_kind]}
</Badge>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
{toZhPriority(workItemDetailItem.priority)}
</span>
{workItemDetailItem.source_work_item?.intent && (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
{workItemDetailItem.source_work_item.intent}
</span>
)}
{workItemDetailItem.source_work_item?.source_system && (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
{workItemDetailItem.source_work_item.source_system}
</span>
)}
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">工作项摘要</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-700">
{workItemDetailItem.summary || "暂无摘要"}
</p>
</div>
{workItemDetailItem.source_work_item?.blocking_reason && (
<div className="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-amber-700">阻塞原因</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-amber-900/80">
{workItemDetailItem.source_work_item.blocking_reason}
</p>
</div>
)}
{workItemDetailItem.source_work_item?.ai_plan && (
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">AI 执行计划</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-700">
{workItemDetailItem.source_work_item.ai_plan}
</p>
</div>
)}
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-xl bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">当前阶段</div>
<p className="mt-2 text-sm text-slate-700">
{workItemDetailItem.source_work_item?.stage_text || STATUS_LABELS[workItemDetailItem.display_kind]}
</p>
</div>
<div className="rounded-xl bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">下一步</div>
<p className="mt-2 whitespace-pre-wrap text-sm text-slate-700">
{workItemDetailItem.source_work_item?.next_step_text || "暂无"}
</p>
</div>
</div>
{workItemDetailQuery.isLoading ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-500">
加载工作项详情中…
</div>
) : workItemDetailQuery.data ? (
<>
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-xl bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">决策记录</div>
<p className="mt-2 text-sm text-slate-700">{workItemDetailQuery.data.decision_records.length} 条</p>
</div>
<div className="rounded-xl bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">执行计划</div>
<p className="mt-2 text-sm text-slate-700">{workItemDetailQuery.data.plan_bundles.length} 条</p>
</div>
<div className="rounded-xl bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">回执记录</div>
<p className="mt-2 text-sm text-slate-700">{workItemDetailQuery.data.reply_receipts.length} 条</p>
</div>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">执行结果</div>
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all text-xs leading-6 text-slate-700">
{formatJsonBlock(workItemDetailQuery.data.execution_result)}
</pre>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">复盘摘要</div>
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all text-xs leading-6 text-slate-700">
{formatJsonBlock(workItemDetailQuery.data.review_summary)}
</pre>
</div>
</>
) : (
<div className="rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
工作项详情加载失败,请稍后重试。
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setWorkItemDetailItem(null)}>
关闭
</Button>
{workItemDetailItem?.target_url && (
<Button
className="bg-blue-600 text-white hover:bg-blue-700"
onClick={() => {
void openTarget(workItemDetailItem, { forceDirect: true });
setWorkItemDetailItem(null);
}}
>
进入项目
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(notificationDetailItem)}
onOpenChange={(open) => {
if (!open) setNotificationDetailItem(null);
}}
>
<DialogContent className="max-h-[90vh] w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] overflow-y-auto">
<DialogHeader>
<DialogTitle>{notificationDetailItem?.title || "通知详情"}</DialogTitle>
<DialogDescription>
{notificationDetailItem?.project_name || (notificationDetailItem?.project_id ? `项目 #${notificationDetailItem.project_id}` : "未绑定项目")}
{" · "}
{formatDetailTime(notificationDetailItem?.updated_at || notificationDetailItem?.created_at)}
</DialogDescription>
</DialogHeader>
{notificationDetailItem && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className={cn("text-[11px]", STATUS_STYLES[notificationDetailItem.display_kind])}>
{STATUS_LABELS[notificationDetailItem.display_kind]}
</Badge>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
{toZhPriority(notificationDetailItem.priority)}
</span>
{notificationDetailItem.source_notification?.source && (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
{notificationDetailItem.source_notification.source}
</span>
)}
{notificationDetailItem.source_work_item?.source_system && (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
{notificationDetailItem.source_work_item.source_system}
</span>
)}
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">通知摘要</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-700">
{notificationDetailItem.source_notification?.content || notificationDetailItem.summary || "暂无摘要"}
</p>
</div>
{notificationDetailItem.ai_plan && (
<div className="rounded-xl bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">AI 已做/将做什么</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-700">{notificationDetailItem.ai_plan}</p>
</div>
)}
{notificationDetailItem.blocking_reason && (
<div className="rounded-xl border border-amber-100 bg-amber-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-amber-700">当前阻塞</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-amber-900/80">{notificationDetailItem.blocking_reason}</p>
</div>
)}
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-xl bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">当前阶段</div>
<p className="mt-2 text-sm text-slate-700">{notificationDetailItem.stage_text || STATUS_LABELS[notificationDetailItem.display_kind]}</p>
</div>
<div className="rounded-xl bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">建议下一步</div>
<p className="mt-2 text-sm text-slate-700">{notificationDetailItem.next_step_text || "先确认通知内容,再进入结果页处理。"}</p>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setNotificationDetailItem(null)}>
关闭
</Button>
{notificationDetailItem?.target_url && (
<Button
className="bg-blue-600 text-white hover:bg-blue-700"
onClick={() => {
const currentItem = notificationDetailItem;
setNotificationDetailItem(null);
if (currentItem) {
void openTarget(currentItem, { forceDirect: true });
}
}}
>
进入结果页
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -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<string>();
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<string, number>;
const dailyPayload = (pmoDailySnapshot?.payload || {}) as Record<string, unknown>;
const riskActions = Array.isArray(dailyPayload.focus_actions)
@ -529,7 +599,7 @@ 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 },
{ label: "待确认工作项", value: pendingManualWorkItemCount },
].map((metric) => (
<div key={metric.label} className="rounded-xl bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.12em] text-slate-500">{metric.label}</div>
@ -563,8 +633,19 @@ export default function ProjectBoardPage() {
</Button>
</div>
{pmoWorkItems.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-sm text-slate-500">
当前项目暂无 PMO 工作项。
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6">
<div className="text-sm text-slate-600">当前项目暂无 PMO 工作项。</div>
<div className="mt-2 text-xs text-slate-500">
建议下一步:先在任务阶段创建首批任务,或前往消息台确认待处理信号。
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" variant="outline" onClick={() => setActiveStage("task")}>
去任务阶段
</Button>
<Button size="sm" variant="ghost" onClick={() => navigate(`/inbox?project_id=${id}`)}>
打开消息台
</Button>
</div>
</div>
) : (
pmoWorkItems.map((item) => (
@ -575,11 +656,11 @@ export default function ProjectBoardPage() {
onClick={() => navigate(`/inbox?project_id=${id}&q=${encodeURIComponent(item.title)}`)}
>
<div className="flex items-start justify-between gap-3">
<div className="line-clamp-2 text-sm font-medium text-slate-900">{item.title}</div>
<div className="line-clamp-2 text-sm font-medium text-slate-900">{toReadableWorkItemTitle(item)}</div>
{item.state === "waiting_manual" && <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />}
</div>
<div className="mt-2 text-xs text-slate-500">{item.stage_text || item.state}</div>
<div className="mt-2 line-clamp-2 text-xs text-slate-600">{item.next_step_text || item.summary}</div>
<div className="mt-2 line-clamp-2 text-xs text-slate-600">{toReadableWorkItemSummary(item)}</div>
</button>
))
)}
@ -642,8 +723,6 @@ export default function ProjectBoardPage() {
</TabsContent>
</Tabs>
{risks && <SSTCRiskFloatPanel risks={risks} />}
<Dialog open={createCharterOpen} onOpenChange={handleCreateCharterDialogChange}>
<DialogContent className="flex max-h-[85vh] w-[min(560px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] flex-col gap-0 overflow-hidden p-0 sm:w-[560px] sm:max-w-[560px]">
<DialogHeader className="min-w-0 shrink-0 border-b border-slate-200 px-6 py-5">

View File

@ -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"}
{"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"}

View File

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

View File

@ -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] 构建与类型检查结果

View File

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

285
scripts/model_probe.py Normal file
View File

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

View File

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

25
scripts/start_model_probe.sh Executable file
View File

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

View File

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

View File

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

View File

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