Add 模型连通检测启动器}0987654321
This commit is contained in:
parent
a3a4ce61ff
commit
fea6328ab2
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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."
|
|
||||||
echo " If not, you can use Quick Tunnels (random URLs)."
|
|
||||||
echo ""
|
|
||||||
read -p "Do you have a domain and want to set up a named tunnel? (y/N): " SETUP_CHOICE
|
|
||||||
|
|
||||||
if [[ "$SETUP_CHOICE" =~ ^[Yy]$ ]]; then
|
extract_url() {
|
||||||
if [ -f "$SETUP_SCRIPT" ]; then
|
local logfile="$1"
|
||||||
# Run setup script and check its exit code
|
grep -aEo 'https://[-a-z0-9]+\.trycloudflare\.com' "$logfile" 2>/dev/null | tail -1
|
||||||
if ! bash "$SETUP_SCRIPT"; then
|
}
|
||||||
echo "❌ Setup failed or was cancelled."
|
|
||||||
exit 1
|
wait_for_url() {
|
||||||
fi
|
local logfile="$1"
|
||||||
else
|
local retries="${2:-30}"
|
||||||
echo "❌ Setup script not found at $SETUP_SCRIPT"
|
local i=1
|
||||||
exit 1
|
local url=""
|
||||||
fi
|
while [ "$i" -le "$retries" ]; do
|
||||||
else
|
url="$(extract_url "$logfile")"
|
||||||
echo "🔄 Falling back to Quick Tunnels..."
|
if [ -n "$url" ]; then
|
||||||
bash devops/tunnel/start-quick.sh
|
echo "$url"
|
||||||
exit 0
|
return 0
|
||||||
fi
|
fi
|
||||||
fi
|
sleep 1
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# Re-check config after potential setup
|
check_url() {
|
||||||
|
local url="$1"
|
||||||
|
local max_retry="$2"
|
||||||
|
local retry=1
|
||||||
|
while [ "$retry" -le "$max_retry" ]; do
|
||||||
|
if curl -kfsS --max-time 8 "$url" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
retry=$((retry + 1))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_quick_tunnels() {
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
pkill -f "cloudflared tunnel .*127\\.0\\.0\\.1:${FRONTEND_PORT}" >/dev/null 2>&1 || true
|
||||||
|
pkill -f "cloudflared tunnel .*127\\.0\\.0\\.1:${BACKEND_PORT}" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
echo "🚀 启动临时隧道..."
|
||||||
|
echo "📂 日志目录: ${LOG_DIR}"
|
||||||
|
echo "🌐 前端隧道: 127.0.0.1:${FRONTEND_PORT}"
|
||||||
|
cloudflared tunnel --protocol "${TUNNEL_PROTOCOL}" --url "http://127.0.0.1:${FRONTEND_PORT}" >"$FRONT_LOG" 2>&1 &
|
||||||
|
local front_pid=$!
|
||||||
|
|
||||||
|
echo "🌐 后端隧道: 127.0.0.1:${BACKEND_PORT}"
|
||||||
|
cloudflared tunnel --protocol "${TUNNEL_PROTOCOL}" --url "http://127.0.0.1:${BACKEND_PORT}" >"$BACK_LOG" 2>&1 &
|
||||||
|
local back_pid=$!
|
||||||
|
|
||||||
|
echo "⏳ 等待隧道地址..."
|
||||||
|
local front_url=""
|
||||||
|
local back_url=""
|
||||||
|
front_url="$(wait_for_url "$FRONT_LOG" 45 || true)"
|
||||||
|
back_url="$(wait_for_url "$BACK_LOG" 45 || true)"
|
||||||
|
|
||||||
|
if [ -z "$front_url" ] || [ -z "$back_url" ]; then
|
||||||
|
echo "❌ 未获取到公网地址。"
|
||||||
|
echo " 前端日志: $FRONT_LOG"
|
||||||
|
echo " 后端日志: $BACK_LOG"
|
||||||
|
tail -n 20 "$FRONT_LOG" || true
|
||||||
|
tail -n 20 "$BACK_LOG" || true
|
||||||
|
kill "$front_pid" "$back_pid" >/dev/null 2>&1 || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local front_ok=false
|
||||||
|
local back_ok=false
|
||||||
|
if check_url "${front_url}/login" "$HEALTH_RETRIES" || check_url "${front_url}" "$HEALTH_RETRIES"; then
|
||||||
|
front_ok=true
|
||||||
|
fi
|
||||||
|
if check_url "${back_url}/healthz" "$HEALTH_RETRIES"; then
|
||||||
|
back_ok=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$front_ok" = true ] && [ "$back_ok" = true ]; then
|
||||||
|
echo "✅ 隧道连通成功"
|
||||||
|
else
|
||||||
|
echo "⚠️ 隧道已启动,但健康检查未全部通过(可稍后重试)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "----------------------------------------"
|
||||||
|
echo "前端: ${front_url}"
|
||||||
|
echo "后端: ${back_url}"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
echo "ℹ️ 停止命令: pkill -f \"cloudflared tunnel\""
|
||||||
|
|
||||||
|
if [ "$FOREGROUND" != "1" ]; then
|
||||||
|
echo "✅ 已切到后台运行"
|
||||||
|
echo " 前端 PID: ${front_pid}"
|
||||||
|
echo " 后端 PID: ${back_pid}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo ""
|
||||||
|
echo "🔴 tunnel 已关闭"
|
||||||
|
kill "$front_pid" "$back_pid" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
wait "$front_pid" "$back_pid"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "🚀 启动公网穿透..."
|
||||||
if [ -f "$CONFIG_FILE" ]; then
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
|
||||||
<div className="fixed bottom-4 right-2 z-40 sm:right-4">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
className="relative w-[min(220px,calc(100vw-1rem))] justify-center"
|
|
||||||
onClick={() => setMinimized(false)}
|
|
||||||
title="打开预警中心"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="mr-2 h-4 w-4" />
|
|
||||||
预警中心
|
|
||||||
{total > 0 && (
|
|
||||||
<span className="absolute -right-2 -top-2 rounded-full bg-red-600 px-1.5 text-[10px] text-white">
|
|
||||||
{total}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 right-2 z-40 w-[min(320px,calc(100vw-1rem))] rounded-lg border bg-card shadow-xl sm:right-4">
|
<div className="w-[min(360px,calc(100vw-1rem))] rounded-lg bg-card">
|
||||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||||
|
|
@ -525,14 +595,14 @@ export default function ProjectBoardPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
{[
|
{[
|
||||||
{ 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>
|
||||||
<div className="mt-2 text-2xl font-bold text-slate-900">{metric.value}</div>
|
<div className="mt-2 text-2xl font-bold text-slate-900">{metric.value}</div>
|
||||||
</div>
|
</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">
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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] 构建与类型检查结果
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
@ -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}"
|
|
||||||
46
start.sh
46
start.sh
|
|
@ -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
|
||||||
|
|
|
||||||
6
stop.sh
6
stop.sh
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue