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 # GLM-4.7 配 GLM_API_KEY
# MiniMax-M2.5 配 OPENAI_API_KEY # GLM-5(经 OpenAI 兼容网关)配 OPENAI_API_KEY
GLM_API_KEY= GLM_API_KEY=
GLM_BASE_URL=https://api.edgefn.net/v1 GLM_BASE_URL=https://api.edgefn.net/v1
GLM_MODEL=GLM-4.7 GLM_MODEL=GLM-4.7
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.edgefn.net/v1 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_PROVIDER=glm
LLM_ROUTING_LOW_MODEL=GLM-4.7 LLM_ROUTING_LOW_MODEL=GLM-4.7
LLM_ROUTING_HIGH_PROVIDER=openai 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_PROVIDER=glm
LLM_ROUTING_SAFETY_MODEL=GLM-4.7 LLM_ROUTING_SAFETY_MODEL=GLM-4.7

View File

@ -19,6 +19,7 @@ AIProjectManger/
├── CLAUDE.md # 现有 Claude 说明文档 ├── CLAUDE.md # 现有 Claude 说明文档
├── start.sh / stop.sh # 一键启动/停止入口 ├── start.sh / stop.sh # 一键启动/停止入口
├── docker-compose*.yml # 容器编排 ├── docker-compose*.yml # 容器编排
├── mdproject/ # 执行计划与任务清单(中文为主)
├── backend/ # FastAPI 后端 ├── backend/ # FastAPI 后端
├── frontend/ # React 前端 ├── frontend/ # React 前端
├── scripts/ # 根目录运维脚本 ├── scripts/ # 根目录运维脚本
@ -75,6 +76,7 @@ frontend/
│ ├── pages/ # 页面级组件 │ ├── pages/ # 页面级组件
│ ├── components/ │ ├── components/
│ │ ├── ui/ # 基础 UI 组件 │ │ ├── ui/ # 基础 UI 组件
│ │ ├── inbox/ # 消息台 Feed 列表组件
│ │ └── sstc/ # SSTC 工作流组件(已模块化拆分) │ │ └── sstc/ # SSTC 工作流组件(已模块化拆分)
│ ├── hooks/ # 自定义 hooks │ ├── hooks/ # 自定义 hooks
│ ├── lib/ # 通用工具 │ ├── lib/ # 通用工具
@ -85,6 +87,10 @@ frontend/
└── vite.config.ts └── 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/` 目录说明(模块化拆分后) ### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后)
- `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件) - `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件)
- `useSSTCWorkspaceOrchestration.ts`:工作区编排 Hook(状态聚合、请求编排、事件路由) - `useSSTCWorkspaceOrchestration.ts`:工作区编排 Hook(状态聚合、请求编排、事件路由)
@ -125,8 +131,17 @@ frontend/
```bash ```bash
./start.sh dev # 推荐:docker-dev 热更新模式 ./start.sh dev # 推荐:docker-dev 热更新模式
./stop.sh 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 ```bash
cd frontend cd frontend

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import time
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -518,9 +519,101 @@ async def _warmup_world_models() -> None:
logger.warning("[WM_WARMUP] 预热失败", error=str(exc)) 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 @asynccontextmanager
async def lifespan(_: FastAPI): 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 { auto_create_tables = os.getenv("AUTO_CREATE_TABLES", "false").lower() in {
"1", "1",
"true", "true",
@ -535,6 +628,8 @@ async def lifespan(_: FastAPI):
raise RuntimeError("AUTO_CREATE_TABLES=true 已被禁用;请使用 Alembic migration 变更数据库。") raise RuntimeError("AUTO_CREATE_TABLES=true 已被禁用;请使用 Alembic migration 变更数据库。")
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(_check_database_dialect_guard) await conn.run_sync(_check_database_dialect_guard)
_startup_log("数据库连接成功")
await _probe_model_connectivity_on_startup()
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
try: try:
@ -569,8 +664,10 @@ async def lifespan(_: FastAPI):
_warmup_world_models(), _warmup_world_models(),
name="wm-world-model-warmup", name="wm-world-model-warmup",
) )
_startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}")
yield yield
_startup_log("服务关闭中")
outbox_task.cancel() outbox_task.cancel()
try: try:
await outbox_task await outbox_task
@ -586,6 +683,7 @@ async def lifespan(_: FastAPI):
await knowledge_scheduler.stop() await knowledge_scheduler.stop()
await scheduler.stop() await scheduler.stop()
await engine.dispose() await engine.dispose()
_startup_log("服务已关闭")
app = FastAPI( 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" SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SETUP_SCRIPT="devops/tunnel/setup.sh" 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 FRONT_LOG="${LOG_DIR}/tunnel-frontend.log"
if [ ! -f "$CONFIG_FILE" ]; then BACK_LOG="${LOG_DIR}/tunnel-backend.log"
echo "⚠️ Named Tunnel Configuration not found."
echo " If you have a domain, you can set up a stable tunnel." extract_url() {
echo " If not, you can use Quick Tunnels (random URLs)." local logfile="$1"
grep -aEo 'https://[-a-z0-9]+\.trycloudflare\.com' "$logfile" 2>/dev/null | tail -1
}
wait_for_url() {
local logfile="$1"
local retries="${2:-30}"
local i=1
local url=""
while [ "$i" -le "$retries" ]; do
url="$(extract_url "$logfile")"
if [ -n "$url" ]; then
echo "$url"
return 0
fi
sleep 1
i=$((i + 1))
done
return 1
}
check_url() {
local url="$1"
local max_retry="$2"
local retry=1
while [ "$retry" -le "$max_retry" ]; do
if curl -kfsS --max-time 8 "$url" >/dev/null 2>&1; then
return 0
fi
sleep 2
retry=$((retry + 1))
done
return 1
}
run_quick_tunnels() {
mkdir -p "$LOG_DIR"
pkill -f "cloudflared tunnel .*127\\.0\\.0\\.1:${FRONTEND_PORT}" >/dev/null 2>&1 || true
pkill -f "cloudflared tunnel .*127\\.0\\.0\\.1:${BACKEND_PORT}" >/dev/null 2>&1 || true
echo "🚀 启动临时隧道..."
echo "📂 日志目录: ${LOG_DIR}"
echo "🌐 前端隧道: 127.0.0.1:${FRONTEND_PORT}"
cloudflared tunnel --protocol "${TUNNEL_PROTOCOL}" --url "http://127.0.0.1:${FRONTEND_PORT}" >"$FRONT_LOG" 2>&1 &
local front_pid=$!
echo "🌐 后端隧道: 127.0.0.1:${BACKEND_PORT}"
cloudflared tunnel --protocol "${TUNNEL_PROTOCOL}" --url "http://127.0.0.1:${BACKEND_PORT}" >"$BACK_LOG" 2>&1 &
local back_pid=$!
echo "⏳ 等待隧道地址..."
local front_url=""
local back_url=""
front_url="$(wait_for_url "$FRONT_LOG" 45 || true)"
back_url="$(wait_for_url "$BACK_LOG" 45 || true)"
if [ -z "$front_url" ] || [ -z "$back_url" ]; then
echo "❌ 未获取到公网地址。"
echo " 前端日志: $FRONT_LOG"
echo " 后端日志: $BACK_LOG"
tail -n 20 "$FRONT_LOG" || true
tail -n 20 "$BACK_LOG" || true
kill "$front_pid" "$back_pid" >/dev/null 2>&1 || true
return 1
fi
local front_ok=false
local back_ok=false
if check_url "${front_url}/login" "$HEALTH_RETRIES" || check_url "${front_url}" "$HEALTH_RETRIES"; then
front_ok=true
fi
if check_url "${back_url}/healthz" "$HEALTH_RETRIES"; then
back_ok=true
fi
if [ "$front_ok" = true ] && [ "$back_ok" = true ]; then
echo "✅ 隧道连通成功"
else
echo "⚠️ 隧道已启动,但健康检查未全部通过(可稍后重试)"
fi
echo "----------------------------------------"
echo "前端: ${front_url}"
echo "后端: ${back_url}"
echo "----------------------------------------"
echo "ℹ️ 停止命令: pkill -f \"cloudflared tunnel\""
if [ "$FOREGROUND" != "1" ]; then
echo "✅ 已切到后台运行"
echo " 前端 PID: ${front_pid}"
echo " 后端 PID: ${back_pid}"
return 0
fi
cleanup() {
echo "" echo ""
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 echo "🚀 启动公网穿透..."
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
if [ -f "$CONFIG_FILE" ]; then if [ -f "$CONFIG_FILE" ]; then
echo "✅ Configuration found." echo "✅ 检测到命名隧道配置: ${CONFIG_FILE}"
echo "🌐 Starting tunnel for Frontend and Backend..." echo "🌐 使用命名隧道运行(前台)"
exec cloudflared tunnel run --config "$CONFIG_FILE"
# Run the tunnel using the config file
cloudflared tunnel run --config "$CONFIG_FILE"
else
echo "❌ Failed to establish configuration."
exit 1
fi 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_BASE_URL: ${OLLAMA_BASE_URL:?missing_OLLAMA_BASE_URL}
OLLAMA_MODEL: ${OLLAMA_MODEL:?missing_OLLAMA_MODEL} OLLAMA_MODEL: ${OLLAMA_MODEL:?missing_OLLAMA_MODEL}
PYTHONUNBUFFERED: "1" 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: volumes:
- ./backend:/app/backend - ./backend:/app/backend
- ./.openclaw:/app/.openclaw:ro - ./.openclaw:/app/.openclaw:ro

View File

@ -603,17 +603,17 @@ export function FloatingQAChat() {
<button <button
type="button" type="button"
onClick={() => setOpen((prev) => !prev)} 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" open ? "bg-violet-700" : "bg-violet-600 hover:bg-violet-700"
}`} }`}
aria-label="打开 AI 聊天" 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} {unread ? <span className="absolute right-1 top-1 h-2.5 w-2.5 rounded-full bg-red-500" /> : null}
</button> </button>
<div <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" 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 { Outlet, useLocation } from "react-router-dom";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { TopBar } from "./TopBar"; import { TopBar } from "./TopBar";
import { FloatingQAChat } from "@/components/ai-steward/FloatingQAChat";
export default function Layout() { export default function Layout() {
const location = useLocation(); const location = useLocation();
@ -28,6 +29,7 @@ export default function Layout() {
<Outlet /> <Outlet />
</main> </main>
</div> </div>
<FloatingQAChat />
</div> </div>
); );
} }

View File

@ -1,11 +1,14 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Bell, CheckCircle2, LoaderCircle, LogOut, Menu, ShieldAlert, User } from "lucide-react"; import { Bell, CheckCircle2, LoaderCircle, LogOut, Menu, ShieldAlert, User } from "lucide-react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo"; import { aiPmoApi, type AIPMOWorkItemListPayload } from "@/api/aiPmo";
import { sstcApi, type SSTCRisks } from "@/api/sstc";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/toast"; import { useToast } from "@/components/ui/toast";
import { notificationsApi, type NotificationListPayload } from "@/api/notifications"; import { notificationsApi, type NotificationListPayload } from "@/api/notifications";
import { SSTCRiskTopbarEntry } from "@/components/sstc/SSTCRiskFloatPanel";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { parseBackendDate } from "@/lib/datetime"; import { parseBackendDate } from "@/lib/datetime";
import { zhCN } from "@/lib/zhCN"; import { zhCN } from "@/lib/zhCN";
@ -27,6 +30,16 @@ const EMPTY_NOTIFICATIONS: NotificationListPayload = {
const EMPTY_WORK_ITEMS: AIPMOWorkItemListPayload = { const EMPTY_WORK_ITEMS: AIPMOWorkItemListPayload = {
items: [], 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> = { const SECTION_STYLES: Record<InboxDisplayKind, string> = {
action_required: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300", 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 inboxData = useMemo(() => buildInboxView({ notifications, workItems }), [notifications, workItems]);
const currentProjectId = location.pathname.match(/^\/projects\/(\d+)/)?.[1] || null; const currentProjectId = location.pathname.match(/^\/projects\/(\d+)/)?.[1] || null;
const inboxHref = currentProjectId ? `/inbox?project_id=${currentProjectId}` : "/inbox"; 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 fetchBellData = async (notifyOnNew: boolean) => {
const [notificationsResult, workItemsResult] = await Promise.allSettled([ const [notificationsResult, workItemsResult] = await Promise.allSettled([
@ -300,6 +321,7 @@ export function TopBar({ onMenuToggle }: TopBarProps) {
</div> </div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{currentProjectId && <SSTCRiskTopbarEntry risks={currentProjectRisks} />}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full"> <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 progress = overview.task_progress.progress || 0;
const ratio = overview.workload_health.avg_ratio || 0; const ratio = overview.workload_health.avg_ratio || 0;
const workloadMembers = overview.workload_health.members || []; const workloadMembers = overview.workload_health.members || [];
const taskCount = Number(overview.project.task_count || 0);
const projectLabel = formatProjectNameWithId({ const projectLabel = formatProjectNameWithId({
id: overview.project.id, id: overview.project.id,
title: overview.project.title, title: overview.project.title,
@ -98,7 +99,12 @@ export function SSTCGlobalHeader({
<CardContent className="space-y-1 text-sm text-muted-foreground"> <CardContent className="space-y-1 text-sm text-muted-foreground">
<p>项目描述:{overview.project.description || "暂无"}</p> <p>项目描述:{overview.project.description || "暂无"}</p>
<p>创建时间(北京时间):{formatBeijingDateTime(overview.project.created_at)}</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> </CardContent>
</Card> </Card>

View File

@ -1,53 +1,37 @@
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { AlertTriangle, Brain, Minimize2, Maximize2 } from "lucide-react"; import { AlertTriangle, Brain, Maximize2 } from "lucide-react";
import type { SSTCRisks } from "@/api/sstc"; import type { SSTCRisks } from "@/api/sstc";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
interface SSTCRiskFloatPanelProps { interface SSTCRiskPanelProps {
risks: SSTCRisks; risks: SSTCRisks;
onJump?: (entityId: string) => void; onJump?: (entityId: string) => void;
} }
export function SSTCRiskFloatPanel({ risks, onJump }: SSTCRiskFloatPanelProps) { function formatBadgeCount(total: number): string {
const [minimized, setMinimized] = useState(true); 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( const total = useMemo(
() => (risks.summary.conflict_count || 0) + (risks.summary.risk_count || 0), () => computeRiskTotal(risks),
[risks] [risks]
); );
if (minimized) {
return ( return (
<div className="fixed bottom-4 right-2 z-40 sm:right-4"> <div className="w-[min(360px,calc(100vw-1rem))] rounded-lg bg-card">
<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="flex items-center justify-between border-b px-3 py-2"> <div className="flex items-center justify-between border-b px-3 py-2">
<p className="text-sm font-semibold">项目预警中心</p> <p className="text-sm font-semibold">项目预警中心</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="rounded-full bg-red-600 px-2 py-0.5 text-[10px] text-white"> <span className="rounded-full bg-red-600 px-2 py-0.5 text-[10px] text-white">
{total} {formatBadgeCount(total)}
</span> </span>
<button onClick={() => setMinimized(true)} title="最小化">
<Minimize2 className="h-4 w-4 text-muted-foreground" />
</button>
</div> </div>
</div> </div>
<div className="p-2"> <div className="p-2">
@ -108,3 +92,31 @@ export function SSTCRiskFloatPanel({ risks, onJump }: SSTCRiskFloatPanelProps) {
</div> </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 { useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import { BellRing } from "lucide-react";
BellRing,
CheckCircle2,
Filter,
LoaderCircle,
ShieldAlert,
SlidersHorizontal,
Sparkles,
} from "lucide-react";
import { aiPmoApi } from "@/api/aiPmo"; import { aiPmoApi } from "@/api/aiPmo";
import { notificationsApi } from "@/api/notifications"; 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 { 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 { 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 { useToast } from "@/components/ui/toast";
import { buildInboxView, getInboxSectionLabel } from "@/lib/inbox";
import { getApiErrorMessage } from "@/lib/apiError"; import { getApiErrorMessage } from "@/lib/apiError";
import { cn } from "@/lib/utils";
import { toZhPriority } from "@/lib/display";
import { parseBackendDate } from "@/lib/datetime"; 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 POLL_INTERVAL_MS = 15000;
const STATUS_LABELS: Record<InboxDisplayKind, string> = { type InboxTabKey = "all" | "needs_action" | "in_progress" | "done";
action_required: "需你决策",
auto_running: "AI 已接手", const TAB_LABELS: Record<InboxTabKey, string> = {
auto_done: "AI 执行完成", all: "全部",
info: "仅通知", needs_action: "待处理",
in_progress: "进行中",
done: "已完成",
}; };
const STATUS_STYLES: Record<InboxDisplayKind, string> = { const TAB_TO_DISPLAY_KIND: Partial<Record<InboxTabKey, InboxDisplayKind>> = {
action_required: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300", needs_action: "action_required",
auto_running: "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300", in_progress: "auto_running",
auto_done: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", done: "auto_done",
info: "border-slate-400/40 bg-slate-500/10 text-slate-700 dark:text-slate-300",
}; };
const METRIC_CARD_STYLES = [ function normalizeTabKey(raw?: string | null): InboxTabKey {
"bg-blue-50 text-blue-700", const value = String(raw || "").trim();
"bg-sky-50 text-sky-700", if (value === "action_required" || value === "needs_action") return "needs_action";
"bg-emerald-50 text-emerald-700", if (value === "auto_running" || value === "in_progress") return "in_progress";
"bg-amber-50 text-amber-700", if (value === "auto_done" || value === "done") return "done";
"bg-violet-50 text-violet-700", return "all";
];
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 buildScopedSections(items: InboxItemViewModel[]) { function sortByLatest(items: InboxItemViewModel[]): InboxItemViewModel[] {
const sectionKeys: InboxSectionKey[] = ["all", "action_required", "auto_running", "auto_done", "info"]; const toTimestamp = (item: InboxItemViewModel): number => {
return sectionKeys.map((key) => ({ const date = parseBackendDate(item.updated_at || item.created_at);
key, return date ? date.getTime() : -1;
label: getInboxSectionLabel(key), };
count: key === "all" ? items.length : items.filter((item) => item.display_kind === key).length,
})); return [...items].sort((a, b) => toTimestamp(b) - toTimestamp(a));
} }
function isToday(value?: string | null) { function matchesTab(item: InboxItemViewModel, tab: InboxTabKey): boolean {
const date = parseBackendDate(value); if (tab === "all") return true;
if (!date) return false; return item.display_kind === TAB_TO_DISPLAY_KIND[tab];
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>
);
} }
export default function InboxPage() { export default function InboxPage() {
@ -266,34 +58,25 @@ export default function InboxPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { toast } = useToast(); const { toast } = useToast();
const [searchParams, setSearchParams] = useSearchParams(); 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 [pendingActionKey, setPendingActionKey] = useState<string | null>(null);
const [priorityFilter, setPriorityFilter] = useState("all"); const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
const [onlyDecision, setOnlyDecision] = useState(false);
const [onlyUnread, setOnlyUnread] = useState(false); const activeTab = normalizeTabKey(searchParams.get("section"));
const activeSection = (searchParams.get("section") as InboxSectionKey) || "all";
const projectFilter = searchParams.get("project_id") || "all"; const projectFilter = searchParams.get("project_id") || "all";
const searchValue = searchParams.get("q") || ""; const searchValue = searchParams.get("q") || "";
const notificationsQuery = useQuery({ const notificationsQuery = useQuery({
queryKey: ["notifications", "inbox"], queryKey: ["notifications", "inbox"],
queryFn: () => notificationsApi.list({ limit: 60 }), queryFn: () => notificationsApi.list({ limit: 80 }),
refetchInterval: POLL_INTERVAL_MS, refetchInterval: POLL_INTERVAL_MS,
}); });
const workItemsQuery = useQuery({ const workItemsQuery = useQuery({
queryKey: ["ai-pmo-work-items", "inbox"], queryKey: ["ai-pmo-work-items", "inbox"],
queryFn: () => aiPmoApi.listWorkItems({ limit: 60 }), queryFn: () => aiPmoApi.listWorkItems({ limit: 80 }),
refetchInterval: POLL_INTERVAL_MS, 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(() => { const inboxData = useMemo(() => {
return buildInboxView({ return buildInboxView({
notifications: notificationsQuery.data || { items: [], unread_count: 0 }, notifications: notificationsQuery.data || { items: [], unread_count: 0 },
@ -317,42 +100,35 @@ export default function InboxPage() {
}); });
}, [inboxData.items, projectFilter]); }, [inboxData.items, projectFilter]);
const filteredItems = useMemo(() => { const tabCounts = 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(() => {
return { return {
decision_count: projectScopedItems.filter((item) => item.display_kind === "action_required").length, all: projectScopedItems.length,
auto_running_count: projectScopedItems.filter((item) => item.display_kind === "auto_running").length, needs_action: projectScopedItems.filter((item) => item.display_kind === "action_required").length,
auto_done_today: projectScopedItems.filter( in_progress: projectScopedItems.filter((item) => item.display_kind === "auto_running").length,
(item) => item.display_kind === "auto_done" && isToday(item.updated_at || item.created_at) done: projectScopedItems.filter((item) => item.display_kind === "auto_done").length,
).length, } satisfies Record<InboxTabKey, number>;
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),
};
}, [projectScopedItems]); }, [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); const next = new URLSearchParams(searchParams);
next.set("section", section); next.set("section", normalized);
setSearchParams(next); setSearchParams(next);
}; };
@ -370,6 +146,10 @@ export default function InboxPage() {
setSearchParams(next); setSearchParams(next);
}; };
const toggleExpand = (itemId: string) => {
setExpandedItemId((current) => (current === itemId ? null : itemId));
};
const refreshAll = async () => { const refreshAll = async () => {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ["notifications", "inbox"] }), queryClient.invalidateQueries({ queryKey: ["notifications", "inbox"] }),
@ -382,28 +162,12 @@ export default function InboxPage() {
try { try {
await notificationsApi.markRead(Number(item.source_notification.id)); await notificationsApi.markRead(Number(item.source_notification.id));
} catch { } catch {
// 不阻断后续跳转。 // 不阻断后续操作。
} }
} }
}; };
const openNotificationDetail = async (item: InboxItemViewModel) => { const openTarget = 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;
}
await markNotificationRead(item); await markNotificationRead(item);
if (item.target_url) { if (item.target_url) {
@ -412,60 +176,46 @@ export default function InboxPage() {
await refreshAll(); await refreshAll();
return; return;
} }
if (item.source_kind === "ai_pmo_work_item") {
await openWorkItemDetail(item); toast({ title: "当前消息暂无可打开的目标入口", variant: "default" });
return;
}
toast({ title: "当前卡片暂无可打开的详情入口", variant: "error" });
}; };
const handleAction = async (item: InboxItemViewModel, action: InboxActionDescriptor) => { const handleAction = async (item: InboxItemViewModel, action: InboxActionDescriptor) => {
const actionKey = buildActionKey(item, action); const actionKey = buildInboxActionKey(item, action);
setPendingActionKey(actionKey); setPendingActionKey(actionKey);
try { try {
if (item.source_kind === "notification" && action.label === "查看详情") {
await openNotificationDetail(item);
return;
}
if (action.kind === "open_target") { if (action.kind === "open_target") {
await openTarget(item); await openTarget(item);
return; return;
} }
if (action.kind === "open_detail") { if (action.kind === "open_detail") {
if (item.source_kind === "notification") { await markNotificationRead(item);
await openNotificationDetail(item); toggleExpand(item.id);
return; await refreshAll();
}
await openWorkItemDetail(item);
return; return;
} }
if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { 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, { const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
action: "approve", action: "approve",
project_id: item.project_id ?? undefined, project_id: item.project_id ?? undefined,
}); });
await refreshAll(); await refreshAll();
const followupReply = String(result.followup_reply || "").trim(); const followupReply = String(result.followup_reply || "").trim();
if (result.status === "succeeded") { if (result.status === "succeeded") {
toast({ title: followupReply || "AI PMO 已继续执行并完成该工作项", variant: "success" }); toast({ title: followupReply || "已继续执行并完成", variant: "success" });
} else if (result.status === "pending_manual") { } else if (result.status === "pending_manual") {
toast({ title: followupReply || "继续执行后仍需补充信息或人工确认", variant: "default" }); toast({ title: followupReply || "继续执行后仍需人工确认", variant: "default" });
} else { } else {
toast({ title: followupReply || "继续执行失败,请查看详情", variant: "error" }); toast({ title: followupReply || "继续执行失败,请查看详情", variant: "error" });
} }
setWorkItemDetailItem({ setExpandedItemId(item.id);
...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,
});
return; return;
} }
if (action.kind === "reject" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { 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, { const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, {
action: "reject", action: "reject",
@ -474,22 +224,17 @@ export default function InboxPage() {
await refreshAll(); await refreshAll();
const followupReply = String(result.followup_reply || "").trim(); const followupReply = String(result.followup_reply || "").trim();
toast({ title: followupReply || "已标记失效,不再继续执行", variant: "default" }); toast({ title: followupReply || "已标记失效,不再继续执行", variant: "default" });
if (workItemDetailItem?.id === item.id) {
setWorkItemDetailItem(null);
}
return; return;
} }
if (action.kind === "mark_read" && item.source_kind === "notification" && item.source_notification) { if (action.kind === "mark_read" && item.source_kind === "notification" && item.source_notification) {
await notificationsApi.markRead(item.source_notification.id); await notificationsApi.markRead(item.source_notification.id);
await refreshAll(); await refreshAll();
return; return;
} }
if (action.kind === "none") { if (action.kind === "none") {
if (item.source_kind === "ai_pmo_work_item") { toggleExpand(item.id);
await openWorkItemDetail(item);
return;
}
toast({ title: action.label, variant: "default" });
} }
} catch (error) { } catch (error) {
toast({ title: getApiErrorMessage(error), variant: "error" }); toast({ title: getApiErrorMessage(error), variant: "error" });
@ -501,8 +246,8 @@ export default function InboxPage() {
const markAllRead = async () => { const markAllRead = async () => {
try { try {
await notificationsApi.markAllRead(); await notificationsApi.markAllRead();
toast({ title: "站内通知已全部设为已读", variant: "success" });
await refreshAll(); await refreshAll();
toast({ title: "站内通知已全部设为已读", variant: "success" });
} catch (error) { } catch (error) {
toast({ title: getApiErrorMessage(error, "全部已读失败"), variant: "error" }); toast({ title: getApiErrorMessage(error, "全部已读失败"), variant: "error" });
} }
@ -511,82 +256,28 @@ export default function InboxPage() {
const isLoading = notificationsQuery.isLoading || workItemsQuery.isLoading; const isLoading = notificationsQuery.isLoading || workItemsQuery.isLoading;
return ( return (
<> <div className="space-y-4 pb-12">
<div className="space-y-6 pb-16"> <section className="rounded-2xl bg-[#F8F9FA] p-3 md:p-4">
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">消息台</h1> <h1 className="text-xl font-bold tracking-tight text-slate-900 md:text-2xl">消息台</h1>
<p className="mt-1 text-slate-500">AI 决策收件箱 · 先看摘要,再处理真正需要你拍板的事项</p> <p className="mt-1 text-xs text-slate-500 md:text-sm">列表 Feed · 最新在上 · 支持移动端</p>
<p className="mt-1 text-xs text-slate-500"> <p className="mt-1 text-xs text-slate-500">当前结果:{feedItems.length} 条</p>
当前结果:{filteredItems.length} 条
{projectFilter !== "all" ? `(已筛选项目 #${projectFilter})` : "(全租户汇总)"}
</p>
</div> </div>
<div className="flex flex-wrap gap-2">
<Button <Button
variant="outline" 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} onClick={markAllRead}
disabled={notificationsQuery.data?.unread_count === 0} disabled={notificationsQuery.data?.unread_count === 0}
> >
全部已读 全部已读
</Button> </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>
<div className="mt-4 rounded-2xl bg-white p-4 shadow-[0_8px_22px_rgba(15,23,42,0.08)]"> <div className="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_180px]">
<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">
<Input <Input
placeholder="搜索标题、摘要、项目" placeholder="搜索标题、副标题、项目"
defaultValue={searchValue} defaultValue={searchValue}
className="border-slate-200 bg-white text-slate-700 placeholder:text-slate-400" className="border-slate-200 bg-white text-slate-700 placeholder:text-slate-400"
onBlur={(event) => setSearch(event.target.value)} onBlur={(event) => setSearch(event.target.value)}
@ -594,11 +285,11 @@ export default function InboxPage() {
if (event.key === "Enter") setSearch((event.target as HTMLInputElement).value); if (event.key === "Enter") setSearch((event.target as HTMLInputElement).value);
}} }}
/> />
</div>
<select <select
value={projectFilter} value={projectFilter}
onChange={(event) => setProject(event.target.value)} 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> <option value="all">全部项目</option>
{projectOptions.map((option) => ( {projectOptions.map((option) => (
@ -607,307 +298,60 @@ export default function InboxPage() {
</option> </option>
))} ))}
</select> </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> </div>
<Tabs value={activeSection} onValueChange={setSection}> <div className="mt-3">
<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"> <div className="mb-2 text-xs text-slate-500">信息流筛选(单列时间流,不分栏)</div>
{scopedSections.map((section) => ( <div className="flex flex-wrap gap-2">
<TabsTrigger {(Object.keys(TAB_LABELS) as InboxTabKey[]).map((tabKey) => (
key={section.key} <button
value={section.key} key={tabKey}
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" 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} <span>{TAB_LABELS[tabKey]}</span>
</TabsTrigger> <span className="text-[11px] text-slate-500">{tabCounts[tabKey]}</span>
</button>
))} ))}
</TabsList> </div>
</Tabs>
</div> </div>
</section> </section>
{isLoading ? ( {isLoading ? (
<section className="rounded-2xl bg-[#F8F9FA] p-6"> <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 shadow-[0_4px_14px_rgba(15,23,42,0.06)]"> <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" /> <span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
加载消息台中… 加载消息台中…
</div> </div>
</section> </section>
) : filteredItems.length === 0 ? ( ) : feedItems.length === 0 ? (
<section className="rounded-2xl bg-[#F8F9FA] p-6"> <section className="rounded-2xl bg-[#F8F9FA] p-4">
<Card className="rounded-2xl border-0 bg-white shadow-[0_8px_22px_rgba(15,23,42,0.08)]"> <div className="rounded-xl bg-white px-4 py-10 text-center text-slate-500">
<CardContent className="py-12 text-center text-slate-500"> <BellRing className="mx-auto mb-3 h-10 w-10 opacity-50" />
<BellRing className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="font-medium text-slate-700">当前没有匹配收件项</p> <p className="font-medium text-slate-700">当前没有匹配收件项</p>
<p className="mt-1 text-sm">请调整筛选条件后重试</p> <p className="mt-1 text-sm">请调整筛选条件后重试</p>
</CardContent> </div>
</Card>
</section> </section>
) : ( ) : (
<section className="rounded-2xl bg-[#F8F9FA] p-4 md:p-5"> <section className="space-y-2 rounded-2xl bg-[#F8F9FA] p-2 md:p-3">
<div className="grid gap-4 xl:grid-cols-2"> {feedItems.map((item) => (
{filteredItems.map((item) => ( <InboxListItem
<InboxCard
key={item.id} key={item.id}
item={item} item={item}
expanded={expandedItemId === item.id}
pendingActionKey={pendingActionKey} pendingActionKey={pendingActionKey}
onToggleExpand={toggleExpand}
onAction={(target, action) => void handleAction(target, action)} onAction={(target, action) => void handleAction(target, action)}
/> />
))} ))}
</div>
</section> </section>
)} )}
</div> </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 { useParams, Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Plus } from "lucide-react"; 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 { sstcApi, type AIRoutingConfig, type SSTCSignal, type StageKey } from "@/api/sstc";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -23,7 +23,6 @@ import { BudgetControlPanel } from "@/components/budget/BudgetControlPanel";
import { SSTCGlobalHeader } from "@/components/sstc/SSTCGlobalHeader"; import { SSTCGlobalHeader } from "@/components/sstc/SSTCGlobalHeader";
import { SSTCStageNav } from "@/components/sstc/SSTCStageNav"; import { SSTCStageNav } from "@/components/sstc/SSTCStageNav";
import { SSTCWorkspace } from "@/components/sstc/SSTCWorkspace"; import { SSTCWorkspace } from "@/components/sstc/SSTCWorkspace";
import { SSTCRiskFloatPanel } from "@/components/sstc/SSTCRiskFloatPanel";
import { useToast } from "@/components/ui/toast"; import { useToast } from "@/components/ui/toast";
import { formatProjectNameWithId, toZhPriority } from "@/lib/display"; import { formatProjectNameWithId, toZhPriority } from "@/lib/display";
import { getApiErrorMessage } from "@/lib/apiError"; import { getApiErrorMessage } from "@/lib/apiError";
@ -47,6 +46,70 @@ const DEFAULT_SIGNAL_FILTERS: SignalFilterState = {
created_from: "", created_from: "",
created_to: "", 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() { export default function ProjectBoardPage() {
const SIGNAL_PAGE_SIZE = 10; const SIGNAL_PAGE_SIZE = 10;
@ -322,7 +385,14 @@ export default function ProjectBoardPage() {
}), }),
24 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 riskSummary = (pmoRiskSnapshot?.summary || {}) as Record<string, number>;
const dailyPayload = (pmoDailySnapshot?.payload || {}) as Record<string, unknown>; const dailyPayload = (pmoDailySnapshot?.payload || {}) as Record<string, unknown>;
const riskActions = Array.isArray(dailyPayload.focus_actions) 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: "Blocked Tasks", value: Number(riskSummary.blocked_task_count || 0) },
{ label: "待确认信号源", value: Number(riskSummary.pending_signal_source_count || 0) }, { label: "待确认信号源", value: Number(riskSummary.pending_signal_source_count || 0) },
{ label: "风险通知", value: Number(riskSummary.unread_risk_notification_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) => ( ].map((metric) => (
<div key={metric.label} className="rounded-xl bg-slate-50 px-4 py-3"> <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> <div className="text-xs font-semibold tracking-[0.12em] text-slate-500">{metric.label}</div>
@ -563,8 +633,19 @@ export default function ProjectBoardPage() {
</Button> </Button>
</div> </div>
{pmoWorkItems.length === 0 ? ( {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"> <div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6">
当前项目暂无 PMO 工作项。 <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> </div>
) : ( ) : (
pmoWorkItems.map((item) => ( pmoWorkItems.map((item) => (
@ -575,11 +656,11 @@ export default function ProjectBoardPage() {
onClick={() => navigate(`/inbox?project_id=${id}&q=${encodeURIComponent(item.title)}`)} onClick={() => navigate(`/inbox?project_id=${id}&q=${encodeURIComponent(item.title)}`)}
> >
<div className="flex items-start justify-between gap-3"> <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" />} {item.state === "waiting_manual" && <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />}
</div> </div>
<div className="mt-2 text-xs text-slate-500">{item.stage_text || item.state}</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> </button>
)) ))
)} )}
@ -642,8 +723,6 @@ export default function ProjectBoardPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{risks && <SSTCRiskFloatPanel risks={risks} />}
<Dialog open={createCharterOpen} onOpenChange={handleCreateCharterDialogChange}> <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]"> <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"> <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 env = loadEnv(mode, process.cwd(), '');
const tunnelDomain = env.VITE_APP_DOMAIN; const tunnelDomain = env.VITE_APP_DOMAIN;
const proxyTarget = env.VITE_DEV_API_PROXY_TARGET || "http://127.0.0.1:8000"; const proxyTarget = env.VITE_DEV_API_PROXY_TARGET || "http://127.0.0.1:8000";
const usePolling = String(env.CHOKIDAR_USEPOLLING || "false").toLowerCase() === "true";
return { return {
plugins: [react()], plugins: [react()],
@ -17,6 +18,10 @@ export default defineConfig(({ mode }) => {
server: { server: {
port: 5174, port: 5174,
allowedHosts: true, allowedHosts: true,
watch: usePolling ? {
usePolling: true,
interval: 150,
} : undefined,
hmr: tunnelDomain ? { hmr: tunnelDomain ? {
host: tunnelDomain, host: tunnelDomain,
clientPort: 443, 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() { require_cmd() {
command -v "$1" >/dev/null 2>&1 || { command -v "$1" >/dev/null 2>&1 || {
echo "[ERROR] missing command: $1" echo "[错误] 缺少命令: $1"
exit 1 exit 1
} }
} }
@ -19,19 +19,31 @@ require_cmd docker
require_cmd curl require_cmd curl
if ! docker info >/dev/null 2>&1; then if ! docker info >/dev/null 2>&1; then
echo "[ERROR] Docker daemon is not running." echo "[错误] Docker 服务未启动。"
exit 1 exit 1
fi fi
if [[ ! -f "$ENV_FILE" && -f "${PROJECT_DIR}/.env.example" ]]; then if [[ ! -f "$ENV_FILE" && -f "${PROJECT_DIR}/.env.example" ]]; then
cp "${PROJECT_DIR}/.env.example" "$ENV_FILE" cp "${PROJECT_DIR}/.env.example" "$ENV_FILE"
echo "[INFO] .env created from .env.example" echo "[信息] 已根据 .env.example 生成 .env"
fi fi
compose() { compose() {
docker compose -f "$COMPOSE_FILE" "$@" 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() { backend_health() {
curl -fsS "http://127.0.0.1:${BACKEND_PORT:-8000}/healthz" >/dev/null curl -fsS "http://127.0.0.1:${BACKEND_PORT:-8000}/healthz" >/dev/null
} }
@ -51,71 +63,71 @@ db_health() {
case "$ACTION" in case "$ACTION" in
start) start)
echo "[INFO] compose file: ${COMPOSE_FILE}" echo "[信息] 编排文件: ${COMPOSE_FILE}"
compose up -d compose_quiet up -d
echo "[OK] stack started" echo "[完成] 服务栈已启动"
;; ;;
restart) restart)
echo "[INFO] compose file: ${COMPOSE_FILE}" echo "[信息] 编排文件: ${COMPOSE_FILE}"
compose down compose_quiet down
compose up -d compose_quiet up -d
echo "[OK] stack restarted" echo "[完成] 服务栈已重启"
;; ;;
stop) stop)
echo "[INFO] compose file: ${COMPOSE_FILE}" echo "[信息] 编排文件: ${COMPOSE_FILE}"
compose down compose_quiet down
echo "[OK] stack stopped" echo "[完成] 服务栈已停止"
;; ;;
status|ps) status|ps)
echo "[INFO] compose file: ${COMPOSE_FILE}" echo "[信息] 编排文件: ${COMPOSE_FILE}"
compose ps compose ps
;; ;;
logs) logs)
echo "[INFO] compose file: ${COMPOSE_FILE}" echo "[信息] 编排文件: ${COMPOSE_FILE}"
compose logs --tail=200 "${2:-}" compose logs --tail=200 "${2:-}"
;; ;;
health) health)
backend_health && echo "[OK] backend healthz" backend_health && echo "[完成] 后端健康检查通过(/healthz)"
db_health && echo "[OK] db pg_isready" db_health && echo "[完成] 数据库健康检查通过(pg_isready)"
redis_health && echo "[OK] redis ping" redis_health && echo "[完成] Redis 健康检查通过(PING)"
;; ;;
dev-start) dev-start)
COMPOSE_FILE="docker-compose.dev.yml" COMPOSE_FILE="docker-compose.dev.yml"
echo "[INFO] compose file: ${COMPOSE_FILE}" echo "[信息] 编排文件: ${COMPOSE_FILE}"
compose up -d compose_quiet up -d
echo "[OK] dev stack started" echo "[完成] 开发环境服务栈已启动"
;; ;;
dev-restart) dev-restart)
COMPOSE_FILE="docker-compose.dev.yml" COMPOSE_FILE="docker-compose.dev.yml"
echo "[INFO] compose file: ${COMPOSE_FILE}" echo "[信息] 编排文件: ${COMPOSE_FILE}"
compose down compose_quiet down
compose up -d compose_quiet up -d
echo "[OK] dev stack restarted" echo "[完成] 开发环境服务栈已重启"
;; ;;
dev-stop) dev-stop)
COMPOSE_FILE="docker-compose.dev.yml" COMPOSE_FILE="docker-compose.dev.yml"
echo "[INFO] compose file: ${COMPOSE_FILE}" echo "[信息] 编排文件: ${COMPOSE_FILE}"
compose down compose_quiet down
echo "[OK] dev stack stopped" echo "[完成] 开发环境服务栈已停止"
;; ;;
dev-status|dev-ps) dev-status|dev-ps)
COMPOSE_FILE="docker-compose.dev.yml" COMPOSE_FILE="docker-compose.dev.yml"
echo "[INFO] compose file: ${COMPOSE_FILE}" echo "[信息] 编排文件: ${COMPOSE_FILE}"
compose ps compose ps
;; ;;
dev-logs) dev-logs)
COMPOSE_FILE="docker-compose.dev.yml" COMPOSE_FILE="docker-compose.dev.yml"
echo "[INFO] compose file: ${COMPOSE_FILE}" echo "[信息] 编排文件: ${COMPOSE_FILE}"
compose logs --tail=200 "${2:-}" compose logs --tail=200 "${2:-}"
;; ;;
dev-health) dev-health)
COMPOSE_FILE="docker-compose.dev.yml" COMPOSE_FILE="docker-compose.dev.yml"
backend_health && echo "[OK] backend healthz" backend_health && echo "[完成] 后端健康检查通过(/healthz)"
db_health && echo "[OK] db pg_isready" db_health && echo "[完成] 数据库健康检查通过(pg_isready)"
redis_health && echo "[OK] redis ping" 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 exit 1
;; ;;
esac 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() { stop_legacy_conflicts() {
local pid cmd local pid cmd
if [[ "${PMFLOW_KEEP_LEGACY:-0}" == "1" ]]; then if [[ "${PMFLOW_KEEP_LEGACY:-0}" == "1" ]]; then
echo "[PMFlow] skip legacy conflict cleanup (PMFLOW_KEEP_LEGACY=1)" echo "[PMFlow] 跳过旧链路冲突清理(PMFLOW_KEEP_LEGACY=1)"
return return
fi fi
@ -18,7 +18,7 @@ stop_legacy_conflicts() {
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)" cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
if [[ "$cmd" == *"uvicorn app.main:app"* ]]; then if [[ "$cmd" == *"uvicorn app.main:app"* ]]; then
kill "$pid" 2>/dev/null || true kill "$pid" 2>/dev/null || true
echo "[PMFlow] stopped legacy backend pid=$pid" echo "[PMFlow] 已停止旧链路后端 pid=$pid"
fi fi
done < <(lsof -tiTCP:8000 -sTCP:LISTEN 2>/dev/null || true) 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)" 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 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 kill "$pid" 2>/dev/null || true
echo "[PMFlow] stopped legacy frontend pid=$pid" echo "[PMFlow] 已停止旧链路前端 pid=$pid"
fi fi
done < <(lsof -tiTCP:5174 -sTCP:LISTEN 2>/dev/null || true) done < <(lsof -tiTCP:5174 -sTCP:LISTEN 2>/dev/null || true)
} }
@ -160,11 +160,11 @@ start_or_reuse_tunnel() {
print_temp_public_urls() { print_temp_public_urls() {
if [[ "${PMFLOW_NO_TUNNEL:-0}" == "1" ]]; then if [[ "${PMFLOW_NO_TUNNEL:-0}" == "1" ]]; then
echo "[PMFlow] skip temporary tunnel (PMFLOW_NO_TUNNEL=1)" echo "[PMFlow] 跳过临时公网隧道(PMFLOW_NO_TUNNEL=1)"
return return
fi fi
if ! command -v cloudflared >/dev/null 2>&1; then if ! command -v cloudflared >/dev/null 2>&1; then
echo "[PMFlow] cloudflared not found, skip temporary public urls" echo "[PMFlow] 未检测到 cloudflared,跳过临时公网地址生成"
return return
fi fi
@ -173,9 +173,9 @@ print_temp_public_urls() {
local expose_backend="${PMFLOW_EXPOSE_BACKEND_TUNNEL:-0}" local expose_backend="${PMFLOW_EXPOSE_BACKEND_TUNNEL:-0}"
local protocol="${PMFLOW_TUNNEL_PROTOCOL:-http2}" local protocol="${PMFLOW_TUNNEL_PROTOCOL:-http2}"
local frontend_watch="${PMFLOW_FRONTEND_TUNNEL_WATCH:-1}" local frontend_watch="${PMFLOW_FRONTEND_TUNNEL_WATCH:-1}"
echo "[PMFlow] tunnel protocol: ${protocol}" echo "[PMFlow] 隧道协议: ${protocol}"
frontend_port="$(resolve_frontend_tunnel_port)" 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="" front_url=""
if [[ "$frontend_watch" == "1" ]]; then if [[ "$frontend_watch" == "1" ]]; then
bash scripts/tunnel_frontend_watch.sh start >/dev/null 2>&1 || true bash scripts/tunnel_frontend_watch.sh start >/dev/null 2>&1 || true
@ -200,44 +200,40 @@ print_temp_public_urls() {
: > logs/public_url.txt : > logs/public_url.txt
if [[ -n "$front_url" ]]; then if [[ -n "$front_url" ]]; then
echo "frontend=$front_url" >> logs/public_url.txt echo "frontend=$front_url" >> logs/public_url.txt
echo "[PMFlow] temp frontend: $front_url" echo "[PMFlow] 临时前端地址: $front_url"
else else
echo "[PMFlow] temp frontend: (pending, check logs/tunnel-frontend.log)" echo "[PMFlow] 临时前端地址: (等待中,请查看 logs/tunnel-frontend.log)"
fi fi
if [[ "$expose_backend" != "1" ]]; then if [[ "$expose_backend" != "1" ]]; then
echo "[PMFlow] temp backend: (skip, PMFLOW_EXPOSE_BACKEND_TUNNEL=0)" echo "[PMFlow] 临时后端地址: (已跳过,PMFLOW_EXPOSE_BACKEND_TUNNEL=0)"
else else
if [[ -n "$back_url" ]]; then if [[ -n "$back_url" ]]; then
echo "backend=$back_url" >> logs/public_url.txt echo "backend=$back_url" >> logs/public_url.txt
echo "[PMFlow] temp backend: $back_url" echo "[PMFlow] 临时后端地址: $back_url"
else else
echo "[PMFlow] temp backend: (pending, check logs/tunnel-backend.log)" echo "[PMFlow] 临时后端地址: (等待中,请查看 logs/tunnel-backend.log)"
fi fi
fi fi
echo "[PMFlow] public urls saved: logs/public_url.txt" echo "[PMFlow] 公网地址已写入: logs/public_url.txt"
} }
case "$MODE" in case "$MODE" in
dev|"") dev|"")
echo "[PMFlow] start mode: docker-dev hot reload" echo "[PMFlow] 启动模式: 开发环境(Docker 热重载)"
stop_legacy_conflicts stop_legacy_conflicts
bash scripts/docker_stack.sh dev-start bash scripts/docker_stack.sh dev-start
echo "[PMFlow] frontend: http://127.0.0.1:5174" echo "[PMFlow] 前端地址: http://127.0.0.1:5174"
echo "[PMFlow] backend: http://127.0.0.1:8000" echo "[PMFlow] 后端地址: http://127.0.0.1:8000"
print_temp_public_urls print_temp_public_urls
;; ;;
prod) prod)
echo "[PMFlow] start mode: docker prod compose" echo "[PMFlow] 启动模式: 生产环境(Docker Compose)"
stop_legacy_conflicts stop_legacy_conflicts
bash scripts/docker_stack.sh start bash scripts/docker_stack.sh start
echo "[PMFlow] frontend: http://127.0.0.1:5174" echo "[PMFlow] 前端地址: http://127.0.0.1:5174"
echo "[PMFlow] backend: http://127.0.0.1:8000" echo "[PMFlow] 后端地址: http://127.0.0.1:8000"
print_temp_public_urls print_temp_public_urls
;; ;;
legacy)
echo "[PMFlow] start mode: legacy local (venv + npm + tunnel)"
bash scripts/start_legacy_local.sh
;;
urls) urls)
if [[ -s logs/public_url.txt ]]; then if [[ -s logs/public_url.txt ]]; then
local_front_url="$(rg '^frontend=' logs/public_url.txt | tail -n 1 | sed 's/^frontend=//' || true)" 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_front_url:-未就绪}"
echo "后端: ${local_back_url:-未暴露}" echo "后端: ${local_back_url:-未暴露}"
else else
echo "[PMFlow] no public url record found (logs/public_url.txt)" echo "[PMFlow] 未找到公网地址记录(logs/public_url.txt)"
exit 1 exit 1
fi fi
;; ;;
*) *)
echo "Usage: $0 [dev|prod|legacy|urls]" echo "用法: $0 [dev|prod|urls]"
exit 1 exit 1
;; ;;
esac esac

View File

@ -15,12 +15,8 @@ case "$MODE" in
echo "[PMFlow] stop mode: docker prod compose" echo "[PMFlow] stop mode: docker prod compose"
bash scripts/docker_stack.sh stop 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 exit 1
;; ;;
esac esac