Compare commits

...

10 Commits

Author SHA1 Message Date
Yoilun
e08768d8fd fix: restore project handoff records 2026-05-26 12:00:31 +08:00
Yoilun
cb46d5bc04 feat: add workflow batch switcher 2026-05-26 11:48:47 +08:00
Yoilun
0fd7b17aba feat: group project workflow by batch 2026-05-26 11:38:00 +08:00
Yoilun
573d36bb60 fix: merge project workflow into project view 2026-05-26 11:16:12 +08:00
Yoilun
dd834378af fix: stretch main work areas responsively 2026-05-26 10:45:02 +08:00
Yoilun
ee0af20e2c fix: restore project goals and flow records 2026-05-26 00:02:22 +08:00
Yoilun
fcfa824f54 fix: exclude conversation threads from agents 2026-05-25 23:48:17 +08:00
Yoilun
b6648f384d fix: show real project runtime agents 2026-05-25 23:40:52 +08:00
Yoilun
4262191462 feat: add docker deployment 2026-05-25 22:41:12 +08:00
Yoilun
8c1093a53c docs: record browser verification 2026-05-25 22:03:21 +08:00
24 changed files with 2025 additions and 95 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.DS_Store
.superpowers
tmp
*.log
*.bak.*
web/node_modules
web/dist
dist

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS web-build
WORKDIR /src/web
RUN corepack enable && corepack prepare pnpm@10.30.3 --activate
COPY web/package.json web/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY web/ ./
RUN pnpm build
FROM golang:1.24-alpine AS go-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd/ ./cmd/
COPY internal/ ./internal/
ARG TARGETOS=linux
ARG TARGETARCH
RUN if [ -n "$TARGETARCH" ]; then export GOARCH=$TARGETARCH; fi; \
CGO_ENABLED=0 GOOS=$TARGETOS go build -o /out/codex-agent-manager ./cmd/codex-agent-manager
FROM alpine:3.22
WORKDIR /app
COPY --from=go-build /out/codex-agent-manager /usr/local/bin/codex-agent-manager
COPY --from=web-build /src/web/dist ./web/dist
COPY README.md agent.md task_plan.md findings.md progress.md ./
COPY docs/ ./docs/
ENV CODEX_HOME=/codex-home
ENV HTTP_ADDR=0.0.0.0:18083
ENV STATIC_DIR=/app/web/dist
ENV WORKSPACE_ROOT=/app
EXPOSE 18083
ENTRYPOINT ["codex-agent-manager"]

View File

@@ -38,6 +38,55 @@ pnpm dev
打开 `http://127.0.0.1:13083/` 查看工作台。
## Docker 启动
构建镜像:
```bash
docker build -t codex-agent-manager:local .
```
启动容器,并把本机 Codex 配置和当前项目目录挂载进去:
```bash
docker run --rm \
--name codex-agent-manager \
-p 0.0.0.0:18083:18083 \
-e CODEX_HOME=/codex-home \
-e WORKSPACE_ROOT=/Users/yoilun/Code/codex-agent-manager \
-v /Users/yoilun/.codex:/codex-home \
-v /Users/yoilun/Code/codex-agent-manager:/Users/yoilun/Code/codex-agent-manager:ro \
codex-agent-manager:local
```
然后打开 `http://127.0.0.1:18083/`。Docker 镜像内由 Go 后端同时提供 API 和前端静态页面,不需要再单独启动 Vite。
如果要让局域网内其他设备访问,保持上面的 `0.0.0.0:18083:18083` 端口映射,然后在同一局域网的设备上打开:
```text
http://你的电脑局域网IP:18083/
```
在 macOS 上可用下面命令查看当前 Wi-Fi/LAN IP
```bash
ipconfig getifaddr en0
```
也可以使用 Compose
```bash
docker compose up --build
```
如需改挂载路径,可在启动前设置本机环境变量:
```bash
CODEX_HOME=/path/to/.codex \
CODEX_AGENT_MANAGER_PROJECT=/path/to/codex-agent-manager \
docker compose up --build
```
## 验证命令
后端和整体检查:
@@ -56,7 +105,7 @@ pnpm test
pnpm build
```
如果需要浏览器视觉验证,由主智能体或可用浏览器工具访问 `http://127.0.0.1:13083/` 后记录截图结果;不要伪造截图记录
浏览器视觉验证已在 2026-05-25 使用本机 Chrome 访问 `http://127.0.0.1:13083/` 完成,覆盖项目视图、工作流视图、智能体视图、草稿和设置页。内置浏览器插件若返回 `Browser is not available: iab`,可改用 Chrome 做只读页面核验,并在 `progress.md` 记录 fallback
## API 概览

View File

@@ -79,8 +79,7 @@
- 视觉方向是“温和的本地工作台 + 编辑器质感”,不要做成工业监控大屏。
- 不要使用大面积黑底荧光绿、密集硬边框、无解释的红绿灯状态或全页面等宽字体。
- 页面必须清楚区分:
- 项目视图:项目里的智能体执行情况。
- 工作流视图:阶段进度、交接、审查循环、主智能体监管。
- 项目视图:项目里的智能体执行情况、工作流批次、阶段进度、交接、审查循环、主智能体监管
- 智能体视图:名称、描述、角色设定编辑。
- 草稿TOML 校验、diff、备份、写回。
- 设置Codex 路径和数据源配置。
@@ -133,6 +132,7 @@
## 工作流显示规则
- 工作流必须建模为动态事件流或有向图,不允许写死固定流程。
- 项目内工作流必须先按“工作流批次”分组,再按阶段分组;同一项目的 `v1.0 初始搭建``v1.1 优化修复``v2.0 重构升级` 必须清楚区分。
- `thread_spawn_edges` 是派发关系的主要结构化来源。
- `threads` 表用于补充智能体昵称、角色、项目路径和更新时间。
- `thread_goals` 用于补充目标状态。
@@ -140,6 +140,28 @@
- 没有计划文件时,只显示通用事件和推断,不强行命名阶段。
- 所有推断必须标注来源和置信度。
### 子智能体派发元数据
主智能体派发任何子智能体任务时,必须在任务标题、目标或首段说明中保留以下元数据,方便本项目页面从 Codex 运行记录中准确归类:
```text
[项目: /Users/yoilun/Code/codex-agent-manager]
[工作流批次: v1.1 优化修复]
[阶段: 优化阶段 1]
[角色: 代码审查员]
```
本项目当前已完成初始搭建后进入 `v1.1 优化修复`,当前优化改造归入 `优化阶段 1`。后续如果开启 `codex-agent-manager v2.0`,必须显式写成类似:
```text
[项目: /Users/yoilun/Code/codex-agent-manager]
[工作流批次: v2.0 重构升级]
[阶段: 阶段 1 产品规划]
[角色: 产品经理]
```
如果继续派发下一级子智能体,必须把同一组元数据继续传递给下一级任务。`项目` 必须写完整项目路径,不要只写项目名;不要只写“继续优化”或“修一下页面”,否则页面只能低置信度推断批次和阶段。
## 状态规则
运行状态至少包含:

14
compose.yaml Normal file
View File

@@ -0,0 +1,14 @@
services:
codex-agent-manager:
build: .
image: codex-agent-manager:local
ports:
- "0.0.0.0:18083:18083"
environment:
CODEX_HOME: /codex-home
HTTP_ADDR: 0.0.0.0:18083
STATIC_DIR: /app/web/dist
WORKSPACE_ROOT: /Users/yoilun/Code/codex-agent-manager
volumes:
- ${CODEX_HOME:-/Users/yoilun/.codex}:/codex-home
- ${CODEX_AGENT_MANAGER_PROJECT:-/Users/yoilun/Code/codex-agent-manager}:/Users/yoilun/Code/codex-agent-manager:ro

View File

@@ -87,7 +87,7 @@ cd web
pnpm dev
```
前端开发地址为 `http://127.0.0.1:13083/`。前端按视图调用 API项目视图读取 `/api/projects``/api/runtime/threads`,工作流视图读取 `/api/workflow/events`,智能体视图读取 `/api/agents`,并仅对当前选中的智能体调用 `/api/agents/{id}/validate``/api/agents/{id}/write`。静态示例数据仅在接口连接失败时作为 fallback 展示,并必须明确标注“示例/等待连接”;真实接口返回空列表时展示空状态和来源证据,不回退到示例数据。前端可保留 `local_sample``api_missing` 等内部 source kind但用户界面必须显示中文来源和 `高 / 中 / 低` 置信度。
前端开发地址为 `http://127.0.0.1:13083/`。前端按视图调用 API项目视图读取 `/api/projects``/api/runtime/threads`,工作流视图读取 `/api/workflow/events`,智能体视图读取 `/api/agents`,并仅对当前选中的智能体调用 `/api/agents/{id}/validate``/api/agents/{id}/write`。静态示例数据仅在接口连接失败时作为 fallback 展示,并必须明确标注“示例/等待连接”;真实接口返回空列表时展示空状态和来源证据,不回退到示例数据。前端可保留 `local_sample``api_missing` 等内部 source kind但用户界面必须显示中文来源和 `高 / 中 / 低` 置信度。2026-05-25 已通过 Chrome 验证项目视图、工作流视图、智能体视图、草稿和设置页;内置浏览器插件返回 `Browser is not available: iab`,视觉核验使用本机 Chrome 完成。
## API Overview
@@ -125,6 +125,6 @@ pnpm dev
- Codex 内部 SQLite schema 可能变化。
- 运行状态由多来源推断,必须显示置信度。
- 真实 SQLite 读取已覆盖临时测试库;如果真实 Codex schema 新增字段或缺少可选字段,应继续走 schema-aware 查询和来源证据,而不是让 API 500。
- Agent 列表接口返回原始 TOML 文本;智能体编辑区展示的是接口返回的已解析字段,并在代码区说明“接口未返回原始 TOML”
- 浏览器视觉验证需要可用浏览器工具或主智能体执行;文档记录不得伪造截图结果
- Agent 列表接口当前返回原始 TOML 文本供草稿编辑;前端仍必须只在“校验 TOML -> 查看差异 -> 创建备份并写回”的显式流程中写回
- 内置浏览器插件可能不可用;若返回 `Browser is not available: iab`,可使用本机 Chrome 做只读页面核验,并在进度记录中注明 fallback
- 写回只覆盖单个已有 agent TOML不支持创建新 agent、批量写回或自动保存草稿。

View File

@@ -9,14 +9,19 @@ type Config struct {
CodexHome string
HTTPAddr string
WorkspaceRoot string
StaticDir string
}
func DefaultConfig() Config {
httpAddr := envOrDefault("HTTP_ADDR", "127.0.0.1:18083")
staticDir := os.Getenv("STATIC_DIR")
workspaceRoot := envOrDefault("WORKSPACE_ROOT", ".")
if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" {
return Config{
CodexHome: codexHome,
HTTPAddr: "127.0.0.1:18083",
WorkspaceRoot: ".",
HTTPAddr: httpAddr,
WorkspaceRoot: workspaceRoot,
StaticDir: staticDir,
}
}
home, err := os.UserHomeDir()
@@ -25,7 +30,15 @@ func DefaultConfig() Config {
}
return Config{
CodexHome: filepath.Join(home, ".codex"),
HTTPAddr: "127.0.0.1:18083",
WorkspaceRoot: ".",
HTTPAddr: httpAddr,
WorkspaceRoot: workspaceRoot,
StaticDir: staticDir,
}
}
func envOrDefault(name string, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}

View File

@@ -17,6 +17,24 @@ func TestDefaultConfigUsesCODEXHomeOverride(t *testing.T) {
}
}
func TestDefaultConfigUsesDockerRuntimeOverrides(t *testing.T) {
t.Setenv("HTTP_ADDR", "0.0.0.0:18083")
t.Setenv("STATIC_DIR", "/app/web/dist")
t.Setenv("WORKSPACE_ROOT", "/app")
cfg := DefaultConfig()
if cfg.HTTPAddr != "0.0.0.0:18083" {
t.Fatalf("HTTPAddr = %q, want %q", cfg.HTTPAddr, "0.0.0.0:18083")
}
if cfg.StaticDir != "/app/web/dist" {
t.Fatalf("StaticDir = %q, want %q", cfg.StaticDir, "/app/web/dist")
}
if cfg.WorkspaceRoot != "/app" {
t.Fatalf("WorkspaceRoot = %q, want %q", cfg.WorkspaceRoot, "/app")
}
}
func TestDefaultConfigFallsBackToUserCodexHome(t *testing.T) {
t.Setenv("CODEX_HOME", "")
home, err := os.UserHomeDir()

View File

@@ -11,12 +11,19 @@ type Snapshot struct {
type SourceMap map[string]SourceEvidence
type Thread struct {
ID string `json:"id"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Source SourceEvidence `json:"source"`
ID string `json:"id"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
CWD string `json:"cwd"`
Title string `json:"title"`
AgentNickname string `json:"agentNickname,omitempty"`
AgentRole string `json:"agentRole,omitempty"`
AgentPath string `json:"agentPath,omitempty"`
ThreadSource string `json:"threadSource,omitempty"`
Preview string `json:"preview,omitempty"`
Source SourceEvidence `json:"source"`
}
type SpawnEdge struct {

View File

@@ -121,7 +121,20 @@ func readThreads(db *sql.DB, sourcePath string, sources SourceMap) ([]Thread, er
sources["threads"] = tableSource("sqlite_schema_drift", sourcePath, "threads table is missing required id column.")
return []Thread{}, nil
}
query := `SELECT ` + textColumn(columns, "id") + `, ` + textColumn(columns, "role") + `, ` + textColumn(columns, "status") + `, ` + textColumn(columns, "created_at") + `, ` + textColumn(columns, "updated_at") + ` FROM threads ORDER BY ` + orderBy(columns, "created_at", "id")
query := `SELECT ` +
textColumn(columns, "id") + `, ` +
firstTextColumn(columns, "agent_role", "role") + `, ` +
textColumn(columns, "status") + `, ` +
firstTextColumn(columns, "created_at_ms", "created_at") + `, ` +
firstTextColumn(columns, "updated_at_ms", "updated_at") + `, ` +
textColumn(columns, "cwd") + `, ` +
textColumn(columns, "title") + `, ` +
textColumn(columns, "agent_nickname") + `, ` +
textColumn(columns, "agent_role") + `, ` +
textColumn(columns, "agent_path") + `, ` +
textColumn(columns, "thread_source") + `, ` +
textColumn(columns, "preview") +
` FROM threads ORDER BY ` + orderBy(columns, "created_at_ms", "created_at", "id")
rows, err := db.Query(query)
if err != nil {
if isMissingTable(err) {
@@ -133,7 +146,20 @@ func readThreads(db *sql.DB, sourcePath string, sources SourceMap) ([]Thread, er
var threads []Thread
for rows.Next() {
var item Thread
if err := rows.Scan(&item.ID, &item.Role, &item.Status, &item.CreatedAt, &item.UpdatedAt); err != nil {
if err := rows.Scan(
&item.ID,
&item.Role,
&item.Status,
&item.CreatedAt,
&item.UpdatedAt,
&item.CWD,
&item.Title,
&item.AgentNickname,
&item.AgentRole,
&item.AgentPath,
&item.ThreadSource,
&item.Preview,
); err != nil {
return nil, err
}
item.Source = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"}
@@ -152,11 +178,16 @@ func readSpawnEdges(db *sql.DB, sourcePath string, sources SourceMap) ([]SpawnEd
sources["thread_spawn_edges"] = tableSource("sqlite_missing_table", sourcePath, "thread_spawn_edges table was not found.")
return []SpawnEdge{}, nil
}
if !columns["from_thread_id"] || !columns["to_thread_id"] {
if !(columns["from_thread_id"] && columns["to_thread_id"]) && !(columns["parent_thread_id"] && columns["child_thread_id"]) {
sources["thread_spawn_edges"] = tableSource("sqlite_schema_drift", sourcePath, "thread_spawn_edges table is missing required endpoint columns.")
return []SpawnEdge{}, nil
}
query := `SELECT ` + textColumn(columns, "from_thread_id") + `, ` + textColumn(columns, "to_thread_id") + `, ` + textColumn(columns, "reason") + `, ` + textColumn(columns, "created_at") + ` FROM thread_spawn_edges ORDER BY ` + orderBy(columns, "created_at", "from_thread_id", "to_thread_id")
query := `SELECT ` +
firstTextColumn(columns, "from_thread_id", "parent_thread_id") + `, ` +
firstTextColumn(columns, "to_thread_id", "child_thread_id") + `, ` +
firstTextColumn(columns, "reason", "status") + `, ` +
textColumn(columns, "created_at") +
` FROM thread_spawn_edges ORDER BY ` + orderBy(columns, "created_at", "from_thread_id", "parent_thread_id", "to_thread_id", "child_thread_id")
rows, err := db.Query(query)
if err != nil {
if isMissingTable(err) {
@@ -191,7 +222,12 @@ func readGoals(db *sql.DB, sourcePath string, sources SourceMap) ([]Goal, error)
sources["thread_goals"] = tableSource("sqlite_schema_drift", sourcePath, "thread_goals table is missing required thread_id column.")
return []Goal{}, nil
}
query := `SELECT ` + textColumn(columns, "thread_id") + `, ` + textColumn(columns, "goal") + `, ` + textColumn(columns, "status") + `, ` + textColumn(columns, "updated_at") + ` FROM thread_goals ORDER BY ` + orderBy(columns, "updated_at", "thread_id")
query := `SELECT ` +
textColumn(columns, "thread_id") + `, ` +
firstTextColumn(columns, "goal", "objective") + `, ` +
textColumn(columns, "status") + `, ` +
firstTextColumn(columns, "updated_at_ms", "updated_at") +
` FROM thread_goals ORDER BY ` + orderBy(columns, "updated_at_ms", "updated_at", "thread_id")
rows, err := db.Query(query)
if err != nil {
if isMissingTable(err) {
@@ -244,6 +280,15 @@ func textColumn(columns map[string]bool, name string) string {
return "COALESCE(CAST(" + name + " AS TEXT), '')"
}
func firstTextColumn(columns map[string]bool, names ...string) string {
for _, name := range names {
if columns[name] {
return textColumn(columns, name)
}
}
return "''"
}
func orderBy(columns map[string]bool, names ...string) string {
var order []string
for _, name := range names {

View File

@@ -66,6 +66,100 @@ func TestStoreReadsThreadsEdgesAndGoalsFromReadonlySQLite(t *testing.T) {
}
}
func TestStoreReadsCurrentCodexRuntimeSchema(t *testing.T) {
root := t.TempDir()
stateDB := openWritableSQLite(t, filepath.Join(root, "state_5.sqlite"))
defer stateDB.Close()
execSQL(t, stateDB, `CREATE TABLE threads (
id TEXT PRIMARY KEY,
rollout_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
source TEXT NOT NULL,
model_provider TEXT NOT NULL,
cwd TEXT NOT NULL,
title TEXT NOT NULL,
sandbox_policy TEXT NOT NULL,
approval_mode TEXT NOT NULL,
tokens_used INTEGER NOT NULL DEFAULT 0,
has_user_event INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
archived_at INTEGER,
git_sha TEXT,
git_branch TEXT,
git_origin_url TEXT,
cli_version TEXT NOT NULL DEFAULT '',
first_user_message TEXT NOT NULL DEFAULT '',
agent_nickname TEXT,
agent_role TEXT,
memory_mode TEXT NOT NULL DEFAULT 'enabled',
model TEXT,
reasoning_effort TEXT,
agent_path TEXT,
created_at_ms INTEGER,
updated_at_ms INTEGER,
thread_source TEXT,
preview TEXT NOT NULL DEFAULT ''
)`)
execSQL(t, stateDB, `CREATE TABLE thread_spawn_edges (
parent_thread_id TEXT NOT NULL,
child_thread_id TEXT NOT NULL PRIMARY KEY,
status TEXT NOT NULL
)`)
execSQL(t, stateDB, `INSERT INTO threads (
id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode,
cli_version, agent_nickname, agent_role, agent_path, created_at_ms, updated_at_ms, thread_source, preview
) VALUES
('thread-a', '/tmp/a.jsonl', 1, 2, 'codex', 'openai', '/repo/a', '主线程', 'workspace-write', 'on-request',
'0.0.1', '主控', '智能体编排者', '/agents/orchestrator.toml', 1000, 2000, 'cli', '监管项目流程'),
('thread-b', '/tmp/b.jsonl', 3, 4, 'codex', 'openai', '/repo/b', '审查线程', 'workspace-write', 'on-request',
'0.0.1', '审查员', '代码审查员', '/agents/reviewer.toml', 3000, 4000, 'cli', '审查实现')`)
execSQL(t, stateDB, `INSERT INTO thread_spawn_edges (parent_thread_id, child_thread_id, status) VALUES
('thread-a', 'thread-b', 'spawned')`)
goalsDB := openWritableSQLite(t, filepath.Join(root, "goals_1.sqlite"))
defer goalsDB.Close()
execSQL(t, goalsDB, `CREATE TABLE thread_goals (
thread_id TEXT PRIMARY KEY NOT NULL,
goal_id TEXT NOT NULL,
objective TEXT NOT NULL,
status TEXT NOT NULL,
token_budget INTEGER,
tokens_used INTEGER NOT NULL DEFAULT 0,
time_used_seconds INTEGER NOT NULL DEFAULT 0,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
)`)
execSQL(t, goalsDB, `INSERT INTO thread_goals (
thread_id, goal_id, objective, status, token_budget, tokens_used, time_used_seconds, created_at_ms, updated_at_ms
) VALUES
('thread-a', 'goal-a', '管理当前项目的智能体流程', 'active', 10000, 1200, 60, 1000, 2000)`)
snapshot, err := Store{CodexHome: root}.Snapshot()
if err != nil {
t.Fatalf("Snapshot returned error: %v", err)
}
if len(snapshot.Threads) != 2 {
t.Fatalf("threads = %#v", snapshot.Threads)
}
first := snapshot.Threads[0]
if first.ID != "thread-a" || first.CWD != "/repo/a" || first.Title != "主线程" {
t.Fatalf("unexpected first thread identity: %#v", first)
}
if first.AgentNickname != "主控" || first.AgentRole != "智能体编排者" || first.Role != "智能体编排者" {
t.Fatalf("unexpected first thread agent fields: %#v", first)
}
if first.AgentPath != "/agents/orchestrator.toml" || first.Preview != "监管项目流程" || first.CreatedAt != "1000" || first.UpdatedAt != "2000" {
t.Fatalf("unexpected first thread metadata: %#v", first)
}
if len(snapshot.SpawnEdges) != 1 || snapshot.SpawnEdges[0].FromThreadID != "thread-a" || snapshot.SpawnEdges[0].ToThreadID != "thread-b" || snapshot.SpawnEdges[0].Reason != "spawned" {
t.Fatalf("unexpected current-schema edges: %#v", snapshot.SpawnEdges)
}
if len(snapshot.Goals) != 1 || snapshot.Goals[0].ThreadID != "thread-a" || snapshot.Goals[0].Goal != "管理当前项目的智能体流程" || snapshot.Goals[0].Status != "active" || snapshot.Goals[0].UpdatedAt != "2000" {
t.Fatalf("unexpected current-schema goals: %#v", snapshot.Goals)
}
}
func TestStoreMarksStateMissingWhenOnlyGoalsSQLiteExists(t *testing.T) {
root := t.TempDir()
createRuntimeSQLite(t, root)

View File

@@ -6,6 +6,8 @@ import (
"io"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"codex-agent-manager/internal/agents"
@@ -110,6 +112,10 @@ func New(cfg app.Config) http.Handler {
"source": view.Source,
})
})
mux.HandleFunc("/api/", http.NotFound)
if cfg.StaticDir != "" {
mux.HandleFunc("/", staticFrontendHandler(cfg.StaticDir))
}
return mux
}
@@ -211,3 +217,25 @@ func writeJSON(w http.ResponseWriter, status int, body any) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func staticFrontendHandler(staticDir string) http.HandlerFunc {
fileServer := http.FileServer(http.Dir(staticDir))
indexPath := filepath.Join(staticDir, "index.html")
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
return
}
cleanPath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if cleanPath == "" {
http.ServeFile(w, r, indexPath)
return
}
target := filepath.Join(staticDir, cleanPath)
if info, err := os.Stat(target); err == nil && !info.IsDir() {
fileServer.ServeHTTP(w, r)
return
}
http.ServeFile(w, r, indexPath)
}
}

View File

@@ -202,6 +202,60 @@ func TestReadOnlyEndpointsRejectUnsupportedMethods(t *testing.T) {
}
}
func TestStaticFrontendServesIndexAndAssets(t *testing.T) {
root := t.TempDir()
staticDir := filepath.Join(root, "dist")
if err := os.MkdirAll(filepath.Join(staticDir, "assets"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte(`<html><title>管理台</title><script src="/assets/app.js"></script></html>`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staticDir, "assets", "app.js"), []byte(`console.log("ok")`), 0o644); err != nil {
t.Fatal(err)
}
handler := New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0", StaticDir: staticDir})
for _, path := range []string{"/", "/workflow"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status = %d, body = %s", path, rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "<title>管理台</title>") {
t.Fatalf("%s did not serve index.html: %s", path, rec.Body.String())
}
}
req := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `console.log("ok")`) {
t.Fatalf("asset status = %d, body = %s", rec.Code, rec.Body.String())
}
}
func TestStaticFrontendDoesNotMaskMissingAPIRoutes(t *testing.T) {
root := t.TempDir()
staticDir := filepath.Join(root, "dist")
if err := os.MkdirAll(staticDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte(`<html>index</html>`), 0o644); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/missing", nil)
rec := httptest.NewRecorder()
New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0", StaticDir: staticDir}).ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d, body = %s", rec.Code, http.StatusNotFound, rec.Body.String())
}
}
func TestAgentValidateEndpointReturnsDiffAndRejectsUnsupportedMethods(t *testing.T) {
root := t.TempDir()
agentsDir := filepath.Join(root, "agents")

View File

@@ -184,7 +184,16 @@
| 2026-05-25 | `cd web && pnpm test` | PASS | Phase 7 前端单测通过13 个测试全部通过 |
| 2026-05-25 | `cd web && pnpm build` | PASS | Phase 7 前端生产构建通过Vite 构建成功 |
| 2026-05-25 | `git diff --check` | PASS | Phase 7 whitespace 检查通过 |
| 2026-05-25 | Browser visual verification | PENDING_MAIN_AGENT | 阶段 7 不伪造浏览器截图;待主智能体执行并记录 |
| 2026-05-25 | Browser plugin visual verification | DONE_WITH_CONCERNS | 内置浏览器仍返回 `Browser is not available: iab`,改用本机 Chrome 执行同等页面核验 |
| 2026-05-25 | Chrome visual verification | PASS | `http://127.0.0.1:13083/` 项目视图、工作流视图、智能体视图、草稿和设置页均显示中文;真实项目/线程矩阵、工作流阶段与监管、智能体 TOML 草稿校验/差异/备份写回控件可见 |
| 2026-05-25 | `cd web && pnpm test` | FAIL | TDD 红灯:设置页仍显示“本阶段不会写回 / 不保存智能体 TOML”与 Phase 6 单文件写回能力冲突 |
| 2026-05-25 | `cd web && pnpm test` | PASS | 设置页安全文案修复后前端单测通过14 个测试全部通过 |
| 2026-05-25 | Chrome settings re-check | PASS | 设置页显示“安全配置摘要”“仅校验后单文件写回”“不自动保存或批量写回”,旧文案不再可见 |
| 2026-05-25 | Final code review | PASS | 代码审查员复核无阻塞问题;建议将智能体页“只读编辑区”改为更准确的草稿编辑文案 |
| 2026-05-25 | `cd web && pnpm test` | FAIL | TDD 红灯:智能体页仍出现“智能体只读编辑区 / 只读编辑区”旧文案 |
| 2026-05-25 | `cd web && pnpm test` | PASS | 智能体页改为“字段预览与草稿编辑区”后前端单测通过15 个测试全部通过 |
| 2026-05-25 | `cd web && pnpm build` | PASS | 智能体页审查建议修复后前端生产构建通过 |
| 2026-05-25 | Chrome agent view re-check | PASS | 智能体视图显示“字段预览与草稿编辑区”,并保留校验 TOML、查看差异、创建备份并写回控件 |
## Bug Loop
@@ -217,3 +226,5 @@
| 6 | 写回备份/rename 前路径身份可能变化,且备份后并发修改可能被覆盖 | 写回加进程内临界区,记录 agents 目录和目标文件 inode identity备份前和 rename 前复核 identity 与 expectedHash | `go test ./internal/agents ./internal/server` PASS |
| 6 | validate/write POST 可接收超大 body、trailing JSON且错误响应透传路径和英文系统错误 | validate/write 使用 1MiB `MaxBytesReader`、拒绝 trailing JSON并将错误映射为安全中文响应 | `go test ./internal/agents ./internal/server` PASS |
| 6 | 复核后到备份/rename 前仍有父目录路径替换窗口 | 使用 `Openat`/`Renameat` 将目标读取、备份、临时文件和 rename 绑定到已打开的 `agents` 目录 fd并继续复核目录路径身份和目标 hash | `go test ./internal/agents ./internal/server` PASS |
| 7 | 设置页安全边界文案停留在只读阶段,误导用户以为智能体 TOML 不会保存 | 改为“仅校验后单文件写回”,明确项目/运行线程/工作流仍只读,且不自动保存或批量写回 | `cd web && pnpm test` PASSChrome re-check PASS |
| 7 | 智能体页编辑区域文案仍叫“只读编辑区”,与草稿编辑和写回按钮不一致 | 改为“字段预览与草稿编辑区”,并补文案回归测试 | `cd web && pnpm test` PASSChrome re-check PASS |

View File

@@ -9,7 +9,7 @@
- [x] 所有阶段完成
- [x] Go 测试通过
- [x] 前端构建通过
- [ ] 浏览器验证关键页面
- [x] 浏览器验证关键页面
- [x] docs/project.md 记录目标、架构、配置、运行方式、安全边界和恢复方式
- [x] 无 blocking bug 或未处理高风险问题
@@ -24,7 +24,7 @@
| 4 | complete | 中文 UI 只读工作台 | 项目/工作流/智能体视图可浏览;空数据可用 |
| 5 | complete | API 集成和只读数据显示 | 前端连接只读 API显示真实 agent 数据和错误状态 |
| 6 | complete | 草稿、TOML 校验、diff、备份、写回 | 草稿不覆盖原文件;无效 TOML 阻止写回;备份成功后才能写回 |
| 7 | complete | 集成验证与文档 | 测试/构建通过;浏览器验证待主智能体执行;文档完整 |
| 7 | complete | 集成验证与文档 | 测试/构建通过;Chrome 浏览器验证关键页面;文档完整 |
## Errors Encountered
@@ -42,3 +42,4 @@
| 2026-05-25 | 6 | 规格审查发现 malformed TOML 会通过 `strconv.Unquote` 泄漏英文 `invalid syntax` | TDD 补 List/Validate/Write 中文错误断言后包装字符串解析错误 | 已通过最终验证 |
| 2026-05-25 | 6 | 安全审查发现写回 TOCTOU、备份后 CAS 缺失、POST body 无限制、错误响应泄漏路径/英文 | TDD 补目录替换、备份后修改、请求体限制和错误脱敏测试后加身份复核/CAS/MaxBytesReader/中文错误映射 | 已通过最终验证 |
| 2026-05-25 | 6 | 安全复审发现复核后到 createBackup/rename 前仍通过路径重新解析父目录 | TDD 补复核后替换 `agents` 为 symlink 测试后,将备份、临时文件和 rename 绑定到已打开的 agents dirfd | 已通过最终验证 |
| 2026-05-25 | 7 | 设置页仍显示“本阶段不会写回 / 不保存智能体 TOML”与单文件确认写回能力冲突 | TDD 补设置页安全文案测试后改为“仅校验后单文件写回”,并声明不自动保存或批量写回 | 已通过前端测试和 Chrome 页面验证 |

View File

@@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.30.3",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 13083",
"build": "vite build",

View File

@@ -1,7 +1,6 @@
<script setup>
import { computed, ref } from 'vue'
import ProjectView from './views/ProjectView.vue'
import WorkflowView from './views/WorkflowView.vue'
import AgentView from './views/AgentView.vue'
import DraftsView from './views/DraftsView.vue'
import SettingsView from './views/SettingsView.vue'
@@ -9,7 +8,6 @@ import StatusBadge from './components/StatusBadge.vue'
const tabs = [
{ id: 'projects', label: '项目视图', component: ProjectView },
{ id: 'workflow', label: '工作流视图', component: WorkflowView },
{ id: 'agents', label: '智能体视图', component: AgentView },
{ id: 'drafts', label: '草稿', component: DraftsView },
{ id: 'settings', label: '设置', component: SettingsView },
@@ -30,7 +28,7 @@ const activeComponent = computed(() => tabs.find((tab) => tab.id === activeTab.v
<div class="connection-card" aria-label="连接状态">
<StatusBadge label="安全接口" status="complete" confidence="中" source="计划文件" />
<strong>按需读取和确认写回</strong>
<span>项目运行线程和工作流保持只读智能体草稿仅在校验备份和确认后单文件写回</span>
<span>项目运行线程和项目内工作流保持只读智能体草稿仅在校验备份和确认后单文件写回</span>
</div>
</header>

View File

@@ -30,6 +30,7 @@ const STATUS_LABELS = {
failed: '失败',
idle: '空闲',
invalid: '无效',
active: '运行中',
pending: '待处理',
recent: '最近活跃',
running: '运行中',
@@ -164,21 +165,41 @@ export function normalizeAgents(payload = {}) {
export function normalizeRuntime(payload = {}) {
const threads = Array.isArray(payload.items) ? payload.items : []
const goals = Array.isArray(payload.goals) ? payload.goals : []
const edges = Array.isArray(payload.edges) ? payload.edges : []
const goalsByThread = groupBy(goals, (goal) => goal.threadId)
const agents = threads.map((thread) => {
const spawnStatusByThread = new Map(
edges
.filter((edge) => edge.toThreadId)
.map((edge) => [edge.toThreadId, normalizeSpawnStatus(edge.reason || edge.status)]),
)
const agents = threads.filter(isRuntimeAgentThread).map((thread) => {
const source = normalizeSource(thread.source, payload.source)
const threadGoals = goalsByThread.get(thread.id) ?? []
const status = thread.status || spawnStatusByThread.get(thread.id) || threadGoals.find((goal) => goal.status)?.status || 'unknown'
const goalText = threadGoals
.map((goal) => goal.goal || goal.objective)
.filter(Boolean)
.join('')
const taskSummary = summarizeTask(thread.title || thread.preview)
return {
id: thread.id,
name: thread.role || thread.id || '未命名线程',
role: thread.id,
status: thread.status || 'unknown',
statusZh: formatStatus(thread.status),
goal: threadGoals.map((goal) => goal.goal).filter(Boolean).join('') || '没有目标记录',
process: 'SQLite 只读快照',
name: thread.agentNickname || thread.agentRole || thread.title || thread.role || thread.id || '未命名线程',
role: thread.agentRole || thread.role || thread.agentPath || thread.id || '未记录角色',
projectPath: thread.cwd || '',
projectHints: [thread.title, thread.preview, thread.agentPath].filter(Boolean),
status,
statusZh: formatStatus(status),
goal: goalText || taskSummary || '未记录结构化目标',
goalSource: goalText ? '结构化目标' : taskSummary ? '线程任务摘要' : '无目标来源',
process: describeRuntimeProcess(thread),
source: source.label,
confidence: source.confidenceLabel,
lastActivity: thread.updatedAt || thread.createdAt || '没有时间记录',
displayName: actorDisplayName({
name: thread.agentNickname,
role: thread.agentRole || thread.role,
}),
phaseName: extractPhaseName([goalText, taskSummary, thread.title, thread.preview, thread.agentPath]) || '未标注阶段',
}
})
const source = normalizeSource(payload.source)
@@ -187,7 +208,7 @@ export function normalizeRuntime(payload = {}) {
agents,
threads,
goals,
edges: Array.isArray(payload.edges) ? payload.edges : [],
edges,
source,
isEmpty: threads.length === 0,
emptyTitle: '没有运行线程',
@@ -195,6 +216,433 @@ export function normalizeRuntime(payload = {}) {
}
}
function isRuntimeAgentThread(thread) {
if (thread?.threadSource === 'user') {
return false
}
return Boolean(
thread?.agentNickname ||
thread?.agentRole ||
thread?.agentPath ||
thread?.threadSource === 'subagent' ||
(!thread?.threadSource && thread?.role),
)
}
function describeRuntimeProcess(thread) {
if (thread.threadSource === 'subagent') {
return '子智能体线程'
}
if (thread.agentPath) {
return `智能体配置:${basename(thread.agentPath)}`
}
if (thread.agentNickname || thread.agentRole || thread.role) {
return '智能体线程'
}
return 'SQLite 只读快照'
}
export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath = '') {
const targetPath = normalizePath(projectPath)
const agents = runtime.agents
.filter((agent) => belongsToProject(agent, targetPath))
.map((agent) => enrichProjectWorkflow(agent, targetPath))
const threadIds = new Set(agents.map((agent) => agent.id))
const agentByID = new Map(agents.map((agent) => [agent.id, agent]))
const threadByID = new Map(runtime.threads.map((thread) => [thread.id, thread]))
const projectThreadIds = new Set([
...threadIds,
...runtime.threads.filter((thread) => threadBelongsToProject(thread, targetPath)).map((thread) => thread.id),
])
const projectMainThreadIds = new Set(
runtime.threads.filter((thread) => projectThreadIds.has(thread.id) && isMainThread(thread)).map((thread) => thread.id),
)
const agentIdsWithProjectMainEdge = buildAgentIdsWithProjectMainEdge(runtime.edges, threadIds, projectMainThreadIds)
const threads = runtime.threads.filter((thread) => threadIds.has(thread.id))
const goals = runtime.goals.filter((goal) => threadIds.has(goal.threadId) || projectThreadIds.has(goal.threadId))
const edges = runtime.edges.filter((edge) =>
edgeBelongsToProjectFlow(edge, { threadIds, projectThreadIds, threadByID, targetPath, agentIdsWithProjectMainEdge }),
)
const handoffs = edges.map((edge) => normalizeProjectHandoff(edge, { agentByID, threadByID, source: runtime.source }))
const phaseGroups = buildPhaseGroups(agents)
const phaseHandoffs = buildPhaseHandoffs(handoffs, phaseGroups)
const workflowBatches = buildWorkflowBatches(phaseHandoffs)
const supervision = buildSupervision({ agents, handoffs, projectThreadIds, runtime, threadByID, goals })
return {
...runtime,
agents,
threads,
goals,
edges,
handoffs,
phaseGroups,
phaseHandoffs,
workflowBatches,
supervision,
isEmpty: agents.length === 0,
emptyTitle: '这个项目没有运行线程',
emptyText: projectPath
? '当前选中的项目没有匹配到 Codex 运行线程;这里不会显示其他项目的智能体状态。'
: '请先选择一个项目。',
}
}
function buildAgentIdsWithProjectMainEdge(edges, threadIds, projectMainThreadIds) {
const agentIds = new Set()
for (const edge of edges) {
if (projectMainThreadIds.has(edge.fromThreadId) && threadIds.has(edge.toThreadId)) {
agentIds.add(edge.toThreadId)
}
if (projectMainThreadIds.has(edge.toThreadId) && threadIds.has(edge.fromThreadId)) {
agentIds.add(edge.fromThreadId)
}
}
return agentIds
}
function edgeBelongsToProjectFlow(edge, { threadIds, projectThreadIds, threadByID, targetPath, agentIdsWithProjectMainEdge }) {
const fromIsAgent = threadIds.has(edge.fromThreadId)
const toIsAgent = threadIds.has(edge.toThreadId)
const fromIsProjectThread = projectThreadIds.has(edge.fromThreadId)
const toIsProjectThread = projectThreadIds.has(edge.toThreadId)
const fromThread = threadByID.get(edge.fromThreadId)
const toThread = threadByID.get(edge.toThreadId)
const fromIsMain = mainThreadCanJoinProjectFlow(fromThread, targetPath, agentIdsWithProjectMainEdge, edge.toThreadId)
const toIsMain = mainThreadCanJoinProjectFlow(toThread, targetPath, agentIdsWithProjectMainEdge, edge.fromThreadId)
if (fromIsAgent && toIsAgent) {
return true
}
if (fromIsAgent && (toIsProjectThread || toIsMain)) {
return true
}
if (toIsAgent && (fromIsProjectThread || fromIsMain)) {
return true
}
return false
}
function mainThreadCanJoinProjectFlow(thread, targetPath, agentIdsWithProjectMainEdge, agentThreadId) {
if (!isMainThread(thread)) {
return false
}
if (threadBelongsToProject(thread, targetPath)) {
return true
}
if (agentIdsWithProjectMainEdge.has(agentThreadId)) {
return false
}
const cwd = normalizePath(thread?.cwd)
return Boolean(cwd && targetPath.startsWith(`${cwd}/`))
}
function normalizeProjectHandoff(edge, context) {
const source = normalizeSource(edge.source, context.source)
const status = normalizeSpawnStatus(edge.reason || edge.status) || 'unknown'
const fromAgent = context.agentByID.get(edge.fromThreadId)
const toAgent = context.agentByID.get(edge.toThreadId)
const fromIsMain = isMainThread(context.threadByID.get(edge.fromThreadId))
const toIsMain = isMainThread(context.threadByID.get(edge.toThreadId))
const directionLabel = handoffDirectionLabel({ fromAgent, toAgent, fromIsMain, toIsMain })
return {
id: `${edge.fromThreadId || 'unknown'}-${edge.toThreadId || 'unknown'}-${edge.createdAt || edge.reason || edge.status || 'event'}`,
from: runtimeNodeName(edge.fromThreadId, context),
to: runtimeNodeName(edge.toThreadId, context),
summary: formatStatus(status),
directionLabel,
status,
time: edge.createdAt || '后端事件',
source: source.label,
confidence: source.confidenceLabel,
phaseName: handoffPhaseName({ fromAgent, toAgent, toIsMain }),
workflowBatch: handoffWorkflowBatch({ fromAgent, toAgent, toIsMain }),
}
}
function runtimeNodeName(threadID, { agentByID, threadByID }) {
if (!threadID) {
return '未知线程'
}
const agent = agentByID.get(threadID)
if (agent) {
return agent.displayName
}
const thread = threadByID.get(threadID)
if (isMainThread(thread)) {
return '主线程 / 主智能体监管'
}
return actorDisplayName({
name: thread?.agentNickname,
role: thread?.agentRole || thread?.role,
fallbackName: '未知线程',
fallbackRole: '角色未知',
})
}
function handoffPhaseName({ fromAgent, toAgent, toIsMain }) {
if (toIsMain) {
return fromAgent?.phaseName || '未标注阶段'
}
if (toAgent?.phaseName) {
return toAgent.phaseName
}
return fromAgent?.phaseName || '未标注阶段'
}
function handoffWorkflowBatch({ fromAgent, toAgent, toIsMain }) {
if (toIsMain) {
return fromAgent?.workflowBatch || '默认工作流'
}
if (toAgent?.workflowBatch) {
return toAgent.workflowBatch
}
return fromAgent?.workflowBatch || '默认工作流'
}
function handoffDirectionLabel({ fromAgent, toAgent, fromIsMain, toIsMain }) {
if (fromIsMain && toAgent) {
return '主线程派发'
}
if (fromAgent && toIsMain) {
return '回到主线程'
}
if (fromAgent && toAgent && fromAgent.phaseName !== toAgent.phaseName) {
return '跨阶段交接'
}
if (fromAgent && toAgent) {
return '子智能体交接'
}
return '线程交接'
}
function buildPhaseGroups(agents) {
const groups = new Map()
for (const agent of agents) {
const phaseName = agent.phaseName || extractPhaseName([agent.goal, ...(agent.projectHints || [])]) || '未标注阶段'
const workflowBatch = agent.workflowBatch || '默认工作流'
const groupKey = `${workflowBatch}::${phaseName}`
if (!groups.has(groupKey)) {
groups.set(groupKey, { name: phaseName, workflowBatch, agents: [] })
}
groups.get(groupKey).agents.push(agent)
}
return [...groups.values()]
.map(({ name, workflowBatch, agents: phaseAgents }) => {
const roles = [...new Set(phaseAgents.map((agent) => agent.role).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh-CN'))
return {
name,
workflowBatch,
status: phaseStatus(phaseAgents),
statusZh: formatStatus(phaseStatus(phaseAgents)),
roles,
agents: phaseAgents,
}
})
.sort(compareWorkflowPhase)
}
function buildPhaseHandoffs(handoffs, phaseGroups) {
const groups = new Map(phaseGroups.map((phase) => [`${phase.workflowBatch}::${phase.name}`, { ...phase, handoffs: [] }]))
for (const handoff of handoffs) {
const phaseName = handoff.phaseName || '未标注阶段'
const workflowBatch = handoff.workflowBatch || '默认工作流'
const groupKey = `${workflowBatch}::${phaseName}`
if (!groups.has(groupKey)) {
groups.set(groupKey, {
name: phaseName,
workflowBatch,
status: handoff.status,
statusZh: formatStatus(handoff.status),
roles: [],
agents: [],
handoffs: [],
})
}
groups.get(groupKey).handoffs.push(handoff)
}
return [...groups.values()]
.filter((phase) => phase.agents.length > 0 || phase.handoffs.length > 0)
.map((phase) => ({
...phase,
status: phaseStatus([...phase.agents, ...phase.handoffs]),
statusZh: formatStatus(phaseStatus([...phase.agents, ...phase.handoffs])),
}))
.sort(compareWorkflowPhase)
}
function buildWorkflowBatches(phases) {
const groups = new Map()
for (const phase of phases) {
const batchName = phase.workflowBatch || '默认工作流'
if (!groups.has(batchName)) {
groups.set(batchName, {
name: batchName,
status: 'unknown',
statusZh: '未知',
phaseCount: 0,
handoffCount: 0,
agentCount: 0,
phases: [],
})
}
const batch = groups.get(batchName)
batch.phases.push(phase)
batch.phaseCount += 1
batch.handoffCount += phase.handoffs.length
batch.agentCount += phase.agents.length
}
return [...groups.values()]
.map((batch) => {
const status = phaseStatus(batch.phases)
return { ...batch, status, statusZh: formatStatus(status) }
})
.sort((a, b) => workflowBatchSortKey(a.name) - workflowBatchSortKey(b.name) || a.name.localeCompare(b.name, 'zh-CN'))
}
function buildSupervision({ agents, handoffs, projectThreadIds, runtime, threadByID, goals }) {
const mainThread = [...projectThreadIds]
.map((threadID) => threadByID.get(threadID))
.find((thread) => isMainThread(thread))
const orchestrator = agents.find((agent) => /编排|主智能体|监管/.test(`${agent.role} ${agent.name} ${agent.goal}`))
const supervisorID = mainThread?.id || orchestrator?.id || ''
const supervisorGoal = goals.find((goal) => goal.threadId === supervisorID) || goals.find((goal) => projectThreadIds.has(goal.threadId))
const status = mainThread?.status || orchestrator?.status || supervisorGoal?.status || (handoffs.length > 0 ? 'recent' : 'unknown')
const source = normalizeSource(mainThread?.source || orchestrator?.sourceDetail, runtime.source)
return {
actor: mainThread ? '主线程 / 主智能体监管' : orchestrator?.displayName || '主线程 / 主智能体监管',
status,
statusZh: formatStatus(status),
goal: supervisorGoal?.goal || supervisorGoal?.objective || orchestrator?.goal || '没有目标记录',
lastActivity: mainThread?.updatedAt || mainThread?.createdAt || supervisorGoal?.updatedAt || orchestrator?.lastActivity || '没有时间记录',
handoffCount: handoffs.length,
agentCount: agents.length,
source: source.label,
confidence: source.confidenceLabel,
}
}
function phaseStatus(agents) {
if (agents.some((agent) => agent.status === 'running' || agent.status === 'active')) {
return 'running'
}
if (agents.length > 0 && agents.every((agent) => agent.status === 'complete' || agent.status === 'done')) {
return 'complete'
}
return 'unknown'
}
function extractPhaseName(values) {
for (const value of values) {
const text = String(value || '')
const optimization = text.match(/优化阶段\s*([0-9一二三四五六七八九十]+)/)
if (optimization) {
return `优化阶段 ${optimization[1]}`
}
const chinese = text.match(/阶段\s*([0-9一二三四五六七八九十]+)/)
if (chinese) {
return `阶段 ${chinese[1]}`
}
const english = text.match(/\bphase\s*([0-9]+)\b/i)
if (english) {
return `阶段 ${english[1]}`
}
}
return ''
}
function enrichProjectWorkflow(agent, targetPath) {
const values = [agent.goal, agent.name, agent.role, ...(agent.projectHints || [])]
const explicitBatch = extractWorkflowBatchName(values)
const explicitPhase = extractPhaseName(values)
const isCurrentProject = basename(targetPath) === 'codex-agent-manager'
let phaseName = explicitPhase || agent.phaseName || '未标注阶段'
let workflowBatch = explicitBatch
if (!workflowBatch && isCurrentProject && isOptimizationWork(values)) {
if (!/^阶段\s*[0-9一二三四五六七八九十]+$/.test(phaseName) || /^优化阶段/.test(phaseName)) {
workflowBatch = 'v1.1 优化修复'
if (!explicitPhase || phaseName === '未标注阶段') {
phaseName = '优化阶段 1'
}
}
}
if (!workflowBatch && isCurrentProject && /^阶段\s*[0-9一二三四五六七八九十]+$/.test(phaseName)) {
workflowBatch = 'v1.0 初始搭建'
}
return {
...agent,
phaseName,
workflowBatch: normalizeWorkflowBatchName(workflowBatch || '默认工作流'),
}
}
function extractWorkflowBatchName(values) {
for (const value of values) {
const text = String(value || '')
const explicit = text.match(/工作流批次\s*[:]\s*([^\]`\n]+)/)
if (explicit) {
return cleanExplicitWorkflowBatchName(explicit[1])
}
const version = text.match(/\b(v[0-9]+(?:\.[0-9]+)+)\s*([^\]`\n,,。:/、]*)/i)
if (version && /重构|升级|优化|修复|初始|搭建|发布/.test(version[2] || '')) {
return `${version[1]} ${version[2].trim()}`.trim()
}
}
return ''
}
function cleanExplicitWorkflowBatchName(value) {
return String(value || '')
.replace(/\s*[\/、]\s*(?:优化阶段|阶段)\s*[0-9一二三四五六七八九十]+.*$/, '')
.trim()
}
function normalizeWorkflowBatchName(value) {
return String(value || '默认工作流').replace(/^(v[0-9]+(?:\.[0-9]+)+)([^\s].*)$/, '$1 $2').trim()
}
function isOptimizationWork(values) {
return values.some((value) => /优化|修复|改造|调整|合并|布局|详情切换|流程记录|工作流视图|工作流批次|新增|项目视图|交接/.test(String(value || '')))
}
function actorDisplayName({ name = '', role = '', fallbackName = '', fallbackRole = '未记录角色' } = {}) {
const cleanName = String(name || '').trim()
const cleanRole = String(role || '').trim()
if (cleanName && cleanRole) {
return `${cleanName} / ${cleanRole}`
}
if (cleanRole) {
return cleanRole
}
if (cleanName) {
return `${cleanName} / ${fallbackRole}`
}
return fallbackName ? `${fallbackName} / ${fallbackRole}` : `未知线程 / ${fallbackRole}`
}
function phaseSortKey(name) {
const match = String(name).match(/(?:阶段|优化阶段)\s*([0-9]+)/)
if (!match) {
return Number.MAX_SAFE_INTEGER
}
return Number(match[1])
}
function workflowBatchSortKey(name) {
const match = String(name).match(/v([0-9]+)(?:\.([0-9]+))?/i)
if (!match) {
return Number.MAX_SAFE_INTEGER
}
return Number(match[1]) * 100 + Number(match[2] || 0)
}
function compareWorkflowPhase(a, b) {
return workflowBatchSortKey(a.workflowBatch) - workflowBatchSortKey(b.workflowBatch) ||
a.workflowBatch.localeCompare(b.workflowBatch, 'zh-CN') ||
phaseSortKey(a.name) - phaseSortKey(b.name) ||
a.name.localeCompare(b.name, 'zh-CN')
}
export function normalizeWorkflow(payload = {}) {
const source = normalizeSource(payload.source)
const phases = Array.isArray(payload.phases)
@@ -327,6 +775,93 @@ function basename(path) {
return String(path).split('/').filter(Boolean).at(-1) ?? String(path)
}
function normalizePath(path) {
return String(path || '').replace(/\/+$/, '')
}
function summarizeTask(value) {
const firstLine = String(value || '').split('\n').map((line) => line.trim()).find(Boolean) || ''
if (firstLine.length <= 180) {
return firstLine
}
return `${firstLine.slice(0, 177)}...`
}
function belongsToProject(agent, targetPath) {
if (!targetPath) {
return false
}
const agentPath = normalizePath(agent.projectPath)
if (agentPath === targetPath) {
return true
}
if (!agentPath || !targetPath.startsWith(`${agentPath}/`)) {
return false
}
return [agent.process, ...(agent.projectHints || [])].some((value) => containsPathToken(value, targetPath) || containsProjectToken(value, targetPath))
}
function threadBelongsToProject(thread, targetPath) {
if (!targetPath) {
return false
}
const cwd = normalizePath(thread?.cwd)
if (cwd === targetPath) {
return true
}
if (!cwd || !targetPath.startsWith(`${cwd}/`)) {
return false
}
return [thread?.title, thread?.preview, thread?.agentPath].some((value) => containsPathToken(value, targetPath) || containsProjectToken(value, targetPath))
}
function threadCanJoinProjectFlow(thread, targetPath) {
return threadBelongsToProject(thread, targetPath)
}
function isMainThread(thread) {
if (!thread || thread.threadSource === 'subagent') {
return false
}
return Boolean(thread.threadSource === 'user' || (!thread.agentNickname && !thread.agentRole && !thread.agentPath && !thread.role))
}
function normalizeSpawnStatus(status) {
if (status === 'open') {
return 'running'
}
if (status === 'closed') {
return 'complete'
}
return status || ''
}
function containsPathToken(value, targetPath) {
const text = String(value || '')
let index = text.indexOf(targetPath)
while (index !== -1) {
const after = text[index + targetPath.length] || ''
if (!after || !/[A-Za-z0-9._/-]/.test(after)) {
return true
}
index = text.indexOf(targetPath, index + targetPath.length)
}
return false
}
function containsProjectToken(value, targetPath) {
if (!targetPath) {
return false
}
const text = String(value || '')
const metadata = text.match(/\[?项目\s*[:]\s*([^\]\n]+)\]?/)
if (!metadata) {
return false
}
const candidate = normalizePath(metadata[1].trim())
return candidate === targetPath
}
function formatDateTime(value) {
if (!value) {
return '没有时间记录'

View File

@@ -1,5 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import {
formatConfidence,
@@ -7,6 +9,7 @@ import {
formatStatus,
normalizeAgent,
normalizeDraftWriteback,
filterRuntimeByProject,
normalizeProject,
normalizeRuntime,
normalizeValidationResult,
@@ -92,6 +95,51 @@ test('empty agent and settings copy stay Chinese', () => {
assert.ok(settings.every((item) => item.name !== 'Codex home'))
})
test('settings safety copy matches confirmed single-file writeback phase', async () => {
const thisFile = fileURLToPath(import.meta.url)
const viewPath = new URL('../views/SettingsView.vue', `file://${thisFile}`)
const source = await readFile(viewPath, 'utf8')
assert.doesNotMatch(source, /本阶段不会写回/)
assert.doesNotMatch(source, /不保存智能体 TOML/)
assert.match(source, /仅校验后单文件写回/)
assert.match(source, /不自动保存或批量写回/)
})
test('agent editor copy reflects preview and draft editing, not readonly-only mode', async () => {
const thisFile = fileURLToPath(import.meta.url)
const viewPath = new URL('../views/AgentView.vue', `file://${thisFile}`)
const source = await readFile(viewPath, 'utf8')
assert.doesNotMatch(source, /智能体只读编辑区/)
assert.doesNotMatch(source, /<p class="eyebrow">只读编辑区<\/p>/)
assert.match(source, /字段预览与草稿编辑区/)
})
test('layout allows main work areas to stretch on wide browser windows', async () => {
const thisFile = fileURLToPath(import.meta.url)
const stylePath = new URL('../styles.css', `file://${thisFile}`)
const source = await readFile(stylePath, 'utf8')
const projectMinWidth = 280 + 0 + 310 + 16 * 2
const commonLaptopContentWidth = 1366 - 28 * 2
const twoColumnMinWidth = Math.max(720 + 340 + 16, 330 + 720 + 16)
const responsiveBreakpoint = 1140
assert.doesNotMatch(source, /\.app-shell\s*\{[^}]*width:\s*min\(1440px,\s*100%\)/s)
assert.match(source, /\.app-shell\s*\{[^}]*width:\s*100%/s)
assert.doesNotMatch(source, /\.app-shell\s*\{[^}]*max-width:/s)
assert.match(source, /\.project-layout\s*\{[^}]*grid-template-columns:\s*280px minmax\(0,\s*1fr\) 310px/s)
assert.match(source, /\.matrix-table\s*\{[^}]*overflow-x:\s*auto/s)
assert.match(source, /\.matrix-row\s*\{[^}]*min-width:\s*920px/s)
assert.match(source, /\.workflow-layout\s*\{[^}]*grid-template-columns:\s*minmax\(720px,\s*1fr\) 340px/s)
assert.match(source, /\.agent-layout\s*\{[^}]*grid-template-columns:\s*330px minmax\(720px,\s*1fr\)/s)
assert.match(source, /\.drafts-layout\s*\{[^}]*grid-template-columns:\s*minmax\(720px,\s*1fr\) 340px/s)
assert.match(source, /\.settings-layout\s*\{[^}]*max-width:\s*1440px/s)
assert.match(source, /@media\s*\(max-width:\s*1140px\)\s*\{[^}]*\.project-layout,[^}]*\.workflow-layout,[^}]*\.agent-layout,[^}]*\.drafts-layout,[^}]*\.settings-layout\s*\{[^}]*grid-template-columns:\s*1fr/s)
assert.ok(projectMinWidth <= commonLaptopContentWidth)
assert.ok(twoColumnMinWidth <= responsiveBreakpoint - 28 * 2)
})
test('normalizes empty runtime without falling back to fake real data', () => {
const runtime = normalizeRuntime({
items: [],
@@ -111,6 +159,676 @@ test('normalizes empty runtime without falling back to fake real data', () => {
assert.deepEqual(runtime.agents, [])
})
test('filters runtime to selected project and displays agent names from Codex metadata', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'thread-a',
cwd: '/repo/a',
title: '主线程',
agentNickname: '主控',
agentRole: '智能体编排者',
status: '',
updatedAt: '2000',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'thread-b',
cwd: '/repo/b',
title: '审查线程',
agentNickname: '审查员',
agentRole: '代码审查员',
status: 'running',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
goals: [
{ threadId: 'thread-a', goal: '管理当前项目的智能体流程', status: 'active' },
],
edges: [{ fromThreadId: 'thread-a', toThreadId: 'thread-b', reason: 'spawned' }],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/repo/a')
assert.equal(projectRuntime.isEmpty, false)
assert.equal(projectRuntime.agents.length, 1)
assert.equal(projectRuntime.threads.length, 1)
assert.equal(projectRuntime.agents[0].name, '主控')
assert.equal(projectRuntime.agents[0].role, '智能体编排者')
assert.equal(projectRuntime.agents[0].projectPath, '/repo/a')
assert.equal(projectRuntime.agents[0].goal, '管理当前项目的智能体流程')
assert.equal(projectRuntime.agents[0].status, 'active')
assert.equal(projectRuntime.agents[0].statusZh, '运行中')
})
test('agent display names include role without inventing a nickname', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'role-only',
cwd: '/repo/a',
title: '阶段 1产品规划',
agentRole: '产品经理',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'name-only',
cwd: '/repo/a',
title: '阶段 1补充资料',
agentNickname: 'Nash',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/repo/a')
assert.deepEqual(projectRuntime.agents.map((agent) => agent.displayName), [
'产品经理',
'Nash / 未记录角色',
])
})
test('supervision only uses project-proven main threads', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'wrong-main',
cwd: '/Users/yoilun',
title: '普通对话,没有项目路径',
threadSource: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'right-main',
cwd: '/Users/yoilun',
title: '监管 /Users/yoilun/Code/codex-agent-manager 项目',
threadSource: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'coder',
cwd: '/Users/yoilun',
title: '阶段 2实现 /Users/yoilun/Code/codex-agent-manager',
agentNickname: 'Ada',
agentRole: '前端开发者',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
goals: [
{ threadId: 'wrong-main', goal: '监管别的项目', status: 'active' },
{ threadId: 'right-main', goal: '监管 codex-agent-manager 项目', status: 'active' },
],
edges: [
{ fromThreadId: 'wrong-main', toThreadId: 'coder', reason: 'open' },
{ fromThreadId: 'right-main', toThreadId: 'coder', reason: 'open' },
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
assert.deepEqual(projectRuntime.handoffs.map((handoff) => handoff.from), ['主线程 / 主智能体监管'])
assert.equal(projectRuntime.supervision.goal, '监管 codex-agent-manager 项目')
})
test('bare subagent threads are not treated as main supervision threads', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'main',
cwd: '/repo/a',
title: '监管 /repo/a 项目',
threadSource: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'bare-sub',
cwd: '/repo/a',
title: '阶段 3没有角色元数据的子智能体',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'reviewer',
cwd: '/repo/a',
title: '阶段 3审查',
agentNickname: 'Rawls',
agentRole: '代码审查员',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
goals: [
{ threadId: 'main', goal: '监管真实项目', status: 'active' },
{ threadId: 'bare-sub', goal: '裸子智能体目标', status: 'active' },
],
edges: [
{ fromThreadId: 'bare-sub', toThreadId: 'reviewer', reason: 'open' },
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/repo/a')
assert.equal(projectRuntime.supervision.goal, '监管真实项目')
assert.equal(projectRuntime.handoffs[0].directionLabel, '子智能体交接')
})
test('keeps main thread handoffs to project agents even when the main thread has only ancestor cwd', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'main',
cwd: '/Users/yoilun',
title: '我想要一个图形页面去管理 code 文件夹下的项目',
threadSource: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'reviewer',
cwd: '/Users/yoilun',
title: '请审查 `/Users/yoilun/Code/codex-agent-manager` 当前未提交改动。背景:用户要求新增批次切换。',
agentNickname: 'Mencius',
agentRole: '代码审查员',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
edges: [
{ fromThreadId: 'main', toThreadId: 'reviewer', reason: 'closed' },
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
assert.equal(projectRuntime.agents.length, 1)
assert.equal(projectRuntime.handoffs.length, 1)
assert.equal(projectRuntime.handoffs[0].directionLabel, '主线程派发')
assert.equal(projectRuntime.workflowBatches[0].handoffCount, 1)
assert.deepEqual(projectRuntime.workflowBatches[0].phases[0].handoffs.map((handoff) => handoff.to), [
'Mencius / 代码审查员',
])
})
test('keeps parent main handoff when another project main is unrelated to that agent', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'project-main',
cwd: '/Users/yoilun',
title: '另一个明确提到 /Users/yoilun/Code/codex-agent-manager 的用户线程,但没有派发这个智能体',
threadSource: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'parent-main',
cwd: '/Users/yoilun',
title: '继续整个项目',
threadSource: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'reviewer',
cwd: '/Users/yoilun',
title: '请审查 `/Users/yoilun/Code/codex-agent-manager` 当前 bugfix。',
agentNickname: 'Hubble',
agentRole: '代码审查员',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'other-agent',
cwd: '/Users/yoilun',
title: '阶段 2另一个 /Users/yoilun/Code/codex-agent-manager 智能体',
agentNickname: 'Noether',
agentRole: '后端架构师',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
edges: [
{ fromThreadId: 'parent-main', toThreadId: 'reviewer', reason: 'open' },
{ fromThreadId: 'project-main', toThreadId: 'other-agent', reason: 'closed' },
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
assert.deepEqual(projectRuntime.handoffs.map((handoff) => `${handoff.from} -> ${handoff.to}`), [
'主线程 / 主智能体监管 -> Hubble / 代码审查员',
'主线程 / 主智能体监管 -> Noether / 后端架构师',
])
})
test('groups project flow by workflow batch and classifies current optimizations', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'main',
cwd: '/Users/yoilun',
title: '监管 /Users/yoilun/Code/codex-agent-manager 项目',
threadSource: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'initial',
cwd: '/Users/yoilun',
title: '阶段 8Docker 发布 /Users/yoilun/Code/codex-agent-manager',
agentNickname: 'Ada',
agentRole: '前端开发者',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'optimization',
cwd: '/Users/yoilun',
title: '优化改造:合并工作流视图到项目视图 /Users/yoilun/Code/codex-agent-manager',
agentNickname: 'Rawls',
agentRole: '代码审查员',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'v2',
cwd: '/Users/yoilun',
title: '[项目: /Users/yoilun/Code/codex-agent-manager] [工作流批次: v2.0 重构升级] [阶段: 阶段 3 后端架构]',
agentNickname: 'Noether',
agentRole: '后端架构师',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'review-prompt',
cwd: '/Users/yoilun',
title: '请审查 /Users/yoilun/Code/codex-agent-manager。背景将优化改造归类到 `v1.1 优化修复 / 优化阶段 1`。当前实现会给项目内 agent/handoff 增加工作流批次。',
agentNickname: 'Peirce',
agentRole: '代码审查员',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'explicit-slash-batch',
cwd: '/Users/yoilun',
title: '[项目: /Users/yoilun/Code/codex-agent-manager] [工作流批次: v1.2 CI/CD 发布] [阶段: 阶段 9 发布验证]',
agentNickname: 'Curie',
agentRole: '发布经理',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'explicit-phase-tail',
cwd: '/Users/yoilun',
title: '[项目: /Users/yoilun/Code/codex-agent-manager] [工作流批次: v1.1 优化修复 / 优化阶段 1] [阶段: 优化阶段 1]',
agentNickname: 'Turing',
agentRole: '前端开发者',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
edges: [
{ fromThreadId: 'main', toThreadId: 'initial', reason: 'closed' },
{ fromThreadId: 'main', toThreadId: 'optimization', reason: 'open' },
{ fromThreadId: 'optimization', toThreadId: 'v2', reason: 'open' },
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'initial').workflowBatch, 'v1.0 初始搭建')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'optimization').workflowBatch, 'v1.1 优化修复')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'optimization').phaseName, '优化阶段 1')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'v2').workflowBatch, 'v2.0 重构升级')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'v2').phaseName, '阶段 3')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'review-prompt').workflowBatch, 'v1.1 优化修复')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'explicit-slash-batch').workflowBatch, 'v1.2 CI/CD 发布')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'explicit-phase-tail').workflowBatch, 'v1.1 优化修复')
assert.deepEqual(projectRuntime.workflowBatches.map((batch) => batch.name), [
'v1.0 初始搭建',
'v1.1 优化修复',
'v1.2 CI/CD 发布',
'v2.0 重构升级',
])
assert.deepEqual(projectRuntime.workflowBatches[1].phases.map((phase) => phase.name), ['优化阶段 1'])
assert.deepEqual(projectRuntime.workflowBatches[1].phases[0].handoffs.map((handoff) => handoff.to), [
'Rawls / 代码审查员',
])
})
test('workflow metadata requires full project path and keeps optimization phase name', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'short-project',
cwd: '/Users/yoilun',
title: '[项目: codex-agent-manager] [工作流批次: v1.1 优化修复] [阶段: 优化阶段 1]',
agentNickname: '短名项目',
agentRole: '产品经理',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'full-project',
cwd: '/Users/yoilun',
title: '[项目: /Users/yoilun/Code/codex-agent-manager] [工作流批次: v1.1 优化修复] [阶段: 优化阶段 1]',
agentNickname: '完整路径项目',
agentRole: '产品经理',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'numeric-repair',
cwd: '/Users/yoilun',
title: '阶段 3修复初始搭建问题 /Users/yoilun/Code/codex-agent-manager',
agentNickname: '数字阶段',
agentRole: '代码审查员',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'new-batch',
cwd: '/Users/yoilun',
title: '新增工作流批次与项目视图交接 /Users/yoilun/Code/codex-agent-manager',
agentNickname: '新增批次',
agentRole: '前端开发者',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
assert.deepEqual(projectRuntime.agents.map((agent) => agent.id), ['full-project', 'numeric-repair', 'new-batch'])
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'full-project').phaseName, '优化阶段 1')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'full-project').workflowBatch, 'v1.1 优化修复')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'numeric-repair').workflowBatch, 'v1.0 初始搭建')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'new-batch').workflowBatch, 'v1.1 优化修复')
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'new-batch').phaseName, '优化阶段 1')
})
test('does not turn ordinary conversation threads into agents', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'conversation',
cwd: '/Users/yoilun',
title: '为什么点 yoilun 这个项目出来的都是对话的信息',
preview: '普通用户对话,不是智能体',
threadSource: 'user',
role: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'reviewer',
cwd: '/Users/yoilun',
title: '审查 /Users/yoilun 项目',
agentNickname: 'Lorentz',
agentRole: '代码审查员',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun')
assert.equal(runtime.threads.length, 2)
assert.deepEqual(projectRuntime.agents.map((agent) => agent.id), ['reviewer'])
assert.equal(projectRuntime.agents[0].name, 'Lorentz')
assert.equal(projectRuntime.agents[0].process, '子智能体线程')
assert.equal(projectRuntime.agents[0].goal, '审查 /Users/yoilun 项目')
assert.equal(projectRuntime.agents[0].goalSource, '线程任务摘要')
assert.doesNotMatch(projectRuntime.agents[0].process, /审查 \/Users\/yoilun/)
})
test('builds project handoffs and phase groups from runtime edges and agent tasks', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'main',
cwd: '/repo/a',
title: '主控对话',
threadSource: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'coder',
cwd: '/repo/a',
title: '阶段 6实现安全写回',
agentNickname: 'Averroes',
agentRole: '后端架构师',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'reviewer',
cwd: '/repo/a',
title: '阶段 6审查安全写回',
agentNickname: 'Rawls',
agentRole: '代码审查员',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'pm',
cwd: '/repo/a',
title: '阶段 7整理最终文档',
agentNickname: 'Zeno',
agentRole: '高级项目经理',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'publisher',
cwd: '/repo/a',
title: '阶段 7发布变更',
agentNickname: 'Noether',
agentRole: '发布经理',
threadSource: 'subagent',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'other-main',
cwd: '/repo/b',
title: '其他项目主控对话',
threadSource: 'user',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
edges: [
{ fromThreadId: 'main', toThreadId: 'coder', reason: 'closed' },
{ fromThreadId: 'coder', toThreadId: 'reviewer', reason: 'open' },
{ fromThreadId: 'main', toThreadId: 'pm', reason: 'closed' },
{ fromThreadId: 'reviewer', toThreadId: 'main', reason: 'closed' },
{ fromThreadId: 'reviewer', toThreadId: 'publisher', reason: 'open' },
{ fromThreadId: 'other-main', toThreadId: 'coder', reason: 'open' },
],
goals: [
{ threadId: 'main', goal: '监管 /repo/a 项目内智能体流程', status: 'active', updatedAt: '2026-05-26T09:00:00Z' },
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/repo/a')
assert.deepEqual(projectRuntime.handoffs.map((handoff) => handoff.summary), [
'已完成',
'运行中',
'已完成',
'已完成',
'运行中',
])
assert.deepEqual(projectRuntime.handoffs.map((handoff) => `${handoff.from} -> ${handoff.to}`), [
'主线程 / 主智能体监管 -> Averroes / 后端架构师',
'Averroes / 后端架构师 -> Rawls / 代码审查员',
'主线程 / 主智能体监管 -> Zeno / 高级项目经理',
'Rawls / 代码审查员 -> 主线程 / 主智能体监管',
'Rawls / 代码审查员 -> Noether / 发布经理',
])
assert.deepEqual(projectRuntime.handoffs.map((handoff) => handoff.directionLabel), [
'主线程派发',
'子智能体交接',
'主线程派发',
'回到主线程',
'跨阶段交接',
])
assert.ok(projectRuntime.handoffs.every((handoff) => handoff.time === '后端事件'))
assert.deepEqual(projectRuntime.phaseGroups.map((phase) => phase.name), ['阶段 6', '阶段 7'])
assert.equal(projectRuntime.phaseGroups[0].status, 'running')
assert.deepEqual(projectRuntime.phaseGroups[0].roles, ['代码审查员', '后端架构师'])
assert.deepEqual(projectRuntime.phaseGroups[0].agents.map((agent) => agent.name), ['Averroes', 'Rawls'])
assert.deepEqual(projectRuntime.phaseGroups[0].agents.map((agent) => agent.displayName), ['Averroes / 后端架构师', 'Rawls / 代码审查员'])
assert.deepEqual(projectRuntime.phaseHandoffs.map((phase) => phase.name), ['阶段 6', '阶段 7'])
assert.deepEqual(projectRuntime.phaseHandoffs[0].handoffs.map((handoff) => `${handoff.from} -> ${handoff.to}`), [
'主线程 / 主智能体监管 -> Averroes / 后端架构师',
'Averroes / 后端架构师 -> Rawls / 代码审查员',
'Rawls / 代码审查员 -> 主线程 / 主智能体监管',
])
assert.deepEqual(projectRuntime.phaseHandoffs[1].handoffs.map((handoff) => `${handoff.from} -> ${handoff.to}`), [
'主线程 / 主智能体监管 -> Zeno / 高级项目经理',
'Rawls / 代码审查员 -> Noether / 发布经理',
])
assert.equal(projectRuntime.supervision.actor, '主线程 / 主智能体监管')
assert.equal(projectRuntime.supervision.goal, '监管 /repo/a 项目内智能体流程')
assert.equal(projectRuntime.supervision.handoffCount, 5)
assert.equal(projectRuntime.supervision.agentCount, 4)
})
test('project and app views keep workflow inside the selected project view', async () => {
const thisFile = fileURLToPath(import.meta.url)
const viewPath = new URL('../views/ProjectView.vue', `file://${thisFile}`)
const appPath = new URL('../App.vue', `file://${thisFile}`)
const stylePath = new URL('../styles.css', `file://${thisFile}`)
const viewSource = await readFile(viewPath, 'utf8')
const appSource = await readFile(appPath, 'utf8')
const styleSource = await readFile(stylePath, 'utf8')
assert.doesNotMatch(viewSource, /sampleProjects/)
assert.doesNotMatch(viewSource, /sampleAgentMatrix/)
assert.doesNotMatch(appSource, /WorkflowView/)
assert.doesNotMatch(appSource, /工作流视图/)
assert.match(viewSource, /selectedAgentId/)
assert.match(viewSource, /selectAgent/)
assert.match(viewSource, /selectedWorkflowBatchName/)
assert.match(viewSource, /selectedWorkflowBatch/)
assert.match(viewSource, /selectWorkflowBatch/)
assert.match(viewSource, /批次切换/)
assert.match(viewSource, /role="group"/)
assert.match(viewSource, /selectedWorkflowBatch\?\.handoffCount \?\? 0/)
assert.match(viewSource, /workflowBatches/)
assert.match(viewSource, /工作流批次/)
assert.match(viewSource, /supervision/)
assert.match(viewSource, /goalSource/)
assert.match(styleSource, /\.batch-tab:hover/)
assert.match(styleSource, /\.batch-tab:focus-visible/)
})
test('agent collaboration guide requires workflow batch metadata', async () => {
const thisFile = fileURLToPath(import.meta.url)
const agentGuidePath = new URL('../../../agent.md', `file://${thisFile}`)
const source = await readFile(agentGuidePath, 'utf8')
assert.match(source, /工作流批次/)
assert.match(source, /v1\.1 优化修复/)
assert.match(source, /阶段: 优化阶段 1/)
})
test('handoff timeline uses normalized id as stable key', async () => {
const thisFile = fileURLToPath(import.meta.url)
const componentPath = new URL('../components/HandoffTimeline.vue', `file://${thisFile}`)
const source = await readFile(componentPath, 'utf8')
assert.match(source, /:key="item\.id \|\|/)
})
test('keeps project threads when Codex cwd is an ancestor but metadata names the project path', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'thread-a',
cwd: '/Users/yoilun',
title: '处理 /Users/yoilun/Code/codex-agent-manager 的项目状态',
agentNickname: 'Nash',
agentRole: '代码审查员',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'thread-b',
cwd: '/Users/yoilun',
title: '处理其他项目',
agentNickname: '其他线程',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
assert.equal(projectRuntime.agents.length, 1)
assert.equal(projectRuntime.agents[0].id, 'thread-a')
assert.equal(projectRuntime.agents[0].projectPath, '/Users/yoilun')
})
test('does not match sibling projects that only share a path prefix', () => {
const runtime = normalizeRuntime({
items: [
{
id: 'sibling',
cwd: '/Users/yoilun',
title: '处理 /Users/yoilun/Code/codex-agent-manager-old 的项目状态',
agentNickname: '同名前缀项目',
source: { kind: 'sqlite_table', confidence: 'high' },
},
{
id: 'target',
cwd: '/Users/yoilun',
title: '处理 /Users/yoilun/Code/codex-agent-manager 当前工作树',
agentNickname: '目标项目',
source: { kind: 'sqlite_table', confidence: 'high' },
},
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
assert.deepEqual(projectRuntime.agents.map((agent) => agent.id), ['target'])
})
test('infers runtime status from Codex spawn edge state when thread status is absent', () => {
const runtime = normalizeRuntime({
items: [
{ id: 'parent', cwd: '/repo/a', agentNickname: '主控', source: { kind: 'sqlite_table', confidence: 'high' } },
{ id: 'child-open', cwd: '/repo/a', agentNickname: '运行审查员', source: { kind: 'sqlite_table', confidence: 'high' } },
{ id: 'child-closed', cwd: '/repo/a', agentNickname: '已完成审查员', source: { kind: 'sqlite_table', confidence: 'high' } },
],
edges: [
{ fromThreadId: 'parent', toThreadId: 'child-open', reason: 'open' },
{ fromThreadId: 'parent', toThreadId: 'child-closed', reason: 'closed' },
],
source: { kind: 'sqlite_readonly', confidence: 'high' },
})
const byId = new Map(runtime.agents.map((agent) => [agent.id, agent]))
assert.equal(byId.get('child-open').status, 'running')
assert.equal(byId.get('child-open').statusZh, '运行中')
assert.equal(byId.get('child-closed').status, 'complete')
assert.equal(byId.get('child-closed').statusZh, '已完成')
})
test('normalizes empty workflow with source evidence and no sample edges', () => {
const workflow = normalizeWorkflow({
items: [],

View File

@@ -6,12 +6,12 @@ defineProps({
<template>
<ol class="handoff-timeline">
<li v-for="item in items" :key="`${item.from}-${item.to}-${item.summary}`">
<li v-for="item in items" :key="item.id || `${item.from}-${item.to}-${item.summary}`">
<div class="timeline-marker" aria-hidden="true"></div>
<div>
<p class="timeline-title">{{ item.from }} {{ item.to }}</p>
<p>{{ item.summary }}</p>
<span>{{ item.time }} · 来源 {{ item.source }} · 置信度 {{ item.confidence }}</span>
<span>{{ item.directionLabel || '线程交接' }} · {{ item.time }} · 来源 {{ item.source }} · 置信度 {{ item.confidence }}</span>
</div>
</li>
</ol>

View File

@@ -47,7 +47,7 @@ button {
}
.app-shell {
width: min(1440px, 100%);
width: 100%;
margin: 0 auto;
padding: 28px;
}
@@ -260,6 +260,69 @@ button {
border-radius: 8px;
}
.project-flow {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
margin-top: 18px;
}
.batch-switcher {
grid-column: 1 / -1;
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.batch-tab {
flex: 0 0 auto;
min-width: 190px;
max-width: 280px;
padding: 10px 12px;
color: var(--green);
text-align: left;
background: var(--panel-muted);
border: 1px solid var(--line);
border-radius: 8px;
}
.batch-tab:hover {
border-color: var(--green);
box-shadow: 0 8px 20px rgb(36 139 107 / 12%);
}
.batch-tab strong,
.batch-tab span {
display: block;
}
.batch-tab span {
margin-top: 4px;
color: var(--muted);
font-size: 0.82rem;
}
.batch-tab.active {
color: var(--panel);
background: var(--green);
border-color: var(--green);
}
.batch-tab.active span {
color: color-mix(in srgb, var(--panel) 78%, white);
}
.flow-section {
min-width: 0;
padding-top: 16px;
border-top: 1px solid var(--line);
}
.compact-heading {
margin-bottom: 12px;
}
.matrix-row {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1fr) minmax(170px, 1fr) minmax(150px, 0.8fr) minmax(130px, 0.7fr);
@@ -278,6 +341,19 @@ button {
font-weight: 700;
}
.matrix-row.clickable {
cursor: pointer;
}
.matrix-row.clickable:hover,
.matrix-row.clickable.selected {
background: color-mix(in srgb, var(--green-soft) 62%, var(--panel));
}
.matrix-row.clickable.selected {
box-shadow: inset 4px 0 0 var(--green);
}
.matrix-row > span {
min-width: 0;
padding: 14px;
@@ -374,6 +450,13 @@ button {
border-top: 1px solid var(--line);
}
.detail-block span {
display: inline-block;
margin-top: 8px;
color: var(--muted);
font-size: 0.82rem;
}
.detail-grid {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
@@ -414,7 +497,9 @@ button {
}
.project-item[role="button"]:focus-visible,
.matrix-row[tabindex]:focus-visible,
.agent-list-item:focus-visible,
.batch-tab:focus-visible,
.tab-button:focus-visible {
outline: 3px solid color-mix(in srgb, var(--terracotta) 48%, transparent);
outline-offset: 2px;
@@ -422,7 +507,7 @@ button {
.workflow-layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
grid-template-columns: minmax(720px, 1fr) 340px;
gap: 16px;
align-items: start;
}
@@ -448,6 +533,14 @@ button {
margin: 0;
}
.phase-list.compact li {
grid-template-columns: 18px minmax(0, 1fr);
}
.phase-list.compact .status-badge {
grid-column: 2;
}
.phase-list li {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
@@ -518,6 +611,72 @@ button {
font-weight: 700;
}
.phase-handoff-groups {
display: grid;
gap: 14px;
}
.workflow-batches {
display: grid;
gap: 14px;
}
.workflow-batch {
display: grid;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.workflow-batch:first-child {
padding-top: 0;
border-top: 0;
}
.workflow-batch-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 10px;
}
.workflow-batch-heading span {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 0.82rem;
}
.phase-handoff-group {
display: grid;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.phase-handoff-group:first-child {
padding-top: 0;
border-top: 0;
}
.phase-handoff-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.phase-handoff-nested {
display: grid;
gap: 8px;
}
.muted-line {
margin: 0;
color: var(--muted);
line-height: 1.58;
}
.graph-list {
display: grid;
gap: 12px;
@@ -562,7 +721,7 @@ button {
.agent-layout {
display: grid;
grid-template-columns: 330px minmax(0, 1fr);
grid-template-columns: 330px minmax(720px, 1fr);
gap: 16px;
align-items: start;
}
@@ -721,12 +880,20 @@ button {
white-space: pre-wrap;
}
.drafts-layout,
.drafts-layout {
display: grid;
grid-template-columns: minmax(720px, 1fr) 340px;
gap: 16px;
align-items: start;
}
.settings-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 16px;
align-items: start;
max-width: 1440px;
margin: 0 auto;
}
.draft-list,
@@ -830,7 +997,7 @@ button {
line-height: 1.6;
}
@media (max-width: 1120px) {
@media (max-width: 1140px) {
.workspace-header,
.project-layout,
.workflow-layout,
@@ -871,6 +1038,7 @@ button {
.panel-heading.horizontal,
.draft-header,
.setting-row,
.project-flow,
.form-grid {
grid-template-columns: 1fr;
}

View File

@@ -140,10 +140,10 @@ async function writeDraft() {
</button>
</aside>
<section class="panel editor-panel" aria-label="智能体只读编辑区">
<section class="panel editor-panel" aria-label="字段预览与草稿编辑区">
<div class="panel-heading horizontal">
<div>
<p class="eyebrow">只读编辑区</p>
<p class="eyebrow">字段预览与草稿编辑区</p>
<h2>{{ selectedAgent?.name || '没有可显示的智能体' }}</h2>
</div>
<StatusBadge

View File

@@ -1,21 +1,59 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import HandoffTimeline from '../components/HandoffTimeline.vue'
import StatusBadge from '../components/StatusBadge.vue'
import { apiClient } from '../api/client'
import { normalizeProjects, normalizeRuntime } from '../api/normalizers'
import { agentMatrix as sampleAgentMatrix, projects as sampleProjects } from '../data'
import { filterRuntimeByProject, normalizeProjects, normalizeRuntime } from '../api/normalizers'
const loading = ref(true)
const error = ref('')
const projectState = ref(normalizeProjects())
const runtimeState = ref(normalizeRuntime())
const selectedProjectId = ref('')
const selectedAgentId = ref('')
const selectedWorkflowBatchName = ref('')
const selectedProject = computed(() => projectState.value.projects.find((project) => project.id === selectedProjectId.value))
const selectedAgent = computed(() => runtimeState.value.agents[0])
const projectRuntime = computed(() => filterRuntimeByProject(runtimeState.value, selectedProject.value?.path || ''))
const selectedAgent = computed(() =>
projectRuntime.value.agents.find((agent) => agent.id === selectedAgentId.value) ?? projectRuntime.value.agents[0],
)
const selectedWorkflowBatch = computed(() =>
projectRuntime.value.workflowBatches.find((batch) => batch.name === selectedWorkflowBatchName.value) ?? projectRuntime.value.workflowBatches[0],
)
onMounted(loadReadonlyData)
watch(
() => projectRuntime.value.agents.map((agent) => agent.id).join('|'),
() => {
if (!projectRuntime.value.agents.some((agent) => agent.id === selectedAgentId.value)) {
selectedAgentId.value = projectRuntime.value.agents[0]?.id ?? ''
}
},
)
watch(
() => projectRuntime.value.workflowBatches.map((batch) => batch.name).join('|'),
() => {
selectedWorkflowBatchName.value = projectRuntime.value.workflowBatches[0]?.name ?? ''
},
)
function selectProject(projectId) {
selectedProjectId.value = projectId
selectedAgentId.value = ''
selectedWorkflowBatchName.value = ''
}
function selectAgent(agent) {
selectedAgentId.value = agent.id
}
function selectWorkflowBatch(batch) {
selectedWorkflowBatchName.value = batch.name
}
async function loadReadonlyData() {
loading.value = true
error.value = ''
@@ -27,6 +65,8 @@ async function loadReadonlyData() {
projectState.value = normalizeProjects(projectsPayload)
runtimeState.value = normalizeRuntime(runtimePayload)
selectedProjectId.value = projectState.value.projects[0]?.id ?? ''
selectedAgentId.value = projectRuntime.value.agents[0]?.id ?? ''
selectedWorkflowBatchName.value = projectRuntime.value.workflowBatches[0]?.name ?? ''
} catch (err) {
error.value = err?.message || '连接后端接口失败'
} finally {
@@ -59,8 +99,8 @@ async function loadReadonlyData() {
:class="{ selected: project.id === selectedProjectId }"
role="button"
tabindex="0"
@click="selectedProjectId = project.id"
@keyup.enter="selectedProjectId = project.id"
@click="selectProject(project.id)"
@keyup.enter="selectProject(project.id)"
>
<div>
<strong>{{ project.name }}</strong>
@@ -79,16 +119,6 @@ async function loadReadonlyData() {
</dl>
</article>
</template>
<div v-if="error" class="sample-fallback">
<p class="eyebrow">示例 / 等待连接</p>
<article v-for="project in sampleProjects" :key="project.id" class="project-item">
<div>
<strong>示例{{ project.name }}</strong>
<span>{{ project.path }}</span>
</div>
<StatusBadge :label="project.statusZh" :status="project.status" source="示例数据" confidence="低" />
</article>
</div>
</aside>
<section class="panel matrix-panel" aria-label="项目状态矩阵">
@@ -103,15 +133,15 @@ async function loadReadonlyData() {
<div v-if="loading" class="load-state">加载中</div>
<div v-else-if="error" class="empty-state compact error-state">
<strong>连接失败</strong>
<p>无法读取 `/api/runtime/threads`下面仅显示明确标注的示例状态</p>
<p>无法读取 `/api/runtime/threads`当前不显示示例运行状态</p>
</div>
<div v-else-if="runtimeState.isEmpty" class="empty-state compact">
<strong>{{ runtimeState.emptyTitle }}</strong>
<p>{{ runtimeState.emptyText }}</p>
<div v-else-if="projectRuntime.isEmpty" class="empty-state compact">
<strong>{{ projectRuntime.emptyTitle }}</strong>
<p>{{ projectRuntime.emptyText }}</p>
<StatusBadge label="空数据" status="unknown" :source="runtimeState.source.label" :confidence="runtimeState.source.confidenceLabel" />
</div>
<div v-if="!loading && !error && !runtimeState.isEmpty" class="matrix-table" role="table" aria-label="智能体状态矩阵">
<div v-if="!loading && !error && !projectRuntime.isEmpty" class="matrix-table" role="table" aria-label="智能体状态矩阵">
<div class="matrix-row head" role="row">
<span role="columnheader">智能体</span>
<span role="columnheader">状态</span>
@@ -119,47 +149,118 @@ async function loadReadonlyData() {
<span role="columnheader">进程</span>
<span role="columnheader">最近活动</span>
</div>
<div v-for="agent in runtimeState.agents" :key="agent.id" class="matrix-row" role="row">
<div
v-for="agent in projectRuntime.agents"
:key="agent.id"
class="matrix-row clickable"
:class="{ selected: agent.id === selectedAgentId }"
role="row"
tabindex="0"
@click="selectAgent(agent)"
@keyup.enter="selectAgent(agent)"
>
<span role="cell">
<strong>{{ agent.name }}</strong>
<small>{{ agent.role }}</small>
<strong>{{ agent.displayName }}</strong>
<small>{{ agent.workflowBatch }} / {{ agent.phaseName }}</small>
</span>
<span role="cell">
<StatusBadge :label="agent.statusZh" :status="agent.status" :source="agent.source" :confidence="agent.confidence" />
</span>
<span role="cell">{{ agent.goal }}</span>
<span role="cell">
{{ agent.goal }}
<small>{{ agent.goalSource }}</small>
</span>
<span role="cell">{{ agent.process }}</span>
<span role="cell">{{ agent.lastActivity }}</span>
</div>
</div>
<div v-if="error" class="sample-fallback matrix-table" role="table" aria-label="示例智能体状态矩阵">
<div class="matrix-row head" role="row">
<span role="columnheader">示例智能体</span>
<span role="columnheader">状态</span>
<span role="columnheader">目标</span>
<span role="columnheader">进程</span>
<span role="columnheader">最近活动</span>
<div v-if="!loading && !error && !projectRuntime.isEmpty" class="project-flow">
<div class="batch-switcher" role="group" aria-label="批次切换">
<button
v-for="batch in projectRuntime.workflowBatches"
:key="batch.name"
type="button"
class="batch-tab"
:class="{ active: batch.name === selectedWorkflowBatch?.name }"
:aria-pressed="batch.name === selectedWorkflowBatch?.name"
@click="selectWorkflowBatch(batch)"
>
<strong>{{ batch.name }}</strong>
<span>{{ batch.phaseCount }} 阶段 · {{ batch.handoffCount }} 交接</span>
</button>
</div>
<div v-for="agent in sampleAgentMatrix" :key="agent.id" class="matrix-row" role="row">
<span role="cell">
<strong>示例{{ agent.name }}</strong>
<small>{{ agent.role }}</small>
</span>
<span role="cell">
<StatusBadge :label="agent.statusZh" :status="agent.status" source="示例数据" confidence="低" />
</span>
<span role="cell">{{ agent.goal }}</span>
<span role="cell">{{ agent.process }}</span>
<span role="cell">{{ agent.lastActivity }}</span>
<div class="flow-section">
<div class="panel-heading horizontal compact-heading">
<div>
<p class="eyebrow">阶段进度</p>
<h3>工作流批次与阶段</h3>
</div>
<span class="read-only-chip">{{ projectRuntime.workflowBatches.length }} 个批次</span>
</div>
<div v-if="selectedWorkflowBatch" class="workflow-batches">
<section class="workflow-batch">
<div class="workflow-batch-heading">
<div>
<strong>{{ selectedWorkflowBatch.name }}</strong>
<span>{{ selectedWorkflowBatch.phaseCount }} 个阶段 · {{ selectedWorkflowBatch.agentCount }} 个智能体 · {{ selectedWorkflowBatch.handoffCount }} 条交接</span>
</div>
<StatusBadge :label="selectedWorkflowBatch.statusZh" :status="selectedWorkflowBatch.status" source="SQLite 表" confidence="高" />
</div>
<ol class="phase-list compact">
<li v-for="phase in selectedWorkflowBatch.phases" :key="`${selectedWorkflowBatch.name}-${phase.name}`" :data-status="phase.status">
<div class="phase-dot" aria-hidden="true"></div>
<div>
<strong>{{ phase.name }}</strong>
<p>{{ phase.roles.join('、') || '未记录角色分类' }}</p>
<span>{{ phase.agents.map((agent) => agent.displayName).join('、') }}</span>
</div>
<StatusBadge :label="phase.statusZh" :status="phase.status" source="SQLite 表" confidence="高" />
</li>
</ol>
</section>
</div>
<div v-else class="empty-state compact">
<strong>没有阶段归属</strong>
<p>当前项目智能体线程没有可解析的阶段标记</p>
</div>
</div>
<div class="flow-section">
<div class="panel-heading horizontal compact-heading">
<div>
<p class="eyebrow">交互方向</p>
<h3>项目内智能体流程记录</h3>
</div>
<span class="read-only-chip">{{ selectedWorkflowBatch?.handoffCount ?? 0 }} 条交接</span>
</div>
<div v-if="selectedWorkflowBatch" class="phase-handoff-groups">
<section class="phase-handoff-group">
<div class="phase-handoff-heading">
<strong>{{ selectedWorkflowBatch.name }}</strong>
<StatusBadge :label="selectedWorkflowBatch.statusZh" :status="selectedWorkflowBatch.status" source="SQLite 表" confidence="高" />
</div>
<section v-for="phase in selectedWorkflowBatch.phases" :key="`${selectedWorkflowBatch.name}-${phase.name}`" class="phase-handoff-nested">
<strong>{{ phase.name }}</strong>
<HandoffTimeline v-if="phase.handoffs.length > 0" :items="phase.handoffs" />
<p v-else class="muted-line">这个阶段暂时没有交接边</p>
</section>
</section>
</div>
<div v-else class="empty-state compact">
<strong>没有交接记录</strong>
<p>当前项目没有匹配到主线程与智能体之间的交接边</p>
</div>
</div>
</div>
</section>
<aside class="panel detail-panel" aria-label="详情面板">
<div class="panel-heading">
<p class="eyebrow">详情</p>
<h2>{{ selectedAgent?.name || selectedProject?.name || '只读详情' }}</h2>
<h2>{{ selectedAgent?.displayName || selectedProject?.name || '只读详情' }}</h2>
</div>
<StatusBadge
:label="selectedAgent?.statusZh || selectedProject?.statusZh || (error ? '连接失败' : '等待数据')"
@@ -170,14 +271,22 @@ async function loadReadonlyData() {
<div class="detail-block">
<h3>角色摘要</h3>
<p>{{ selectedAgent?.goal || '选择项目或等待运行线程后显示真实目标。' }}</p>
<span v-if="selectedAgent?.goalSource">{{ selectedAgent.goalSource }}</span>
</div>
<div class="detail-block">
<h3>主智能体监管</h3>
<p>{{ projectRuntime.supervision.actor }}{{ projectRuntime.supervision.goal }}</p>
<span>{{ projectRuntime.supervision.statusZh }} · 最近活动 {{ projectRuntime.supervision.lastActivity }}</span>
</div>
<div class="detail-block">
<h3>证据说明</h3>
<p>数据来自只读接口连接失败时只显示明确标注的示例不会把示例伪装真实状态</p>
<p>数据来自只读接口连接失败时保持错误状态不使用示例数据伪装真实状态</p>
</div>
<div class="detail-grid">
<span>项目数</span><strong>{{ projectState.projects.length }}</strong>
<span>线程数</span><strong>{{ runtimeState.threads.length }}</strong>
<span>当前项目线程数</span><strong>{{ projectRuntime.threads.length }}</strong>
<span>子智能体数</span><strong>{{ projectRuntime.supervision.agentCount }}</strong>
<span>交接记录</span><strong>{{ projectRuntime.supervision.handoffCount }}</strong>
<span>接口</span><strong>{{ error ? '连接失败' : '只读连接' }}</strong>
</div>
</aside>

View File

@@ -9,9 +9,9 @@ import { settings } from '../data'
<div class="panel-heading horizontal">
<div>
<p class="eyebrow">设置</p>
<h2>只读配置摘要</h2>
<h2>安全配置摘要</h2>
</div>
<StatusBadge label="接口只读" status="complete" source="计划文件" confidence="中" />
<StatusBadge label="配置受控" status="complete" source="计划文件" confidence="中" />
</div>
<div class="settings-list">
@@ -29,13 +29,14 @@ import { settings } from '../data'
<aside class="panel">
<div class="panel-heading">
<p class="eyebrow">安全边界</p>
<h2>本阶段不会写回</h2>
<h2>仅校验后单文件写回</h2>
</div>
<ul class="safety-list">
<li>不读取或展示 `.codex/auth.json`</li>
<li>不写入 Codex SQLite</li>
<li>不保存智能体 TOML</li>
<li>真实数据通过本阶段只读接口展示</li>
<li>智能体 TOML 仅在校验备份和确认后写回当前文件</li>
<li>不自动保存或批量写回</li>
<li>项目运行线程和工作流仍通过只读接口展示</li>
</ul>
</aside>
</section>