sync: update from private repo (e62f5c7)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-11 01:52:48 +00:00
parent 000a2474aa
commit d061ad08d8
237 changed files with 8441 additions and 5549 deletions
+96
View File
@@ -0,0 +1,96 @@
<!-- DRAFT — English README skeleton for OSS. NOT the live README.
The live public README is oss/overlay/README.md (currently Japanese).
When ready: translate/expand this, make it oss/overlay/README.md,
and move the current JA content to oss/overlay/README.ja.md. -->
# MAESTRO
[English](README.md) | [日本語](README.ja.md)
<!-- badges: add CI/build, release, node version; keep license -->
![License](https://img.shields.io/badge/license-Apache--2.0-blue)
![Node](https://img.shields.io/badge/node-22%2B-brightgreen)
**MAESTRO** is an agent orchestration platform that runs tasks with LLMs. An LLM
classifies each task and routes it to the right workflow (a **Piece**); tools run
in a sandboxed runtime, and you manage workspaces, files, and progress from a web
UI. It works standalone against any OpenAI-compatible LLM endpoint
([Ollama](https://ollama.com/), vLLM, …).
<!-- TODO: add 1-2 screenshots here (task detail / settings), e.g.:
![Task detail](docs/assets/screenshot-task.png) -->
## Features
- **Automatic task routing** — an LLM classifies the request and dispatches it to
the best Piece (a YAML workflow).
- **Pieces × Movements** — a ReAct loop where the LLM and tools collaborate to
advance the task step by step.
- **Rich tool set** — files (Read/Write/Edit/Bash/Glob/Grep), Office
(PDF/Excel/Docx/PPTX), web fetch, browser automation (Playwright), images,
SQLite, knowledge search (RAG), SSH, parallel sub-tasks, MCP integration, more.
- **Bash sandbox** — bwrap-based filesystem/network/env isolation, with a hardened
whitelist fallback; Python packages are pre-baked.
- **Optional LLM Gateway** — a proxy with virtual keys, budgets, and metrics for
shared multi-GPU / multi-team use.
- **Reflection learning, scheduled tasks, task sharing, OAuth (Google/Gitea)** —
all opt-in.
- **Web UI** — create tasks, watch progress, preview artifacts, edit settings,
manage skills and Pieces.
## Quickstart
### Docker (fastest)
```bash
cp .env.example .env # set OLLAMA_BASE_URL / OLLAMA_MODEL
docker compose up -d
# open http://localhost:9876
```
Compose binds to `127.0.0.1:9876` only. Before exposing it to other hosts,
configure OAuth and put it behind a TLS reverse proxy.
> Linux note: `host.docker.internal` may not resolve by default. Use your host's
> gateway IP, add `extra_hosts: ["host.docker.internal:host-gateway"]` to
> compose, or point at the LAN IP of the machine running Ollama.
### From source
```bash
git clone <repo-url> maestro
cd maestro
npm ci && npm --prefix ui ci
npm run setup # interactive: configure the LLM endpoint
scripts/build-all.sh
scripts/server.sh start # http://localhost:9876
```
See **[docs/getting-started.md](docs/getting-started.md)** for the full walkthrough.
## Requirements
- **Node.js 22+**
- **An OpenAI-compatible LLM endpoint** (Ollama / vLLM / …). MAESTRO does not run
the model itself.
- Optional (Bash sandbox): `bwrap` (bubblewrap, unprivileged user namespaces) plus
`python3`/`pip`.
## Documentation
- **[docs/getting-started.md](docs/getting-started.md)** — install, first run, first task, auth/sandbox.
- **[docs/configuration.md](docs/configuration.md)** — full `config.yaml` reference.
- **[docs/architecture.md](docs/architecture.md)** — execution flow, Pieces/Movements, tools, DB, sandbox.
- **[docs/docker.md](docs/docker.md)** — Docker deployment (TODO: add).
- **[AGENTS.md](AGENTS.md)** / **[CONTRIBUTING.md](CONTRIBUTING.md)** — for contributors.
- **[SECURITY.md](SECURITY.md)** — security policy and reporting.
## Security
MAESTRO runs without auth by default — do not expose it directly to untrusted
networks. For multi-user or public deployments, enable OAuth,
`safety.bash_sandbox: always`, and a TLS reverse proxy. See [SECURITY.md](SECURITY.md).
## License
[Apache-2.0](LICENSE).
+24
View File
@@ -0,0 +1,24 @@
<!-- DRAFT / REDUNDANT STUB — for the OSS-readiness audit only.
A complete, public security policy ALREADY EXISTS at
oss/overlay/SECURITY.md (covers supported versions, private vulnerability
reporting, and a deployment-hardening baseline). It is sufficient for OSS.
This file exists only so the audit can point to "SECURITY coverage = yes"
and should be DELETED once the existing oss/overlay/SECURITY.md is accepted.
Do NOT publish this stub. -->
# Security Policy (DRAFT stub — see oss/overlay/SECURITY.md)
The authoritative security policy is **[oss/overlay/SECURITY.md](../oss/overlay/SECURITY.md)**,
which ships publicly as `SECURITY.md`. It covers:
- Supported versions (latest release + `main`).
- Private vulnerability reporting (no public issues for undisclosed vulns;
use the host's private reporting feature or contact the owner; 7-day ack).
- Deployment baseline (localhost until OAuth, TLS reverse proxy,
`safety.bash_sandbox: always`, secret hygiene, `/metrics` restriction,
tool/integration review).
No separate top-level `SECURITY.md` is needed. Delete this draft after confirming
the overlay policy.
+87
View File
@@ -0,0 +1,87 @@
[English](architecture.md) | 日本語
# Architecture Overview
MAESTRO は、ユーザーが投げたタスクを LLM 駆動のワークフロー(Piece)で実行する
エージェントオーケストレーターである。コントリビュータ向けのコードマップは
[../AGENTS.md](../AGENTS.md) も参照。
## 実行フロー
```
UI (POST /api/local/tasks)
→ bridge/server.ts (Express API)
→ Repository (SQLite: jobs テーブルに enqueue)
→ Worker.poll() が queued ジョブを取得
→ piece-classifier.ts: LLM がタスクを分類し Piece を選択
→ piece-runner.ts: pieces/*.yaml を読み、movements を順に実行
→ agent-loop.ts: 1 movement の ReAct ループ (LLM ↔ tool calls)
├─ 中間遷移: transition ツール
└─ 終了: complete ツール (success / aborted / needs_user_input)
→ ジョブ完了: DB 更新 + 進捗コメント。成果物は workspace/output/
```
1. **API 受付**`bridge/server.ts` がタスクを受け、`Repository` 経由で `jobs` テーブルに `queued` で登録する。
2. **ワーカー**`worker.ts` が DB をポーリングし、自分の `profiles`/`task_classes` に合致するジョブを取得(複数ワーカーが並走)。
3. **分類**`piece-classifier.ts` がタスク本文と全 Piece の description を LLM に渡し、最適な Piece を選ぶ。
4. **Piece 実行**`piece-runner.ts` が Piece の movements を順に回す。verify movement のフィードバックは次の execute に引き継がれ、`transition.lessons` で movement 間の教訓が蓄積される。
5. **ReAct ループ**`agent-loop.ts` が 1 movement 内で LLM とツールを往復させる。`ContextManager` が LLM の `usage` からトークン使用量を追跡し、閾値(70/85/95%)で warn / prompt / force_transition を発火する。
## Piece と Movement
- **Piece** = `pieces/*.yaml``movements` 配列で構成。
-**Movement**`allowed_tools`LLM に提示するツール)、`edit`Write/Edit 可否)、`rules`(遷移条件)を持つ。`allowed_tools` 外のツールは LLM から見えない。
- **遷移**: 中間ホップは `transition``rules[].next` に列挙した宛先のみ選択可)、終了は `complete``complete.result` がユーザーに見える唯一の最終出力。
- **`default_next`** はエンジン内部の sentinel(コンテキスト溢れ時の強制遷移、ASK 上限時のフォールバック)。
- **Progressive pressure**: 同一 movement への連続訪問が増えると警告を注入し、閾値超過で ABORT。
## ツールランタイム
ツールは `src/engine/tools/*.ts` のモジュール群。`tools/index.ts` が動的にロードし dispatch する。各ツールは 1 行の description(毎 LLM 呼び出しに乗るため簡潔に)を持ち、詳細手順は `docs/tools/<name>.md``ReadToolDoc` で取得)に置く。主なモジュールは [../AGENTS.md](../AGENTS.md#tool-modules) の一覧を参照。
Read 系ツールは並列実行される。Write/Edit は movement の `edit: true` のときのみ提示され、書き込みは主に `workspace/output/` に限られる。
## Bash サンドボックス
エージェントの Bash 実行は、利用可能なら **bwrap サンドボックス**で隔離する:
- **ファイルシステム**: タスクの workspace のみ rw bind、`/usr` 等は ro、他タスクの workspace やホスト `/home` は不可視。
- **環境変数**: `--clearenv` + 最小 allowlist のみ注入(シークレット env はサンドボックス内から見えない)。
- **ネットワーク**: `--unshare-net` で遮断(外向き通信は SSRF ガード付きの WebFetch/MCP に集約)。
- **各 Bash コールは独立**したサンドボックス(揮発 `/tmp`・毎回新名前空間)。永続するのは workspace のみ。
`safety.bash_sandbox` でモードを選ぶ(`auto`/`always`/`off`)。bwrap 不在時は **hardened フォールバック**(コマンド許可リスト + パススコープ検査 + env スクラブ付き exec)になる。実行時 `pip`/`npm install` は全モードで拒否され、Python パッケージは `runtime/python-requirements.txt` からプリベイクされる。詳細は [operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md)。
## ワークスペース構造(ジョブ実行時)
```
{worktree_dir}/local/{taskId}/
input/ アップロード・DownloadFile の保存先
output/ 成果物(Write/Edit が許可される主な場所)
logs/ activity.log / 各種履歴
subtasks/ SpawnSubTask の結果
skills/ ReadSkill で materialize されたスキルファイル
```
## データベース
SQLitebetter-sqlite3)。`db/schema.sql` が初期スキーマ。追加カラムは `db/migrate.ts`
`PRAGMA table_info` → 存在チェック → `ALTER TABLE ADD COLUMN` のパターンで冪等に適用する
(バージョン管理テーブルは使わない)。主なテーブル: `jobs` / `local_tasks` /
`local_task_comments` / `audit_log` ほか。
## ジョブのライフサイクル
`queued``dispatching``running``succeeded` / `failed` / `waiting_human`ASK 回答待ち)/ `waiting_subtasks`(並列サブタスク待ち)。失敗時は `retry` で再 `queued`(最大 `retry.max_attempts` 回)。
## オプションのサブシステム
- **LLM Gateway**`src/gateway/` — MAESTRO 自身を OpenAI 互換 LLM プロキシとして公開(仮想キー・予算・Prometheus メトリクス)。複数 GPU/チーム共有向け。env/接続種別が `AAO_*`/`aao_gateway` の歴史的接頭辞を使う。
- **MCP** — Model Context Protocol サーバー連携(`MCP_ENCRYPTION_KEY` 必須)。
- **Reflection** — ジョブ完了ごとにユーザーメモリを LLM が自動更新(既定 OFF、revert 可)。
- **認証** — Passport による Google/Gitea OAuth(任意)。`private`/`org`/`public` の可視性モデル。
- **スケジューラ** — cron 式の定期タスク。
## フロントエンド
React + Vite + TailwindCSS + @tanstack/react-query。`ui/src/App.tsx` がルート。2 カラム(list + detail)レイアウトで、タスク一覧・スケジュール・設定・スキル/Piece 管理を扱う。
+55 -54
View File
@@ -1,85 +1,86 @@
English | [日本語](architecture.ja.md)
# Architecture Overview
MAESTRO は、ユーザーが投げたタスクを LLM 駆動のワークフロー(Piece)で実行する
エージェントオーケストレーターである。コントリビュータ向けのコードマップは
[../AGENTS.md](../AGENTS.md) も参照。
MAESTRO is an agent orchestrator that runs the tasks a user submits with LLM-driven workflows (Pieces).
For a code map aimed at contributors, see also [../AGENTS.md](../AGENTS.md).
## 実行フロー
## Execution flow
```
UI (POST /api/local/tasks)
→ bridge/server.ts (Express API)
→ Repository (SQLite: jobs テーブルに enqueue)
→ Worker.poll() queued ジョブを取得
→ piece-classifier.ts: LLM がタスクを分類し Piece を選択
→ piece-runner.ts: pieces/*.yaml を読み、movements を順に実行
→ agent-loop.ts: 1 movement の ReAct ループ (LLM ↔ tool calls)
├─ 中間遷移: transition ツール
└─ 終了: complete ツール (success / aborted / needs_user_input)
ジョブ完了: DB 更新 + 進捗コメント。成果物は workspace/output/
→ Repository (SQLite: enqueue into the jobs table)
→ Worker.poll() picks up queued jobs
→ piece-classifier.ts: the LLM classifies the task and selects a Piece
→ piece-runner.ts: reads pieces/*.yaml and runs the movements in order
→ agent-loop.ts: the ReAct loop for one movement (LLM ↔ tool calls)
├─ intermediate transition: the transition tool
└─ termination: the complete tool (success / aborted / needs_user_input)
job complete: update DB + post a progress comment. Deliverables go to workspace/output/
```
1. **API 受付**`bridge/server.ts` がタスクを受け、`Repository` 経由で `jobs` テーブルに `queued` で登録する。
2. **ワーカー**`worker.ts` が DB をポーリングし、自分の `profiles`/`task_classes` に合致するジョブを取得(複数ワーカーが並走)。
3. **分類**`piece-classifier.ts` がタスク本文と全 Piece の description を LLM に渡し、最適な Piece を選ぶ。
4. **Piece 実行**`piece-runner.ts` Piece movements を順に回す。verify movement のフィードバックは次の execute に引き継がれ、`transition.lessons` で movement 間の教訓が蓄積される。
5. **ReAct ループ**`agent-loop.ts` が 1 movement 内で LLM とツールを往復させる。`ContextManager` LLM `usage` からトークン使用量を追跡し、閾値(70/85/95%)で warn / prompt / force_transition を発火する。
1. **API intake**`bridge/server.ts` receives the task and registers it in the `jobs` table as `queued` via the `Repository`.
2. **Worker**`worker.ts` polls the DB and picks up jobs matching its `profiles`/`task_classes` (multiple workers run in parallel).
3. **Classification**`piece-classifier.ts` passes the task body and the descriptions of all Pieces to the LLM and chooses the best-fit Piece.
4. **Piece execution**`piece-runner.ts` iterates through the Piece's movements in order. Feedback from a verify movement is carried over to the next execute, and lessons between movements accumulate via `transition.lessons`.
5. **ReAct loop**`agent-loop.ts` shuttles between the LLM and tools within one movement. `ContextManager` tracks token usage from the LLM's `usage` and fires warn / prompt / force_transition at thresholds (70/85/95%).
## Piece Movement
## Piece and Movement
- **Piece** = `pieces/*.yaml``movements` 配列で構成。
- **Movement** `allowed_tools`LLM に提示するツール)、`edit`Write/Edit 可否)、`rules`(遷移条件)を持つ。`allowed_tools` 外のツールは LLM から見えない。
- **遷移**: 中間ホップは `transition``rules[].next` に列挙した宛先のみ選択可)、終了は `complete``complete.result` がユーザーに見える唯一の最終出力。
- **`default_next`** はエンジン内部の sentinel(コンテキスト溢れ時の強制遷移、ASK 上限時のフォールバック)。
- **Progressive pressure**: 同一 movement への連続訪問が増えると警告を注入し、閾値超過で ABORT
- **Piece** = `pieces/*.yaml`. Composed of a `movements` array.
- Each **Movement** has `allowed_tools` (the tools presented to the LLM), `edit` (whether Write/Edit is allowed), and `rules` (transition conditions). Tools outside `allowed_tools` are invisible to the LLM.
- **Transitions**: an intermediate hop uses `transition` (only the destinations listed in `rules[].next` are selectable); termination uses `complete`. `complete.result` is the only final output visible to the user.
- **`default_next`** is an engine-internal sentinel (forced transition on context overflow, fallback at the ASK limit).
- **Progressive pressure**: as consecutive revisits to the same movement increase, warnings are injected, and exceeding the threshold triggers ABORT.
## ツールランタイム
## Tool runtime
ツールは `src/engine/tools/*.ts` のモジュール群。`tools/index.ts` が動的にロードし dispatch する。各ツールは 1 行の description(毎 LLM 呼び出しに乗るため簡潔に)を持ち、詳細手順は `docs/tools/<name>.md``ReadToolDoc` で取得)に置く。主なモジュールは [../AGENTS.md](../AGENTS.md#tool-modules) の一覧を参照。
Tools are a set of modules in `src/engine/tools/*.ts`. `tools/index.ts` loads and dispatches them dynamically. Each tool has a one-line description (kept concise because it rides on every LLM call), and detailed instructions live in `docs/tools/<name>.md` (fetched with `ReadToolDoc`). For the main modules, see the list in [../AGENTS.md](../AGENTS.md#tool-modules).
Read 系ツールは並列実行される。Write/Edit は movement `edit: true` のときのみ提示され、書き込みは主に `workspace/output/` に限られる。
Read-type tools run in parallel. Write/Edit is only presented when the movement has `edit: true`, and writes are mostly limited to `workspace/output/`.
## Bash サンドボックス
## Bash sandbox
エージェントの Bash 実行は、利用可能なら **bwrap サンドボックス**で隔離する:
The agent's Bash execution is isolated with the **bwrap sandbox** when available:
- **ファイルシステム**: タスクの workspace のみ rw bind、`/usr` 等は ro、他タスクの workspace やホスト `/home` は不可視。
- **環境変数**: `--clearenv` + 最小 allowlist のみ注入(シークレット env はサンドボックス内から見えない)。
- **ネットワーク**: `--unshare-net` で遮断(外向き通信は SSRF ガード付きの WebFetch/MCP に集約)。
- ** Bash コールは独立**したサンドボックス(揮発 `/tmp`・毎回新名前空間)。永続するのは workspace のみ。
- **Filesystem**: only the task's workspace is rw-bound, `/usr` etc. are ro, and other tasks' workspaces and the host `/home` are invisible.
- **Environment variables**: `--clearenv` + injecting only a minimal allowlist (secret env vars are invisible from inside the sandbox).
- **Network**: blocked with `--unshare-net` (outbound communication is consolidated into WebFetch/MCP with SSRF guards).
- **Each Bash call** gets an independent sandbox (volatile `/tmp`, a fresh namespace each time). Only the workspace persists.
`safety.bash_sandbox` でモードを選ぶ(`auto`/`always`/`off`)。bwrap 不在時は **hardened フォールバック**(コマンド許可リスト + パススコープ検査 + env スクラブ付き exec)になる。実行時 `pip`/`npm install` は全モードで拒否され、Python パッケージは `runtime/python-requirements.txt` からプリベイクされる。詳細は [operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md)
`safety.bash_sandbox` selects the mode (`auto`/`always`/`off`). When bwrap is absent, it falls back to a **hardened fallback** (an exec with a command allowlist + path-scope checks + env scrubbing). Runtime `pip`/`npm install` are rejected in all modes, and Python packages are pre-baked from `runtime/python-requirements.txt`. For details, see [operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md).
## ワークスペース構造(ジョブ実行時)
## Workspace structure (during job execution)
```
{worktree_dir}/local/{taskId}/
input/ アップロード・DownloadFile の保存先
output/ 成果物(Write/Edit が許可される主な場所)
logs/ activity.log / 各種履歴
subtasks/ SpawnSubTask の結果
skills/ ReadSkill で materialize されたスキルファイル
input/ where uploads and DownloadFile saves go
output/ deliverables (the main place where Write/Edit is allowed)
logs/ activity.log / various histories
subtasks/ results from SpawnSubTask
skills/ skill files materialized by ReadSkill
```
## データベース
## Database
SQLitebetter-sqlite3)。`db/schema.sql` が初期スキーマ。追加カラムは `db/migrate.ts`
`PRAGMA table_info`存在チェック`ALTER TABLE ADD COLUMN` のパターンで冪等に適用する
(バージョン管理テーブルは使わない)。主なテーブル: `jobs` / `local_tasks` /
`local_task_comments` / `audit_log` ほか。
SQLite (better-sqlite3). `db/schema.sql` is the initial schema. Additional columns are applied idempotently in `db/migrate.ts`
with the pattern `PRAGMA table_info`existence check`ALTER TABLE ADD COLUMN`
(no version-management table is used). Main tables: `jobs` / `local_tasks` /
`local_task_comments` / `audit_log`, and others.
## ジョブのライフサイクル
## Job lifecycle
`queued``dispatching``running``succeeded` / `failed` / `waiting_human`ASK 回答待ち)/ `waiting_subtasks`(並列サブタスク待ち)。失敗時は `retry` で再 `queued`(最大 `retry.max_attempts` 回)。
`queued``dispatching``running``succeeded` / `failed` / `waiting_human` (waiting for an ASK answer) / `waiting_subtasks` (waiting for parallel subtasks). On failure, `retry` re-queues it (up to `retry.max_attempts` times).
## オプションのサブシステム
## Optional subsystems
- **LLM Gateway**`src/gateway/`MAESTRO 自身を OpenAI 互換 LLM プロキシとして公開(仮想キー・予算・Prometheus メトリクス)。複数 GPU/チーム共有向け。env/接続種別が `AAO_*`/`aao_gateway` の歴史的接頭辞を使う。
- **MCP** — Model Context Protocol サーバー連携(`MCP_ENCRYPTION_KEY` 必須)。
- **Reflection** — ジョブ完了ごとにユーザーメモリを LLM が自動更新(既定 OFF、revert 可)。
- **認証** — Passport による Google/Gitea OAuth(任意)。`private`/`org`/`public` の可視性モデル。
- **スケジューラ** — cron 式の定期タスク。
- **LLM Gateway** (`src/gateway/`)exposes MAESTRO itself as an OpenAI-compatible LLM proxy (virtual keys, budgets, Prometheus metrics). For sharing across multiple GPUs/teams. Its env vars and connection type use the historical `AAO_*`/`aao_gateway` prefixes.
- **MCP** — Model Context Protocol server integration (`MCP_ENCRYPTION_KEY` required).
- **Reflection** — the LLM automatically updates user memory after each job completes (OFF by default, revertible).
- **Authentication** — Google/Gitea OAuth via Passport (optional). A `private`/`org`/`public` visibility model.
- **Scheduler** — cron-expression scheduled tasks.
## フロントエンド
## Frontend
React + Vite + TailwindCSS + @tanstack/react-query`ui/src/App.tsx` がルート。2 カラム(list + detail)レイアウトで、タスク一覧・スケジュール・設定・スキル/Piece 管理を扱う。
React + Vite + TailwindCSS + @tanstack/react-query. `ui/src/App.tsx` is the root. A two-column (list + detail) layout handles the task list, schedule, settings, and skill/Piece management.
+231
View File
@@ -0,0 +1,231 @@
[English](configuration.md) | 日本語
# Configuration Reference
MAESTRO は単一の `config.yaml``config.yaml.example` をコピーして作成)で設定する。
- **YAML キーは snake_case**`max_concurrency`)、コード内は camelCase。`src/config.ts``transformKeys` が変換する。
- 一部は**環境変数で上書き**できる([末尾参照](#environment-variable-overrides))。
- `config_version: 2` が現行スキーマ。
> 値の一次ソースは `config.yaml.example`(コメント付き)と `src/config.ts`。本リファレンスは各項目の意味をまとめたもの。
---
## `llm` — ジョブ実行時の LLM 接続
| キー | 既定 | 意味 |
|------|------|------|
| `timeout_minutes` | 10 | 1 リクエスト全体の上限(分)。 |
| `retry.max_attempts` | 3 | 429/5xx/一時接続失敗時の再試行回数。 |
| `retry.backoff_ms` | [2000,5000,15000] | 各再試行の待機(ms)。 |
| `retry.retryable_status` | [429,500,502,503,504] | 再試行対象の HTTP ステータス。 |
### `llm.workers[]` — ジョブ実行に使う接続先(必須)
| キー | 意味 |
|------|------|
| `id` | ワーカー識別子。 |
| `connection_type` | `direct`Ollama/vLLM 等の OpenAI 互換 backend に直結)/ `aao_gateway`(別 Gateway 経由、Gateway Key 必須)。 |
| `endpoint` | OpenAI 互換 API のベース URL(例 `http://localhost:11434/v1`)。 |
| `model` | 使用モデル名(ワーカーごとに明示。`default_model` は廃止)。 |
| `api_key` | `aao_gateway` 時の virtual key 等(任意)。 |
| `roles` | 用途フィルタ: `auto`/`fast`/`quality`/`title`/`reflection` 等。`[title]` のみ=タイトル生成専用。 |
| `max_concurrency` | このワーカーの並列度。 |
| `vlm` | `true` で画像入力対応(ReadImage は VLM ワーカーを優先)。 |
| `enabled` | 有効/無効。 |
### `llm.metrics` — Prometheus exporterworker 側)
| キー | 既定 | 意味 |
|------|------|------|
| `enabled` | true | `/metrics`bridge HTTP, 既定 `PORT=9876`)に mount。 |
| `prefix` | `aao_worker` | メトリクス名 prefix。 |
| `bearer_token` | — | 設定すると Bearer 認証必須(`env:NAME` 形式可)。 |
| `allowed_hosts` | localhost のみ | 許可元 IP。本番は bearer か allowlist を必ず設定。 |
---
## `gateway` — LLM Gateway サーバー(任意)
MAESTRO 自身を OpenAI 互換の LLM Gateway として公開する(仮想キー・予算・メトリクス付き、複数 GPU/チーム共有向け)。**env 変数や接続種別が `AAO_*` / `aao_gateway` という歴史的な接頭辞を使う点に注意(AAO = この Gateway 機能の旧称)。** 詳細は [aao-gateway-overview.md](aao-gateway-overview.md)。
| キー | 既定 | 意味 |
|------|------|------|
| `enabled` | false | true で同 process gateway が起動(hot reload 対応)。 |
| `listen_port` | 4000 | separate-deploy 時のみ。 |
| `request_timeout_sec` | 600 | 1 リクエスト全体(streaming 込み)。 |
| `upstream_timeout_sec` | 30 | 各 upstream の TTFB 上限。 |
| `shutdown_graceful_sec` | 30 | SIGTERM 後の SSE drain 上限。 |
| `backends[]` | — | `id`/`endpoint`/`model`/`max_slots`/`api_key`。model 厳密一致で routing。 |
| `virtual_keys[]` | — | bootstrap/backup 用(`key`/`team`/`allowed_models`/`tokens_budget`/`rate_limit_rpm`)。新規発行は admin API 推奨。 |
| `metrics` | enabled | `prefix: aao_gateway`、team/key_prefix/backend ラベル。auth 必須運用。 |
Virtual Key の発行・rotation は admin REST API`POST /api/admin/gateway/keys`)または UISettings → LLM → Gateway Server)で行う。
---
## `storage` — パス・容量
| キー | 既定 | 意味 |
|------|------|------|
| `worktree_dir` | ./data/workspaces | ジョブ作業ディレクトリのベース。 |
| `custom_pieces_dir` | — | リポジトリ内 `pieces/` に加えて読む Piece dir(任意)。 |
| `user_folder_root` | ./data/users | `{root}/{userId}/` に AGENTS.md/scripts/notes 等を保存。 |
| `task_upload_max_size_mb` | 50 | タスク/コメント body 上限(base64 込み。範囲 11000)。 |
| `trash_retention_days` | 30 | `trash/` の自動 sweep。0 で都度全削除。 |
---
## Execution — 並列度・上限・再試行
| キー | 既定 | 意味 |
|------|------|------|
| `concurrency` | 4 | 全ワーカー合算の最大並列ジョブ数(env `CONCURRENCY`)。 |
| `max_movements` | 200 | 1 ジョブ内の最大 movement 数(loop 防止)。 |
| `retry.max_attempts` | 3 | ジョブ失敗時の最大再試行。 |
| `retry.backoff_seconds` | [60,300,900] | 各 attempt 間の待機秒。 |
| `ask.max_per_job` | 2 | 1 ジョブの ASK(ユーザー質問)上限。 |
| `subtasks.max_depth` | 2 | SpawnSubTask のネスト最大深度。 |
| `subtasks.max_per_parent` | 10 | 1 ジョブが生成できるサブタスク最大数。 |
---
## `context` — LLM コンテキスト管理
| キー | 既定 | 意味 |
|------|------|------|
| `limit_tokens` | 自動取得→128000 | コンテキスト上限。省略時はプロバイダ API から自動取得。 |
| `thresholds[]` | 0.7=warn / 0.85=prompt / 0.95=force_transition | 使用率閾値ごとの動作。 |
---
## `safety` — 暴走防止・Bash サンドボックス
| キー | 既定 | 意味 |
|------|------|------|
| `max_iterations` | 200 | 1 movement 内の最大イテレーション。 |
| `max_revisits` | 3 | 同一 movement の最大再訪問。超過で ABORT。 |
| `prompt_guard_ratio` | 0.8 | プロンプトがコンテキスト上限の何%まで許容するか(0.5–0.95)。 |
| `history_summarization.enabled` | true | 古い turn を構造化要約に置換して粘る。 |
| `history_summarization.tail_turns` | 2 | 末尾何 turn を保護するか。 |
| `history_summarization.preserve_recent_budget` | 8000 | 末尾保護の最大トークン。 |
| `bash_unrestricted` | false | true で Bash のコマンド許可リストを撤廃(**サンドボックス機構は別途 `bash_sandbox` が制御**)。 |
| `bash_sandbox` | auto | Bash 隔離機構: `auto`(bwrap あれば使用、無ければ hardened-whitelist/ `always`(bwrap 強制・不在なら起動失敗、本番推奨)/ `off`(bwrap 不使用、env スクラブは維持)。詳細 [operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md)。 |
---
## `search_filter` — WebSearch の機密情報漏洩防止
| キー | 既定 | 意味 |
|------|------|------|
| `blocked_patterns[]` | — | 完全一致で検索クエリから除去するパターン。 |
| `auto_block.private_ip` | true | 10/172.16-31/192.168/127.* を自動ブロック。 |
| `auto_block.internal_domain` | true | `.local`/`.internal`/`.lan`/`.intranet`/`.corp`/`.home`。 |
| `auto_block.email` / `phone` | true | メール/電話番号。 |
---
## `browser` — BrowseWeb ランタイム
| キー | 既定 | 意味 |
|------|------|------|
| `page_timeout` | 60000 | ページ遷移 timeoutms)。 |
| `action_timeout` | 30000 | アクション timeoutms)。 |
| `captcha_solve` | skip | `skip` / `novnc`(人手 CAPTCHA 解決)。 |
| `max_captcha_pages` | 5 | CAPTCHA ページ上限。 |
| `channel` | chromium | `chromium`/`chrome`/`msedge`。 |
| `executable_path` | — | ブラウザ実行ファイル(channel と排他)。 |
---
## `tools` — ツール設定
UI 上は Web & Search / Browser / Media & Documents / External Services / Legacy Knowledge に分かれるが YAML は `tools` 1 ブロック。主な項目:
**Web & Search**: `searxng_url`(WebSearch フォールバック先), `webfetch_timeout`(sec), `websearch_timeout`, `webfetch_allowed_hosts[]`SSRF 例外: private IP/.local を許可する場合)。
**Media & Documents**: `vision_model`/`vision_base_url`/`vision_timeout`/`vision_max_tokens`ReadImage 用 VLM, `ocr_model`, `office_{excel,docx,pdf}_max_size_mb`(既定 10, `office_pptx_max_size_mb`50, `office_pptx_max_uncompressed_mb`200, zip-bomb 検知), `speech_server_url`/`speech_timeout`/`speech_language`
**External Services**: `x_*`Twitter/X CLI 連携: `x_cli_command`/`x_auth_token`/`x_ct0`/`x_proxy`/`x_download_*` 等), `google_maps_api_key`/`maps_timeout`(未設定で Nominatim/OSRM, `amazon_affiliate_tag`/`keepa_api_key`
**User scripts**: `user_scripts_enabled`RunUserScript。plain runtime は Node `--permission` で sandbox 化), `user_scripts_allow_userids[]`
**Legacy Knowledge**: `knowledge_service_url`(未設定で knowledge ツール無効), `knowledge_namespaces`namespace ごとの api_key)。新規 namespace は MCP 経由を推奨。
---
## `notes` — Shared Knowledge Notes 注入
`data/users/{userId}/notes/` のノートをシステムプロンプトに注入。`inject.per_note_max_kb`(日本語は 4 推奨), `inject.total_max_kb`, `inject.over_budget_strategy``truncate_last`/`skip_remaining`/`degrade_to_search`)。
---
## `auth` — 認証(任意)
未設定なら認証なしで動作。
| キー | 意味 |
|------|------|
| `session_secret` | セッション署名鍵(ランダム文字列)。 |
| `session_max_age` | セッション有効期間(ms, 既定 86400000=24h)。 |
| `secure_cookie` | HTTPS 環境では true。 |
| `admin_emails[]` | admin ロールにするメール。 |
| `primary_provider` | `google` / `gitea`(両方有効時に明示)。 |
| `providers.google` | `client_id`/`client_secret`/`callback_url`。 |
| `providers.gitea` | `client_id`/`client_secret`/`base_url`/`callback_url`。ログイン時に Gitea org を取得し可視性に利用。 |
---
## `branding`(任意)
`app_name`/`primary_color`/`login_page_title`/`logo_url`/`favicon_url`/`footer_text`。Settings → System → Brandingadmin)で GUI 編集可。`config.yaml``data/branding/` は gitignore 済み。
---
## `secrets`
`master_key_path`(既定 `./data/secrets/master.key`, 32 byte, 初回起動で自動生成・mode 0600)。SSH 鍵・MCP トークン等の暗号化に使う。
---
## `reflection` — 学習(既定 OFF
ON で毎ジョブ完了後にユーザーメモリを LLM が自動更新(snapshot は revert 可)。`enabled`, `max_memory_changes_per_job`(3), `piece_edit_cooldown_hours`(24), `snapshot_retention_days`(90), `per_user_daily_budget_tokens`(200000)。
---
## `mcp` — Model Context Protocol
サーバーは admin UIglobal/各ユーザー(self-hosted)で管理。**`MCP_ENCRYPTION_KEY` env64 hex)が必須。** `call_timeout_seconds`(60), `max_binary_size_mb`(20), `max_output_files_per_job`(10), `max_output_size_mb_per_job`(200), `tool_cache_ttl_seconds`(600), `oauth_pending_ttl_minutes`(10), `allow_private_addresses`private 網の MCP server 用、既定 false)。
---
## `ssh` — SSH ツール(既定 OFF
`enabled`, `allow_private_addresses`(global 既定、admin は per-connection grant 可), `call_timeout_seconds`(30), `max_output_bytes`(32768), `max_{upload,download}_size_mb`(100), `audit_retention_days`(90), `admin_bypasses_grants`(true), abuse 検知(`abuse_window_minutes`/`abuse_failure_threshold`/`abuse_lock_minutes`)。
**Interactive Console**`ssh.console`: `enabled`, `idle_timeout_seconds`(1800), `max_session_duration_seconds`(14400), `scrollback_bytes`(524288), `max_sessions_per_connection`(3) ほか。手順は [ssh.md](ssh.md)。
---
## `notifications.push` — Web PushV2, 任意)
HTTPS ホスティング必須(iOS は PWA インストール)。`enabled`, `vapid_subject`(RFC 8292), `vapid_current_path`(自動生成・mode 0600, `vapid_history_dir`, `payload_max_bytes`(3072, 上限 4096), `queue_concurrency`(8), `per_send_timeout_ms`(10000)。鍵ローテーション: `npm run vapid-rotate`
---
## Environment variable overrides
一部設定は環境変数で上書きできる:
| 環境変数 | 上書き対象 |
|----------|------------|
| `OLLAMA_BASE_URL` | LLM エンドポイント |
| `OLLAMA_MODEL` | モデル名 |
| `WORKTREE_DIR` | `storage.worktree_dir` |
| `CONCURRENCY` | `concurrency` |
| `DB_PATH` | SQLite DB パス |
| `PORT` | bridge HTTP ポート(既定 9876 |
| `LOG_LEVEL` | `debug`/`info`/`warn`/`error`(既定 info |
| `MCP_ENCRYPTION_KEY` | MCP/SSH 秘密の暗号化鍵(MCP 利用時必須) |
+124 -122
View File
@@ -1,229 +1,231 @@
English | [日本語](configuration.ja.md)
# Configuration Reference
MAESTRO は単一の `config.yaml``config.yaml.example` をコピーして作成)で設定する。
MAESTRO is configured with a single `config.yaml` (created by copying `config.yaml.example`).
- **YAML キーは snake_case**`max_concurrency`)、コード内は camelCase`src/config.ts``transformKeys` が変換する。
- 一部は**環境変数で上書き**できる([末尾参照](#environment-variable-overrides))。
- `config_version: 2` が現行スキーマ。
- **YAML keys are snake_case** (`max_concurrency`), camelCase in the code. `transformKeys` in `src/config.ts` converts them.
- Some can be **overridden by environment variables** ([see the end](#environment-variable-overrides)).
- `config_version: 2` is the current schema.
> 値の一次ソースは `config.yaml.example`(コメント付き)と `src/config.ts`。本リファレンスは各項目の意味をまとめたもの。
> The primary source for values is `config.yaml.example` (with comments) and `src/config.ts`. This reference summarizes the meaning of each option.
---
## `llm` — ジョブ実行時の LLM 接続
## `llm` — LLM connection for job execution
| キー | 既定 | 意味 |
| Key | Default | Meaning |
|------|------|------|
| `timeout_minutes` | 10 | 1 リクエスト全体の上限(分)。 |
| `retry.max_attempts` | 3 | 429/5xx/一時接続失敗時の再試行回数。 |
| `retry.backoff_ms` | [2000,5000,15000] | 各再試行の待機(ms)。 |
| `retry.retryable_status` | [429,500,502,503,504] | 再試行対象の HTTP ステータス。 |
| `timeout_minutes` | 10 | Overall limit per request (minutes). |
| `retry.max_attempts` | 3 | Number of retries on 429/5xx/transient connection failures. |
| `retry.backoff_ms` | [2000,5000,15000] | Wait per retry (ms). |
| `retry.retryable_status` | [429,500,502,503,504] | HTTP statuses eligible for retry. |
### `llm.workers[]` — ジョブ実行に使う接続先(必須)
### `llm.workers[]` — connection targets used for job execution (required)
| キー | 意味 |
| Key | Meaning |
|------|------|
| `id` | ワーカー識別子。 |
| `connection_type` | `direct`Ollama/vLLM 等の OpenAI 互換 backend に直結)/ `aao_gateway`(別 Gateway 経由、Gateway Key 必須)。 |
| `endpoint` | OpenAI 互換 API のベース URL(例 `http://localhost:11434/v1`)。 |
| `model` | 使用モデル名(ワーカーごとに明示。`default_model` は廃止)。 |
| `api_key` | `aao_gateway` 時の virtual key 等(任意)。 |
| `roles` | 用途フィルタ: `auto`/`fast`/`quality`/`title`/`reflection` 等。`[title]` のみ=タイトル生成専用。 |
| `max_concurrency` | このワーカーの並列度。 |
| `vlm` | `true` で画像入力対応(ReadImage は VLM ワーカーを優先)。 |
| `enabled` | 有効/無効。 |
| `id` | Worker identifier. |
| `connection_type` | `direct` (connect directly to an OpenAI-compatible backend such as Ollama/vLLM) / `aao_gateway` (via a separate Gateway, Gateway Key required). |
| `endpoint` | Base URL of the OpenAI-compatible API (e.g. `http://localhost:11434/v1`). |
| `model` | Model name to use (specify explicitly per worker; `default_model` is removed). |
| `api_key` | Virtual key etc. for `aao_gateway` (optional). |
| `roles` | Purpose filter: `auto`/`fast`/`quality`/`title`/`reflection`, etc. `[title]` only = dedicated to title generation. |
| `max_concurrency` | This worker's concurrency. |
| `vlm` | `true` enables image input (ReadImage prefers VLM workers). |
| `enabled` | Enabled/disabled. |
### `llm.metrics` — Prometheus exporterworker 側)
### `llm.metrics` — Prometheus exporter (worker side)
| キー | 既定 | 意味 |
| Key | Default | Meaning |
|------|------|------|
| `enabled` | true | `/metrics`bridge HTTP, 既定 `PORT=9876`)に mount。 |
| `prefix` | `aao_worker` | メトリクス名 prefix |
| `bearer_token` | — | 設定すると Bearer 認証必須(`env:NAME` 形式可)。 |
| `allowed_hosts` | localhost のみ | 許可元 IP。本番は bearer allowlist を必ず設定。 |
| `enabled` | true | Mounted on `/metrics` (bridge HTTP, default `PORT=9876`). |
| `prefix` | `aao_worker` | Metric name prefix. |
| `bearer_token` | — | When set, Bearer auth is required (the `env:NAME` form is allowed). |
| `allowed_hosts` | localhost only | Permitted source IPs. In production, always set a bearer or allowlist. |
---
## `gateway` — LLM Gateway サーバー(任意)
## `gateway` — LLM Gateway server (optional)
MAESTRO 自身を OpenAI 互換の LLM Gateway として公開する(仮想キー・予算・メトリクス付き、複数 GPU/チーム共有向け)。**env 変数や接続種別が `AAO_*` / `aao_gateway` という歴史的な接頭辞を使う点に注意(AAO = この Gateway 機能の旧称)。** 詳細は [aao-gateway-overview.md](aao-gateway-overview.md)
Exposes MAESTRO itself as an OpenAI-compatible LLM Gateway (with virtual keys, budgets, and metrics, for sharing across multiple GPUs/teams). **Note that its env vars and connection type use the historical `AAO_*` / `aao_gateway` prefix (AAO = the old name of this Gateway feature).** For details, see [aao-gateway-overview.md](aao-gateway-overview.md).
| キー | 既定 | 意味 |
| Key | Default | Meaning |
|------|------|------|
| `enabled` | false | true で同 process gateway が起動(hot reload 対応)。 |
| `listen_port` | 4000 | separate-deploy 時のみ。 |
| `request_timeout_sec` | 600 | 1 リクエスト全体(streaming 込み)。 |
| `upstream_timeout_sec` | 30 | 各 upstream の TTFB 上限。 |
| `shutdown_graceful_sec` | 30 | SIGTERM 後の SSE drain 上限。 |
| `backends[]` | — | `id`/`endpoint`/`model`/`max_slots`/`api_key`。model 厳密一致で routing。 |
| `virtual_keys[]` | — | bootstrap/backup 用(`key`/`team`/`allowed_models`/`tokens_budget`/`rate_limit_rpm`)。新規発行は admin API 推奨。 |
| `metrics` | enabled | `prefix: aao_gateway`team/key_prefix/backend ラベル。auth 必須運用。 |
| `enabled` | false | true starts the gateway in the same process (supports hot reload). |
| `listen_port` | 4000 | Only for separate-deploy. |
| `request_timeout_sec` | 600 | Per whole request (including streaming). |
| `upstream_timeout_sec` | 30 | TTFB limit per upstream. |
| `shutdown_graceful_sec` | 30 | SSE drain limit after SIGTERM. |
| `backends[]` | — | `id`/`endpoint`/`model`/`max_slots`/`api_key`. Routing by exact model match. |
| `virtual_keys[]` | — | For bootstrap/backup (`key`/`team`/`allowed_models`/`tokens_budget`/`rate_limit_rpm`). Issuing new ones via the admin API is recommended. |
| `metrics` | enabled | `prefix: aao_gateway`, team/key_prefix/backend labels. Run with auth required. |
Virtual Key の発行・rotation は admin REST API`POST /api/admin/gateway/keys`)または UISettings → LLM → Gateway Server)で行う。
Issue and rotate Virtual Keys via the admin REST API (`POST /api/admin/gateway/keys`) or the UI (Settings → LLM → Gateway Server).
---
## `storage` — パス・容量
## `storage` — paths and capacity
| キー | 既定 | 意味 |
| Key | Default | Meaning |
|------|------|------|
| `worktree_dir` | ./data/workspaces | ジョブ作業ディレクトリのベース。 |
| `custom_pieces_dir` | — | リポジトリ内 `pieces/` に加えて読む Piece dir(任意)。 |
| `user_folder_root` | ./data/users | `{root}/{userId}/` AGENTS.md/scripts/notes 等を保存。 |
| `task_upload_max_size_mb` | 50 | タスク/コメント body 上限(base64 込み。範囲 11000)。 |
| `trash_retention_days` | 30 | `trash/` の自動 sweep。0 で都度全削除。 |
| `worktree_dir` | ./data/workspaces | Base for job working directories. |
| `custom_pieces_dir` | — | A Piece dir read in addition to the in-repo `pieces/` (optional). |
| `user_folder_root` | ./data/users | Stores AGENTS.md/scripts/notes etc. under `{root}/{userId}/`. |
| `task_upload_max_size_mb` | 50 | Upper limit for task/comment body (including base64; range 11000). |
| `trash_retention_days` | 30 | Auto-sweep of `trash/`. 0 deletes everything each time. |
---
## Execution — 並列度・上限・再試行
## Execution — concurrency, limits, retry
| キー | 既定 | 意味 |
| Key | Default | Meaning |
|------|------|------|
| `concurrency` | 4 | 全ワーカー合算の最大並列ジョブ数(env `CONCURRENCY`)。 |
| `max_movements` | 200 | 1 ジョブ内の最大 movement 数(loop 防止)。 |
| `retry.max_attempts` | 3 | ジョブ失敗時の最大再試行。 |
| `retry.backoff_seconds` | [60,300,900] | attempt 間の待機秒。 |
| `ask.max_per_job` | 2 | 1 ジョブの ASK(ユーザー質問)上限。 |
| `subtasks.max_depth` | 2 | SpawnSubTask のネスト最大深度。 |
| `subtasks.max_per_parent` | 10 | 1 ジョブが生成できるサブタスク最大数。 |
| `concurrency` | 4 | Max concurrent jobs across all workers (env `CONCURRENCY`). |
| `max_movements` | 200 | Max movements within one job (loop prevention). |
| `retry.max_attempts` | 3 | Max retries when a job fails. |
| `retry.backoff_seconds` | [60,300,900] | Wait seconds between each attempt. |
| `ask.max_per_job` | 2 | ASK (user question) limit per job. |
| `subtasks.max_depth` | 2 | Max nesting depth of SpawnSubTask. |
| `subtasks.max_per_parent` | 10 | Max subtasks one job can spawn. |
---
## `context` — LLM コンテキスト管理
## `context` — LLM context management
| キー | 既定 | 意味 |
| Key | Default | Meaning |
|------|------|------|
| `limit_tokens` | 自動取得→128000 | コンテキスト上限。省略時はプロバイダ API から自動取得。 |
| `thresholds[]` | 0.7=warn / 0.85=prompt / 0.95=force_transition | 使用率閾値ごとの動作。 |
| `limit_tokens` | auto-fetched→128000 | Context limit. When omitted, auto-fetched from the provider API. |
| `thresholds[]` | 0.7=warn / 0.85=prompt / 0.95=force_transition | Action per usage-ratio threshold. |
---
## `safety` — 暴走防止・Bash サンドボックス
## `safety` — runaway prevention and Bash sandbox
| キー | 既定 | 意味 |
| Key | Default | Meaning |
|------|------|------|
| `max_iterations` | 200 | 1 movement 内の最大イテレーション。 |
| `max_revisits` | 3 | 同一 movement の最大再訪問。超過で ABORT |
| `prompt_guard_ratio` | 0.8 | プロンプトがコンテキスト上限の何%まで許容するか(0.50.95)。 |
| `history_summarization.enabled` | true | 古い turn を構造化要約に置換して粘る。 |
| `history_summarization.tail_turns` | 2 | 末尾何 turn を保護するか。 |
| `history_summarization.preserve_recent_budget` | 8000 | 末尾保護の最大トークン。 |
| `bash_unrestricted` | false | true で Bash のコマンド許可リストを撤廃(**サンドボックス機構は別途 `bash_sandbox` が制御**)。 |
| `bash_sandbox` | auto | Bash 隔離機構: `auto`bwrap あれば使用、無ければ hardened-whitelist/ `always`(bwrap 強制・不在なら起動失敗、本番推奨)/ `off`(bwrap 不使用、env スクラブは維持)。詳細 [operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md) |
| `max_iterations` | 200 | Max iterations within one movement. |
| `max_revisits` | 3 | Max revisits to the same movement. Exceeding it triggers ABORT. |
| `prompt_guard_ratio` | 0.8 | Up to what % of the context limit the prompt is allowed to use (0.50.95). |
| `history_summarization.enabled` | true | Replace old turns with a structured summary to hang on longer. |
| `history_summarization.tail_turns` | 2 | How many trailing turns to protect. |
| `history_summarization.preserve_recent_budget` | 8000 | Max tokens for tail protection. |
| `bash_unrestricted` | false | true removes Bash's command allowlist (**the sandbox mechanism is controlled separately by `bash_sandbox`**). |
| `bash_sandbox` | auto | Bash isolation mechanism: `auto` (use bwrap if present, otherwise hardened-whitelist) / `always` (force bwrap; fail to start if absent, recommended for production) / `off` (do not use bwrap; env scrubbing is kept). Details: [operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md). |
---
## `search_filter` — WebSearch の機密情報漏洩防止
## `search_filter` — preventing sensitive-info leakage in WebSearch
| キー | 既定 | 意味 |
| Key | Default | Meaning |
|------|------|------|
| `blocked_patterns[]` | — | 完全一致で検索クエリから除去するパターン。 |
| `auto_block.private_ip` | true | 10/172.16-31/192.168/127.* を自動ブロック。 |
| `auto_block.internal_domain` | true | `.local`/`.internal`/`.lan`/`.intranet`/`.corp`/`.home` |
| `auto_block.email` / `phone` | true | メール/電話番号。 |
| `blocked_patterns[]` | — | Patterns removed from the search query by exact match. |
| `auto_block.private_ip` | true | Automatically block 10/172.16-31/192.168/127.*. |
| `auto_block.internal_domain` | true | `.local`/`.internal`/`.lan`/`.intranet`/`.corp`/`.home`. |
| `auto_block.email` / `phone` | true | Email/phone number. |
---
## `browser` — BrowseWeb ランタイム
## `browser` — BrowseWeb runtime
| キー | 既定 | 意味 |
| Key | Default | Meaning |
|------|------|------|
| `page_timeout` | 60000 | ページ遷移 timeoutms)。 |
| `action_timeout` | 30000 | アクション timeoutms)。 |
| `captcha_solve` | skip | `skip` / `novnc`(人手 CAPTCHA 解決)。 |
| `max_captcha_pages` | 5 | CAPTCHA ページ上限。 |
| `channel` | chromium | `chromium`/`chrome`/`msedge` |
| `executable_path` | — | ブラウザ実行ファイル(channel と排他)。 |
| `page_timeout` | 60000 | Page navigation timeout (ms). |
| `action_timeout` | 30000 | Action timeout (ms). |
| `captcha_solve` | skip | `skip` / `novnc` (manual CAPTCHA solving). |
| `max_captcha_pages` | 5 | CAPTCHA page limit. |
| `channel` | chromium | `chromium`/`chrome`/`msedge`. |
| `executable_path` | — | Browser executable (mutually exclusive with channel). |
---
## `tools` — ツール設定
## `tools` — tool settings
UI 上は Web & Search / Browser / Media & Documents / External Services / Legacy Knowledge に分かれるが YAML は `tools` 1 ブロック。主な項目:
In the UI these are split into Web & Search / Browser / Media & Documents / External Services / Legacy Knowledge, but in YAML they are a single `tools` block. Main items:
**Web & Search**: `searxng_url`WebSearch フォールバック先), `webfetch_timeout`(sec), `websearch_timeout`, `webfetch_allowed_hosts[]`SSRF 例外: private IP/.local を許可する場合)。
**Web & Search**: `searxng_url` (WebSearch fallback target), `webfetch_timeout` (sec), `websearch_timeout`, `webfetch_allowed_hosts[]` (SSRF exceptions: when allowing private IP/.local).
**Media & Documents**: `vision_model`/`vision_base_url`/`vision_timeout`/`vision_max_tokens`ReadImage 用 VLM, `ocr_model`, `office_{excel,docx,pdf}_max_size_mb`(既定 10, `office_pptx_max_size_mb`50, `office_pptx_max_uncompressed_mb`200, zip-bomb 検知), `speech_server_url`/`speech_timeout`/`speech_language`
**Media & Documents**: `vision_model`/`vision_base_url`/`vision_timeout`/`vision_max_tokens` (VLM for ReadImage), `ocr_model`, `office_{excel,docx,pdf}_max_size_mb` (default 10), `office_pptx_max_size_mb` (50), `office_pptx_max_uncompressed_mb` (200, zip-bomb detection), `speech_server_url`/`speech_timeout`/`speech_language`.
**External Services**: `x_*`Twitter/X CLI 連携: `x_cli_command`/`x_auth_token`/`x_ct0`/`x_proxy`/`x_download_*` 等), `google_maps_api_key`/`maps_timeout`(未設定で Nominatim/OSRM, `amazon_affiliate_tag`/`keepa_api_key`
**External Services**: `x_*` (Twitter/X CLI integration: `x_cli_command`/`x_auth_token`/`x_ct0`/`x_proxy`/`x_download_*` etc.), `google_maps_api_key`/`maps_timeout` (Nominatim/OSRM when unset), `amazon_affiliate_tag`/`keepa_api_key`.
**User scripts**: `user_scripts_enabled`RunUserScriptplain runtime Node `--permission` で sandbox 化), `user_scripts_allow_userids[]`
**User scripts**: `user_scripts_enabled` (RunUserScript; the plain runtime is sandboxed with Node `--permission`), `user_scripts_allow_userids[]`.
**Legacy Knowledge**: `knowledge_service_url`(未設定で knowledge ツール無効), `knowledge_namespaces`namespace ごとの api_key)。新規 namespace は MCP 経由を推奨。
**Legacy Knowledge**: `knowledge_service_url` (knowledge tools disabled when unset), `knowledge_namespaces` (per-namespace api_key). For new namespaces, using MCP is recommended.
---
## `notes` — Shared Knowledge Notes 注入
## `notes` — Shared Knowledge Notes injection
`data/users/{userId}/notes/` のノートをシステムプロンプトに注入。`inject.per_note_max_kb`(日本語は 4 推奨), `inject.total_max_kb`, `inject.over_budget_strategy``truncate_last`/`skip_remaining`/`degrade_to_search`)。
Injects notes from `data/users/{userId}/notes/` into the system prompt. `inject.per_note_max_kb` (4 recommended for Japanese), `inject.total_max_kb`, `inject.over_budget_strategy` (`truncate_last`/`skip_remaining`/`degrade_to_search`).
---
## `auth` — 認証(任意)
## `auth` — authentication (optional)
未設定なら認証なしで動作。
Runs without authentication when unset.
| キー | 意味 |
| Key | Meaning |
|------|------|
| `session_secret` | セッション署名鍵(ランダム文字列)。 |
| `session_max_age` | セッション有効期間(ms, 既定 86400000=24h)。 |
| `secure_cookie` | HTTPS 環境では true。 |
| `admin_emails[]` | admin ロールにするメール。 |
| `primary_provider` | `google` / `gitea`(両方有効時に明示)。 |
| `providers.google` | `client_id`/`client_secret`/`callback_url` |
| `providers.gitea` | `client_id`/`client_secret`/`base_url`/`callback_url`。ログイン時に Gitea org を取得し可視性に利用。 |
| `session_secret` | Session signing key (a random string). |
| `session_max_age` | Session lifetime (ms, default 86400000=24h). |
| `secure_cookie` | true in HTTPS environments. |
| `admin_emails[]` | Emails granted the admin role. |
| `primary_provider` | `google` / `gitea` (specify explicitly when both are enabled). |
| `providers.google` | `client_id`/`client_secret`/`callback_url`. |
| `providers.gitea` | `client_id`/`client_secret`/`base_url`/`callback_url`. Fetches Gitea orgs at login and uses them for visibility. |
---
## `branding`(任意)
## `branding` (optional)
`app_name`/`primary_color`/`login_page_title`/`logo_url`/`favicon_url`/`footer_text`Settings → System → Brandingadmin)で GUI 編集可。`config.yaml``data/branding/` gitignore 済み。
`app_name`/`primary_color`/`login_page_title`/`logo_url`/`favicon_url`/`footer_text`. Editable via GUI at Settings → System → Branding (admin). `config.yaml` and `data/branding/` are gitignored.
---
## `secrets`
`master_key_path`(既定 `./data/secrets/master.key`, 32 byte, 初回起動で自動生成・mode 0600)。SSH 鍵・MCP トークン等の暗号化に使う。
`master_key_path` (default `./data/secrets/master.key`, 32 bytes, auto-generated on first launch with mode 0600). Used to encrypt SSH keys, MCP tokens, etc.
---
## `reflection` — 学習(既定 OFF
## `reflection` — learning (OFF by default)
ON で毎ジョブ完了後にユーザーメモリを LLM が自動更新(snapshot revert 可)。`enabled`, `max_memory_changes_per_job`(3), `piece_edit_cooldown_hours`(24), `snapshot_retention_days`(90), `per_user_daily_budget_tokens`(200000)
When ON, the LLM automatically updates user memory after each job completes (snapshots are revertible). `enabled`, `max_memory_changes_per_job` (3), `piece_edit_cooldown_hours` (24), `snapshot_retention_days` (90), `per_user_daily_budget_tokens` (200000).
---
## `mcp` — Model Context Protocol
サーバーは admin UIglobal/各ユーザー(self-hosted)で管理。**`MCP_ENCRYPTION_KEY` env64 hex)が必須。** `call_timeout_seconds`(60), `max_binary_size_mb`(20), `max_output_files_per_job`(10), `max_output_size_mb_per_job`(200), `tool_cache_ttl_seconds`(600), `oauth_pending_ttl_minutes`(10), `allow_private_addresses`private 網の MCP server 用、既定 false)。
Servers are managed in the admin UI (global) / per user (self-hosted). **The `MCP_ENCRYPTION_KEY` env (64 hex) is required.** `call_timeout_seconds` (60), `max_binary_size_mb` (20), `max_output_files_per_job` (10), `max_output_size_mb_per_job` (200), `tool_cache_ttl_seconds` (600), `oauth_pending_ttl_minutes` (10), `allow_private_addresses` (for MCP servers on a private network, default false).
---
## `ssh` — SSH ツール(既定 OFF
## `ssh` — SSH tool (OFF by default)
`enabled`, `allow_private_addresses`(global 既定、admin は per-connection grant 可), `call_timeout_seconds`(30), `max_output_bytes`(32768), `max_{upload,download}_size_mb`(100), `audit_retention_days`(90), `admin_bypasses_grants`(true), abuse 検知(`abuse_window_minutes`/`abuse_failure_threshold`/`abuse_lock_minutes`)。
`enabled`, `allow_private_addresses` (global default; admins can grant per-connection), `call_timeout_seconds` (30), `max_output_bytes` (32768), `max_{upload,download}_size_mb` (100), `audit_retention_days` (90), `admin_bypasses_grants` (true), abuse detection (`abuse_window_minutes`/`abuse_failure_threshold`/`abuse_lock_minutes`).
**Interactive Console**`ssh.console`: `enabled`, `idle_timeout_seconds`(1800), `max_session_duration_seconds`(14400), `scrollback_bytes`(524288), `max_sessions_per_connection`(3) ほか。手順は [ssh.md](ssh.md)
**Interactive Console** (`ssh.console`): `enabled`, `idle_timeout_seconds` (1800), `max_session_duration_seconds` (14400), `scrollback_bytes` (524288), `max_sessions_per_connection` (3), and others. For the procedure, see [ssh.md](ssh.md).
---
## `notifications.push` — Web PushV2, 任意)
## `notifications.push` — Web Push (V2, optional)
HTTPS ホスティング必須(iOS は PWA インストール)。`enabled`, `vapid_subject`(RFC 8292), `vapid_current_path`(自動生成・mode 0600, `vapid_history_dir`, `payload_max_bytes`(3072, 上限 4096), `queue_concurrency`(8), `per_send_timeout_ms`(10000)。鍵ローテーション: `npm run vapid-rotate`
HTTPS hosting required (iOS requires a PWA install). `enabled`, `vapid_subject` (RFC 8292), `vapid_current_path` (auto-generated, mode 0600), `vapid_history_dir`, `payload_max_bytes` (3072, max 4096), `queue_concurrency` (8), `per_send_timeout_ms` (10000). Key rotation: `npm run vapid-rotate`.
---
## Environment variable overrides
一部設定は環境変数で上書きできる:
Some settings can be overridden by environment variables:
| 環境変数 | 上書き対象 |
| Environment variable | Overrides |
|----------|------------|
| `OLLAMA_BASE_URL` | LLM エンドポイント |
| `OLLAMA_MODEL` | モデル名 |
| `OLLAMA_BASE_URL` | LLM endpoint |
| `OLLAMA_MODEL` | Model name |
| `WORKTREE_DIR` | `storage.worktree_dir` |
| `CONCURRENCY` | `concurrency` |
| `DB_PATH` | SQLite DB パス |
| `PORT` | bridge HTTP ポート(既定 9876 |
| `LOG_LEVEL` | `debug`/`info`/`warn`/`error`(既定 info |
| `MCP_ENCRYPTION_KEY` | MCP/SSH 秘密の暗号化鍵(MCP 利用時必須) |
| `DB_PATH` | SQLite DB path |
| `PORT` | bridge HTTP port (default 9876) |
| `LOG_LEVEL` | `debug`/`info`/`warn`/`error` (default info) |
| `MCP_ENCRYPTION_KEY` | Encryption key for MCP/SSH secrets (required when using MCP) |
-151
View File
@@ -1,151 +0,0 @@
# Agent Orchestrator — Design System
A local, single-tenant agent orchestration platform. Users submit natural-language tasks via a small Japanese-language admin UI; an LLM classifier routes each task into a named **Piece** (workflow), which runs a multi-step **Movement** chain against local Ollama workers and emits progress/results back to the UI as chat-style comments.
## Product at a glance
- **One product, one surface:** a React + Vite + TailwindCSS admin dashboard at `/ui/` on the orchestrator server (`http://this-machine:9876/ui/`). Mobile (single-column), tablet (list + chat), desktop (list + chat + detail) layouts.
- **Language:** Japanese throughout (UI labels, empty states, toasts). Latin/mono treatment reserved for identifiers, status keywords, version tags, wordmark.
- **Primary objects:** Task → Job → Movement → Tool call. Status kanban: `queued / running / waiting_human / waiting_subtasks / retry / succeeded / failed / cancelled`.
- **Interaction model:** a threaded chat with progress cards interleaved between user requests and agent results (green = result, amber = ASK, blue bubble = user, grey pill card = progress).
## Sources consulted
- **Codebase (read-only local mount):** `gitea-agent-orchestrator/`
- `ui/tailwind.config.js`, `ui/src/index.css` — tokens, fonts
- `ui/src/lib/utils.ts``statusTone()`, `relativeTime()`, activity parsing
- `ui/src/components/**` — TopBar, FilterBar, TaskListItem, ChatPane, ChatMessage, DetailHeader, OverviewTab, ProgressTab, CreateTaskDialog, EmptyState, StatChip, StatusBadge, LoadingSpinner
- `ui/public/favicon.svg` — the "hub + 3 agents" mark
- `README.md` (root) — product narrative, piece list, tool registry
- **No Figma, no slide templates, no marketing site** were provided; this design system documents the existing admin UI only.
## Index
| File | Purpose |
|---|---|
| `README.md` | This document |
| `SKILL.md` | Agent Skill wrapper (portable to Claude Code) |
| `colors_and_type.css` | CSS variables for color/type/spacing/radii/shadow |
| `assets/logo.svg` | 32×32 brand mark (blue rounded square, orchestrator hub + 3 agent nodes) |
| `assets/wordmark.svg` | Logo + "AGENT ORCHESTRATOR" mono wordmark lockup |
| `preview/*.html` | Atomic design-system cards (registered in review) |
| `ui_kits/admin/` | High-fidelity React recreation of the admin dashboard |
---
## Content fundamentals
**Voice.** Terse, functional, Japanese. Almost no marketing flourish. Labels are nouns or imperative verbs — "新しい依頼" (new request), "Task 作成" (create Task), "詳細" (details), "送信" (send), "共有停止" (stop sharing).
**Person.** Neither "あなた" nor "私" — the UI talks about objects, not people. Empty state uses a numbered list of actions ("左パネルからタスクを選択する" — "select a task from the left panel") rather than second-person.
**Case & casing.**
- Japanese sentences where there's human prose.
- English identifiers kept English, capitalized as in code: **Tasks / Schedules / Settings / Users** (TopBar navs), **Inbox / Running / Waiting / Subtasks / Retry / Done / Failed / Cancelled** (status columns). These are not translated, even in a Japanese UI.
- Meta labels are SMALL-CAPS UPPERCASE with wide tracking in the mono face: `STEP`, `TOOL`, `PREVIEW`, `FINAL`, `ASK`, `LOG`.
- "Agent Orchestrator" wordmark: uppercase, mono, letter-spacing: 0.16em, blue-600.
- Product name "agent-orchestrator" in kebab-case in docs; UI header is Title Case.
**Example strings** (verbatim from code):
- `新しい依頼` (new request) — primary CTA
- `スレッドを選択してください` (please select a thread) — empty state title
- `左の一覧から選ぶと、会話、進捗、成果物を追えます。` — empty state description
- `メッセージを入力... (Ctrl+Enter で送信)` — composer placeholder
- `まだ進行情報がありません。` (no progress info yet)
- `(activity.log がまだありません)` — empty log fallback
- `良かった` / `改善が必要` — feedback thumbs labels
**Numbers & units.** Counts render as `<bold number> 件` (items), `<bold number> 実行中` (running), `<bold number> 待機` (waiting). Relative time in Japanese: `たった今 / N分前 / N時間前 / N日前`. Durations mix units: `ms`, `s`, `m Ns`.
**Tone.** Operator-facing, not consumer-facing. No exclamation marks, no emoji in UI chrome. Emoji appear only as **domain signals inside agent content**: 👍 / 👎 on feedback buttons, 📋 on checklist progress cards. Unicode symbols (☑ ✗ ⊘ ☐ ▶) are used as list markers inside agent-emitted checklists. Do not introduce new emoji outside these established spots.
---
## Visual foundations
**Palette.** A near-monochromatic slate neutral scale (Tailwind `slate-50…900`) carrying the entire surface, with **#2563eb (blue-600)** as the only brand color for primary actions, active states, focus rings, and the logo. Semantic status pills add pastel bg / deep fg pairs: green (running/success), amber (waiting/retry), indigo (subtasks), red (failed), blue (succeeded/queued edge cases), slate (queued/cancelled). All defined verbatim in `statusTone()` in `ui/src/lib/utils.ts`.
**Type.** `IBM Plex Sans JP` for everything, `IBM Plex Mono` for identifiers, log output, version tags, wordmark, cron expressions, and the micro-label uppercase treatment. Body is **13px** — small and dense. Titles jump to 18px (detail) or 20px (dialog); chat bubbles are 14px leading-relaxed. Mobile forces input `font-size: 16px !important` to prevent iOS auto-zoom.
- *Font substitution note:* IBM Plex is already loaded from Google Fonts in the codebase; no substitution required.
**Weight.** Heavy. 700 ("bold") is the workhorse — buttons, pill labels, status chips, even 10px/11px micro labels. 800 ("extra-bold") is reserved for titles and primary CTAs. 400/500 appear in body copy only.
**Spacing.** Tailwind 4px scale. Gutters between panels are `8px` (`p-2 gap-2`). Cards pad `16px` (`p-4`). Buttons pad `6px 10px` (small chips), `8px 16px` (primary). The desktop layout is a 3-column grid: `clamp(240px, 22vw, 280px)` list / flexible chat / `clamp(280px, 26vw, 440px)` detail (or `clamp(360px, 30vw, 560px)` wide).
**Backgrounds.** Flat solid colors — **never gradients**. App root is `#f3f6fb` (between slate-50 and slate-100); content cards are white. Activity log switches to an inverted surface: `bg-slate-900 text-slate-100` as a "terminal" zone. No illustrations, no patterns, no photos, no blur/glassmorphism.
**Animation.** Minimal and purposeful. Only three motion idioms:
1. `transition-colors` on hover/active states (Tailwind default ~150ms).
2. `animate-pulse` on a 2px blue dot while a job is running.
3. `animate-spin` on the loading spinner (2px slate-200 border, blue-600 top-border).
No fades, no slides, no springs, no bounces. Expand/collapse caret rotates 90° (`rotate-90`) on click.
**Hover states.** Buttons and list items darken one step: transparent → `bg-slate-100`, `bg-blue-600``bg-blue-700`, border-slate-200 → border-slate-300. Text links: `hover:underline` only on small text actions. No scale, no shadow-lift.
**Press / active states.** Active navigation uses **filled accent**: `bg-blue-600 text-white`. Active filter pills use **tinted** style: `border-blue-600 bg-blue-50 text-blue-700`. Active list item: `border-blue-500 bg-blue-50`. No shrink, no darken-further.
**Focus.** `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500`. Inputs on focus: `focus:border-blue-400 focus:ring-2 focus:ring-blue-100`.
**Borders.** 1px, `#e2e8f0` (slate-200) by default. Active states promote to `slate-300` (hover) or `blue-500/600` (selected). Chat bubbles have no border on the user (blue-filled) bubble but do on ask/result bubbles to soften the pastel.
**Shadows.** Two tiers only:
- `shadow-sm` — resting card (every panel, chip, list item). Nearly invisible, but cards in Vite have it.
- `shadow-2xl` — Dialog overlay only.
No colored shadows, no inner shadows, no glows.
**Radii (heavy rounding).** The product reads "rounded" first, flat second.
- `rounded-lg` (8px): small selects, small buttons, rotating caret container, log surface.
- `rounded-xl` (12px): **default** — cards, buttons, inputs, list items, panels.
- `rounded-2xl` (16px): dialogs, chat bubbles (tail reduced to `rounded-br-md` / `rounded-bl-md`).
- `rounded-full` (9999px): status pills, filter tabs, avatar, pulse dot.
**Transparency & blur.** Modal overlay uses `bg-slate-900/50` or `bg-black/40` — straight alpha, no backdrop-blur. No frosted chrome anywhere.
**Cards.** White fill, 1px slate-200 border, `rounded-xl`, `shadow-sm`. Padded `p-3` or `p-4`. The admin list panel is itself a card; list items inside are nested mini-cards with the same recipe.
**Chat bubbles.**
- User: `rounded-2xl rounded-br-md`, `bg-blue-600 text-white`, `shadow`.
- Agent ASK: `rounded-2xl rounded-bl-md`, `bg-amber-50 border border-amber-200`, `shadow-sm`.
- Agent RESULT: `rounded-2xl rounded-bl-md`, `bg-green-50 border border-green-200`, `shadow-sm`, renders Markdown.
- Progress card: centered, `bg-slate-50 border border-slate-200 rounded-xl`, 12px slate-500 text, click-to-expand.
**Protection gradients vs capsules.** Never gradients. Always capsules/pills for chips and status, always rectangular cards for containers.
**Imagery vibe.** The brand has no photography. The single branded image is the `favicon.svg`: a rounded blue square with a white central "hub" and three satellite nodes connected by thin 55%-opacity white lines — a literal orchestrator-connecting-workers glyph. Keep this as the only decorative asset unless explicitly asked.
**Layout rules.** Fixed TopBar at top (white, slate-200 bottom border). Main content fills remaining `h-dvh` and is `overflow-hidden` at the root — panels handle their own scroll. Toasts slide in at the top-center (`mx-4 mt-2`). Dialogs center-screen, `max-width: min(860px, 92vw)`, `max-height: 88dvh`, scroll internally.
---
## Iconography
**No icon font, no icon library dependency.** Icons are inline SVG written directly in components, drawn at `w-3.5 h-3.5`, `w-4 h-4`, or `w-5 h-5`, stroke-based, `stroke-width="2"`, `strokeLinecap="round"`, `strokeLinejoin="round"`, `fill="none"`. Style is close to **Lucide / Feather** — 2px stroke, round caps, 24×24 viewBox, minimal. Example glyphs in source: magnifying-glass (search), paperclip (attach), cross (close), VNC monitor square, chevron-right caret.
**Substitution policy.** When extending the system, prefer **Lucide** (`https://unpkg.com/lucide-static`) or hand-write a 2px-stroke, round-cap, 24×24 SVG inline. Do **not** introduce Heroicons solid, Material, or Phosphor — those break the line-weight consistency.
**Unicode glyphs** are used functionally inside agent-generated content:
- `☑ ✗ ⊘ ☐` — checklist item states (done / failed / skipped / pending)
- `▶` — expand caret (rotates to down)
- `×` — close buttons within attachment chips
- `✕` — mobile dialog close
- `+` / `` — add/create indicators
- `·` — meta separator (`worker: … · mode: …`)
**Emoji.** Deliberately limited:
- 👍 / 👎 — feedback rating only
- 📋 — checklist progress card header only
Do not introduce new emoji. When agent markdown renders emoji, the `prose` plugin styles them at 14px inline — do not restyle.
**Logo usage.** The 32×32 favicon is the only mark. Minimum size 16×16. Clear space: `x/4` on all sides where `x` is the square's edge. Do not recolor the blue fill; if placing on blue, invert to a white square with blue contents.
---
## Component notes (see `ui_kits/admin/`)
The admin UI kit recreates: TopBar, FilterBar, TaskListItem, TaskListPanel, ChatPane, ChatMessage (user/ask/result/progress variants), DetailPanel with tab pills, StatusBadge, StatChip, EmptyState, LoadingSpinner, CreateTaskDialog, and the composite desktop 3-column layout.
## Caveats
- No external brand guide, marketing site, or Figma was provided — this system is derived strictly from the live admin UI source.
- No printed/decorative imagery exists in the codebase; the only "brand asset" is the favicon logo.
- Fonts load from Google Fonts CDN (same as production); no local TTFs needed.
-128
View File
@@ -1,128 +0,0 @@
/* Agent Orchestrator — Colors & Type
Extracted from gitea-agent-orchestrator/ui (Tailwind config + index.css + usage).
Palette: slate neutrals + #2563eb blue accent, with pastel semantic chips.
*/
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&family=IBM+Plex+Sans+JP:wght@400;500;600;700;800&display=swap');
:root {
/* —— Brand / Accent ———————————————————————— */
--accent: #2563eb; /* blue-600 — primary action, active tab, focus ring */
--accent-deep: #1d4ed8; /* blue-700 — hover on primary button */
--accent-50: #eff6ff;
--accent-100: #dbeafe;
--accent-200: #bfdbfe;
--accent-500: #3b82f6;
--accent-700: #1d4ed8;
/* —— Ink / Neutrals (slate scale) ————————————— */
--ink: #0f172a; /* slate-900 — primary text */
--muted: #64748b; /* slate-500 — secondary text */
--slate-50: #f8fafc; /* body surface */
--slate-100: #f1f5f9; /* scrollbar track, chip bg */
--slate-200: #e2e8f0; /* default border */
--slate-300: #cbd5e1;
--slate-400: #94a3b8;
--slate-500: #64748b;
--slate-600: #475569;
--slate-700: #334155;
--slate-800: #1e293b;
--slate-900: #0f172a;
--app-bg: #f3f6fb; /* root bg (index.html) */
/* —— Semantic status tones (from statusTone()) ————
bg / fg pairs used in status pills and cards. */
--status-running-bg: #dcfce7; --status-running-fg: #166534; /* green */
--status-waiting-bg: #fef9c3; --status-waiting-fg: #854d0e; /* amber */
--status-subtasks-bg: #e0e7ff; --status-subtasks-fg: #3730a3; /* indigo */
--status-failed-bg: #fee2e2; --status-failed-fg: #b91c1c; /* red */
--status-succeeded-bg: #dbeafe; --status-succeeded-fg: #1e40af; /* blue */
--status-retry-bg: #fef3c7; --status-retry-fg: #92400e; /* amber-deep */
--status-queued-bg: #e2e8f0; --status-queued-fg: #475569; /* slate */
/* Message bubbles (ChatMessage.tsx) */
--bubble-user-bg: #2563eb; --bubble-user-fg: #ffffff;
--bubble-ask-bg: #fffbeb; --bubble-ask-border: #fde68a; /* amber-50/200 */
--bubble-result-bg: #f0fdf4; --bubble-result-border: #bbf7d0; /* green-50/200 */
/* Log / terminal surface (ProgressTab) */
--log-bg: #0f172a;
--log-fg: #f1f5f9;
/* —— Typography ——————————————————————————— */
--font-sans: 'IBM Plex Sans JP', 'Hiragino Sans', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'IBM Plex Mono', ui-monospace, Menlo, monospace;
/* UI base is small & dense — 13px body, mobile auto-zooms to 16. */
--fs-10: 10px; /* micro labels, version tag */
--fs-11: 11px; /* meta, pill labels, timestamps */
--fs-12: 12px; /* secondary body, nav labels */
--fs-13: 13px; /* DEFAULT body */
--fs-14: 14px; /* chat bubble body */
--fs-15: 15px; /* chat header */
--fs-18: 18px; /* detail title */
--fs-20: 20px; /* dialog title (xl) */
--fw-regular: 400;
--fw-medium: 500;
--fw-bold: 700; /* used aggressively — even small chips are bold */
--fw-extra: 800; /* titles and headers */
/* —— Radii (very rounded) ————————————————————— */
--radius-sm: 8px; /* rounded-lg — small buttons, selects, log surface */
--radius-md: 12px; /* rounded-xl — DEFAULT card/button/input — everywhere */
--radius-lg: 16px; /* rounded-2xl — dialogs, chat bubbles */
--radius-pill: 9999px; /* status pills, filter tabs, avatar */
/* —— Shadow system ————————————————————————— */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); /* card resting */
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); /* modal */
/* —— Spacing scale (Tailwind units × 4) ————————— */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
/* —— Borders ——————————————————————————————— */
--border-default: 1px solid var(--slate-200);
--border-active: 1px solid var(--accent);
/* —— Semantic aliases ———————————————————————— */
--fg-1: var(--slate-900);
--fg-2: var(--slate-600);
--fg-3: var(--slate-500);
--fg-muted: var(--slate-400);
--bg-1: #ffffff;
--bg-2: var(--slate-50);
--bg-app: var(--app-bg);
--border: var(--slate-200);
}
/* —— Base type roles ————————————————————————— */
html, body { font-family: var(--font-sans); color: var(--fg-1); background: var(--bg-app); }
body { font-size: var(--fs-13); }
.h1 { font-size: var(--fs-20); font-weight: var(--fw-extra); color: var(--fg-1); letter-spacing: -0.01em; }
.h2 { font-size: var(--fs-18); font-weight: var(--fw-extra); color: var(--fg-1); }
.h3 { font-size: var(--fs-15); font-weight: var(--fw-bold); color: var(--fg-1); }
.h4 { font-size: var(--fs-13); font-weight: var(--fw-bold); color: var(--fg-1); }
.p { font-size: var(--fs-13); color: var(--fg-2); line-height: 1.55; }
.meta { font-size: var(--fs-11); color: var(--fg-3); }
.micro { font-size: var(--fs-10); color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.08em; font-weight: var(--fw-bold); }
.mono { font-family: var(--font-mono); }
.code { font-family: var(--font-mono); font-size: var(--fs-12); background: var(--slate-100); padding: 2px 6px; border-radius: 6px; }
/* The signature "Agent Orchestrator" wordmark style used in TopBar */
.wordmark {
font-family: var(--font-mono);
font-size: var(--fs-11);
font-weight: var(--fw-bold);
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.16em;
}
@@ -1,128 +0,0 @@
// ChatPane — mirrors ui/src/components/chat/* with user/ask/result/progress bubbles
function Bubble({ role, children, footer }) {
const isUser = role === 'user';
const style = {
maxWidth: '85%',
padding: '10px 14px',
borderRadius: 16,
fontSize: 13,
lineHeight: 1.55,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
};
if (isUser) {
Object.assign(style, { background: '#2563eb', color: '#fff', borderBottomRightRadius: 4, alignSelf: 'flex-end' });
} else if (role === 'ask') {
Object.assign(style, { background: '#fef9c3', color: '#854d0e', border: '1px solid #fde68a', borderBottomLeftRadius: 4 });
} else if (role === 'result') {
Object.assign(style, { background: '#ecfdf5', color: '#065f46', border: '1px solid #a7f3d0', borderBottomLeftRadius: 4 });
} else {
Object.assign(style, { background: '#fff', color: '#0f172a', border: '1px solid #e2e8f0', borderBottomLeftRadius: 4 });
}
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: isUser ? 'flex-end' : 'flex-start', gap: 4 }}>
<div style={style}>{children}</div>
{footer && <div style={{ fontSize: 10, color: '#94a3b8' }}>{footer}</div>}
</div>
);
}
function ProgressBubble({ text }) {
return (
<div style={{
alignSelf: 'flex-start', background: '#f1f5f9', color: '#475569',
padding: '8px 12px', borderRadius: 12, fontSize: 12,
display: 'inline-flex', alignItems: 'center', gap: 8,
}}>
<Spinner />
<span>{text}</span>
</div>
);
}
function ChatHeader({ task, onOpenDetail, detailOpen }) {
return (
<div style={{
flexShrink: 0, padding: '12px 16px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>
TASK #{task.id}
</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.title}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<StatusBadge status={task.status} />
<button onClick={onOpenDetail} style={{
padding: '6px 10px', borderRadius: 8, border: '1px solid #e2e8f0',
background: detailOpen ? '#eff6ff' : '#fff',
color: detailOpen ? '#1d4ed8' : '#475569',
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>詳細</button>
</div>
</div>
);
}
function Composer({ onSend }) {
const [text, setText] = React.useState('');
const send = () => { if (!text.trim()) return; onSend(text.trim()); setText(''); };
return (
<div style={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', background: '#fff', padding: 12 }}>
<div style={{
display: 'flex', alignItems: 'flex-end', gap: 8, background: '#f8fafc',
border: '1px solid #e2e8f0', borderRadius: 12, padding: 8,
}}>
<button style={{ padding: 6, background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer' }}>
<IconAttach width={16} height={16} />
</button>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); } }}
rows={2}
placeholder="メッセージを入力 (⌘+Enter で送信)"
style={{
flex: 1, resize: 'none', border: 'none', outline: 'none', background: 'transparent',
fontFamily: 'inherit', fontSize: 13, color: '#0f172a', lineHeight: 1.5, minHeight: 32,
}}
/>
<button onClick={send} style={{
padding: '6px 14px', background: '#2563eb', color: '#fff', borderRadius: 8,
fontSize: 12, fontWeight: 700, border: 'none', cursor: text.trim() ? 'pointer' : 'not-allowed',
opacity: text.trim() ? 1 : 0.5, fontFamily: 'inherit',
}}>送信</button>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#94a3b8', paddingLeft: 4 }}>エージェントは常に /brainstorm /plan /implement のパイプラインで動作します</div>
</div>
);
}
function ChatPane({ task, messages, onSend, onOpenDetail, detailOpen }) {
const scrollRef = React.useRef(null);
React.useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages.length, task.id]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#f8fafc' }}>
<ChatHeader task={task} onOpenDetail={onOpenDetail} detailOpen={detailOpen} />
<div ref={scrollRef} style={{
flex: 1, overflowY: 'auto', padding: '16px 20px',
display: 'flex', flexDirection: 'column', gap: 12, minHeight: 0,
}}>
{messages.map((m, i) => (
m.role === 'progress'
? <ProgressBubble key={i} text={m.content} />
: <Bubble key={i} role={m.role} footer={m.footer}>{m.content}</Bubble>
))}
</div>
<Composer onSend={onSend} />
</div>
);
}
window.ChatPane = ChatPane;
@@ -1,134 +0,0 @@
// DetailPanel — tabs: overview, progress (activity + log surface)
function Tabs({ tab, onTab }) {
const items = [
{ id: 'overview', label: '概要' },
{ id: 'progress', label: '進捗' },
{ id: 'subtasks', label: 'サブタスク' },
];
return (
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid #e2e8f0', background: '#fff' }}>
{items.map(it => (
<button key={it.id} onClick={() => onTab(it.id)} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
background: tab === it.id ? '#eff6ff' : 'transparent',
color: tab === it.id ? '#1d4ed8' : '#64748b',
}}>{it.label}</button>
))}
</div>
);
}
function OverviewTab({ task }) {
const Row = ({ label, value }) => (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, padding: '8px 0', borderBottom: '1px solid #f1f5f9', fontSize: 12 }}>
<span style={{ color: '#64748b' }}>{label}</span>
<span style={{ color: '#0f172a', fontWeight: 600, textAlign: 'right' }}>{value}</span>
</div>
);
return (
<div style={{ padding: 16, overflowY: 'auto', fontSize: 13, color: '#0f172a' }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>DESCRIPTION</div>
<div style={{ marginTop: 6, color: '#334155', lineHeight: 1.6, fontSize: 13 }}>{task.body}</div>
<div style={{ marginTop: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<StatChip label="試行" value={`${task.attempts}/3`} />
<StatChip label="ピース" value={task.piece} color="#2563eb" />
<StatChip label="ワーカー" value={task.worker || '—'} />
</div>
<div style={{ marginTop: 16 }}>
<Row label="リポジトリ" value={<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 11 }}>{task.repo}</span>} />
<Row label="ブランチ" value={<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 11 }}>{task.branch}</span>} />
<Row label="作成日時" value={new Date(task.createdAt).toLocaleString('ja-JP')} />
<Row label="更新日時" value={relativeTime(task.updatedAt)} />
<Row label="担当" value={task.assignee} />
</div>
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
<button style={{
padding: '8px 14px', borderRadius: 10, border: '1px solid #e2e8f0',
background: '#fff', color: '#475569', fontSize: 12, fontWeight: 600,
cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
}}>再試行</button>
<button style={{
padding: '8px 14px', borderRadius: 10, border: '1px solid #fecaca',
background: '#fff', color: '#b91c1c', fontSize: 12, fontWeight: 600,
cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
}}>キャンセル</button>
</div>
</div>
);
}
function ProgressTab({ task }) {
const events = task.events || [];
return (
<div style={{ padding: 16, overflowY: 'auto' }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em', marginBottom: 8 }}>ACTIVITY</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 20 }}>
{events.map((e, i) => (
<div key={i} style={{ display: 'flex', gap: 10, fontSize: 12 }}>
<div style={{ flexShrink: 0, marginTop: 3 }}>
<div style={{ width: 8, height: 8, borderRadius: 9999, background: e.kind === 'error' ? '#dc2626' : e.kind === 'ok' ? '#16a34a' : '#3b82f6' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#0f172a', fontWeight: 600 }}>{e.label}</div>
<div style={{ color: '#64748b', fontSize: 11 }}>{e.meta}</div>
</div>
<div style={{ fontSize: 10, color: '#94a3b8', fontFamily: 'IBM Plex Mono, monospace' }}>{e.time}</div>
</div>
))}
</div>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em', marginBottom: 6 }}>ACTIVITY.LOG</div>
<div style={{
background: '#0f172a', color: '#e2e8f0',
fontFamily: 'IBM Plex Mono, monospace', fontSize: 11, lineHeight: 1.6,
padding: 12, borderRadius: 8, whiteSpace: 'pre', overflowX: 'auto',
}}>
{`[10:42:18] ` + String.fromCharCode(9432) + ` starting worker for task #` + task.id + `
[10:42:19] ` + String.fromCharCode(9432) + ` branch: ` + task.branch + `
[10:42:21] ` + String.fromCharCode(9432) + ` piece: ` + task.piece + `
[10:42:22] ` + String.fromCharCode(9655) + ` /brainstorm
[10:43:04] ` + String.fromCharCode(10003) + ` /plan (12 steps)
[10:43:05] ` + String.fromCharCode(9655) + ` /implement
[10:44:58] ` + String.fromCharCode(10003) + ` tests passed`}
</div>
</div>
);
}
function DetailPanel({ task, onClose }) {
const [tab, setTab] = React.useState('overview');
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#f8fafc', borderLeft: '1px solid #e2e8f0' }}>
<div style={{
flexShrink: 0, padding: '12px 16px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>DETAIL</div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>#{task.id} {task.title}</div>
</div>
<button onClick={onClose} style={{
width: 28, height: 28, borderRadius: 8, border: '1px solid #e2e8f0',
background: '#fff', color: '#64748b', cursor: 'pointer', display: 'inline-flex',
alignItems: 'center', justifyContent: 'center',
}}><IconClose width={12} height={12} /></button>
</div>
<Tabs tab={tab} onTab={setTab} />
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
{tab === 'overview' && <OverviewTab task={task} />}
{tab === 'progress' && <ProgressTab task={task} />}
{tab === 'subtasks' && (
<div style={{ padding: 16, fontSize: 13, color: '#64748b' }}>
サブタスクはこのタスクにありません
</div>
)}
</div>
</div>
);
}
window.DetailPanel = DetailPanel;
@@ -1,86 +0,0 @@
// Shared small primitives for the admin UI kit.
// Status tone + tiny SVG icons + labels matching the codebase.
const STATUS_LABELS = {
queued: 'Inbox', running: 'Running', waiting_human: 'Waiting',
waiting_subtasks: 'Subtasks', retry: 'Retry', succeeded: 'Done',
failed: 'Failed', cancelled: 'Cancelled',
};
const STATUS_TONE = {
running: { bg: '#dcfce7', fg: '#166534' },
waiting_human: { bg: '#fef9c3', fg: '#854d0e' },
waiting_subtasks: { bg: '#e0e7ff', fg: '#3730a3' },
failed: { bg: '#fee2e2', fg: '#b91c1c' },
succeeded: { bg: '#dbeafe', fg: '#1e40af' },
retry: { bg: '#fef3c7', fg: '#92400e' },
queued: { bg: '#e2e8f0', fg: '#475569' },
cancelled: { bg: '#e2e8f0', fg: '#475569' },
};
function StatusBadge({ status, small }) {
const tone = STATUS_TONE[status] || STATUS_TONE.queued;
const label = STATUS_LABELS[status] || status;
const style = {
background: tone.bg, color: tone.fg,
fontSize: small ? 10 : 11, fontWeight: 700,
padding: small ? '1px 8px' : '2px 10px', borderRadius: 9999,
display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap',
};
return <span style={style}>{label}</span>;
}
function StatChip({ label, value, color }) {
return (
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
padding: '8px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
minWidth: 0, flex: '1 1 0', minWidth: 80,
}}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.06em', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>{label}</div>
<div style={{
fontSize: 15, fontWeight: 800, color: color || '#0f172a', marginTop: 2,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{value}</div>
</div>
);
}
function Spinner() {
return <div style={{
width: 16, height: 16, border: '2px solid #e2e8f0', borderTopColor: '#2563eb',
borderRadius: '9999px', animation: 'ao-spin 1s linear infinite', display: 'inline-block',
}} />;
}
function PulseDot() {
return <span style={{
display: 'inline-block', width: 8, height: 8, background: '#3b82f6',
borderRadius: 9999, animation: 'ao-pulse 1.2s ease-in-out infinite',
}} />;
}
function IconSearch(props) {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>;
}
function IconAttach(props) {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>;
}
function IconClose(props) {
return <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}><path d="M4 4l8 8M12 4l-8 8"/></svg>;
}
function relativeTime(ms) {
const mins = Math.floor((Date.now() - ms) / 60000);
if (mins < 1) return 'たった今';
if (mins < 60) return `${mins}分前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}時間前`;
return `${Math.floor(hrs / 24)}日前`;
}
Object.assign(window, {
STATUS_LABELS, STATUS_TONE,
StatusBadge, StatChip, Spinner, PulseDot,
IconSearch, IconAttach, IconClose, relativeTime,
});
@@ -1,12 +0,0 @@
# Admin Dashboard UI Kit
High-fidelity recreation of the Agent Orchestrator admin UI (`ui/` in the codebase).
- `index.html` — interactive 3-column dashboard: task list, chat, detail panel. Click a task to open chat; open detail to see Overview / Progress tabs.
- `TopBar.jsx` — top navigation with wordmark, section nav, status counts, primary CTA
- `TaskList.jsx` — FilterBar + LocalTaskListItem
- `ChatPane.jsx` — header + messages + composer with user / ask / result / progress bubbles
- `DetailPanel.jsx` — tabbed detail with Overview + Progress (activity timeline + log surface)
- `Primitives.jsx` — StatusBadge, StatChip, tiny SVG icons, spinner, pulse dot
Cosmetic recreation — no real API. Data is inline sample Japanese task data.
@@ -1,92 +0,0 @@
// TaskList — FilterBar + LocalTaskListItem recreation
function FilterBar({ status, onStatus, search, onSearch, sort, onSort, counts, total }) {
const columns = ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled'];
const chipStyle = (active) => ({
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
border: '1px solid ' + (active ? '#2563eb' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
color: active ? '#1d4ed8' : '#64748b',
fontFamily: 'inherit',
});
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 12, padding: '6px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<IconSearch width={14} height={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
<input value={search} onChange={(e) => onSearch(e.target.value)} placeholder="検索..."
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0 }} />
</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
<button style={chipStyle(status === 'all')} onClick={() => onStatus('all')}>
All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{total}</span>
</button>
{columns.map(s => (
<button key={s} style={chipStyle(status === s)} onClick={() => onStatus(s)}>
{STATUS_LABELS[s]} <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts[s] || 0}</span>
</button>
))}
</div>
<select value={sort} onChange={(e) => onSort(e.target.value)} style={{
padding: '6px 10px', fontSize: 12, background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 8, color: '#334155', outline: 'none', fontFamily: 'inherit',
}}>
<option value="updated">新しい順</option>
<option value="status">ステータス順</option>
<option value="title">タイトル順</option>
</select>
</div>
);
}
function TaskItem({ task, active, onClick }) {
return (
<button onClick={onClick} style={{
width: '100%', textAlign: 'left', padding: '10px 12px', borderRadius: 12,
border: '1px solid ' + (active ? '#3b82f6' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
cursor: 'pointer', transition: 'background .15s', fontFamily: 'inherit',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
#{task.id} {task.title}
</div>
<StatusBadge status={task.status} small />
</div>
<div style={{ marginTop: 2, fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.body.length > 60 ? task.body.slice(0, 60) + '…' : task.body}
</div>
<div style={{ marginTop: 2, fontSize: 10, color: '#94a3b8' }}>{relativeTime(task.updatedAt)}</div>
</button>
);
}
function TaskList({ tasks, activeId, onSelect, filters, setFilters }) {
const counts = {};
for (const s of ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled']) {
counts[s] = tasks.filter(t => t.status === s).length;
}
const filtered = tasks
.filter(t => filters.status === 'all' || t.status === filters.status)
.filter(t => !filters.search || (t.title + t.body).toLowerCase().includes(filters.search.toLowerCase()))
.sort((a, b) => filters.sort === 'title' ? a.title.localeCompare(b.title) : b.updatedAt - a.updatedAt);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<FilterBar
status={filters.status} onStatus={(s) => setFilters(f => ({ ...f, status: s }))}
search={filters.search} onSearch={(q) => setFilters(f => ({ ...f, search: q }))}
sort={filters.sort} onSort={(s) => setFilters(f => ({ ...f, sort: s }))}
counts={counts} total={tasks.length}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
{filtered.map(t => <TaskItem key={t.id} task={t} active={activeId === t.id} onClick={() => onSelect(t.id)} />)}
{filtered.length === 0 && <div style={{ fontSize: 13, color: '#64748b', padding: '12px 8px' }}>スレッドがありません</div>}
</div>
</div>
);
}
window.TaskList = TaskList;
@@ -1,62 +0,0 @@
// TopBar — mirrors ui/src/components/layout/TopBar.tsx
function TopBar({ page, onNavigate, counts, onOpenCreate, user }) {
const navItem = (id, label) => (
<button
key={id}
onClick={() => onNavigate(id)}
style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 500,
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
background: page === id ? '#2563eb' : 'transparent',
color: page === id ? '#fff' : '#64748b',
transition: 'background .15s',
}}
>{label}</button>
);
return (
<div style={{
flexShrink: 0, background: '#fff', borderBottom: '1px solid #e2e8f0',
padding: '12px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<img src="../../assets/logo.svg" width="22" height="22" alt="" />
<span style={{
fontFamily: 'IBM Plex Mono, monospace', fontSize: 11, fontWeight: 700,
color: '#2563eb', textTransform: 'uppercase', letterSpacing: '.16em',
}}>Agent Orchestrator</span>
<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 10, color: '#94a3b8' }}>v1.14.0</span>
<div style={{ display: 'flex', gap: 4 }}>
{navItem('tasks', 'Tasks')}
{navItem('schedules', 'Schedules')}
{navItem('settings', 'Settings')}
{navItem('users', 'Users')}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 11, color: '#64748b', display: 'flex', gap: 6 }}>
<span><b style={{ color: '#334155' }}>{counts.total}</b> </span>
<span><b style={{ color: '#16a34a' }}>{counts.running}</b> 実行中</span>
<span><b style={{ color: '#d97706' }}>{counts.waiting}</b> 待機</span>
{counts.failed > 0 && <span><b style={{ color: '#dc2626' }}>{counts.failed}</b> 失敗</span>}
</div>
{user && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{
width: 24, height: 24, borderRadius: 9999, background: '#dbeafe', color: '#1d4ed8',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700,
}}>{user.name.charAt(0)}</div>
<span style={{ fontSize: 12, color: '#475569' }}>{user.name}</span>
</div>
)}
<button onClick={onOpenCreate} style={{
padding: '8px 16px', background: '#2563eb', color: '#fff', borderRadius: 12,
fontSize: 13, fontWeight: 700, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
}}>新しい依頼</button>
</div>
</div>
);
}
window.TopBar = TopBar;
@@ -1,231 +0,0 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>Agent Orchestrator — Admin</title>
<link rel="stylesheet" href="../../colors_and_type.css">
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: var(--font-sans);
background: var(--bg-app, #f8fafc);
color: var(--fg1, #0f172a);
font-size: 13px;
-webkit-font-smoothing: antialiased;
}
@keyframes ao-spin { from { transform: rotate(0) } to { transform: rotate(360deg) } }
@keyframes ao-pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.35 } }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border: 2px solid #f8fafc; border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/[email protected]/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/[email protected]/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="./Primitives.jsx"></script>
<script type="text/babel" src="./TopBar.jsx"></script>
<script type="text/babel" src="./TaskList.jsx"></script>
<script type="text/babel" src="./ChatPane.jsx"></script>
<script type="text/babel" src="./DetailPanel.jsx"></script>
<script type="text/babel">
const MIN = 60 * 1000;
const H = 60 * MIN;
const now = Date.now();
const SAMPLE_TASKS = [
{
id: 412, title: 'Xの朝のAIダイジェスト生成',
body: '毎朝 7:00 JST にフォロー中のAI関連アカウントの過去24hをサマリし、DMで送信する。Twitter CLIを使用し、1スレッドにまとめること。',
status: 'running', piece: 'x-ai-digest', worker: 'worker-03', attempts: 1,
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/412-morning-digest',
createdAt: now - 2*H, updatedAt: now - 4*MIN,
events: [
{ kind: 'info', label: '/brainstorm 完了', meta: '12個のアイデアを生成', time: '10:42' },
{ kind: 'info', label: '/plan 完了', meta: '12ステップ · 推定 4分', time: '10:43' },
{ kind: 'info', label: '/implement 実行中', meta: 'ステップ 8 / 12', time: '10:45' },
],
},
{
id: 411, title: 'Brave Search の CAPTCHA 回避',
body: 'noVNC経由でBraveに繰り返しCAPTCHAが発生。ユーザーの介入が必要。',
status: 'waiting_human', piece: 'general', worker: 'worker-01', attempts: 2,
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/411-brave-captcha',
createdAt: now - 3*H, updatedAt: now - 18*MIN,
events: [
{ kind: 'info', label: '/brainstorm 完了', meta: '', time: '08:10' },
{ kind: 'error', label: 'ASK が発行されました', meta: 'CAPTCHAの解決を依頼', time: '08:22' },
],
},
{
id: 410, title: 'GitHub Issue #284 の対応',
body: 'scheduler.ts のタイムアウト処理リファクタ。worker-manager.test.ts を更新。',
status: 'succeeded', piece: 'general', worker: 'worker-02', attempts: 1,
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/410-sched-timeout',
createdAt: now - 8*H, updatedAt: now - 2*H,
events: [
{ kind: 'info', label: '/plan 完了', meta: '7ステップ', time: '02:11' },
{ kind: 'ok', label: 'PR 作成', meta: '#284 テスト通過', time: '04:08' },
],
},
{
id: 409, title: 'ブレスト: 社内AIエージェント活用事例',
body: '営業部向け、週次の活用アイデアを10件ブレストし、優先順位をつけて提出。',
status: 'queued', piece: 'brainstorming', worker: null, attempts: 0,
assignee: '@tomoko', repo: 'gitea:corp/ops', branch: '—',
createdAt: now - 30*MIN, updatedAt: now - 25*MIN,
events: [],
},
{
id: 408, title: '四半期データの集計とグラフ化',
body: 'Q3のSNSエンゲージメントを集計し、CSVとPNGで出力。',
status: 'waiting_subtasks', piece: 'data-process', worker: 'worker-05', attempts: 1,
assignee: '@kenta', repo: 'gitea:corp/analytics', branch: 'task/408-q3-roundup',
createdAt: now - 5*H, updatedAt: now - 45*MIN,
events: [
{ kind: 'info', label: 'サブタスク3件を発行', meta: '#408-1, #408-2, #408-3', time: '09:30' },
],
},
{
id: 407, title: '競合サービスのリサーチ',
body: 'エージェント型ワーカー系SaaSを3社分析し、比較表を作成する。',
status: 'failed', piece: 'research', worker: 'worker-04', attempts: 3,
assignee: '@tomoko', repo: 'gitea:corp/research', branch: 'task/407-competitors',
createdAt: now - 26*H, updatedAt: now - 10*H,
events: [
{ kind: 'error', label: 'タイムアウト', meta: '3回連続で失敗', time: 'yesterday' },
],
},
{
id: 406, title: 'ゲーム実況の告知ツイート生成',
body: '今夜のストリーム用の告知ツイートを3案作成。ハッシュタグ付き。',
status: 'retry', piece: 'game-tweet-generator', worker: 'worker-06', attempts: 2,
assignee: '@daichi', repo: 'gitea:daichi/stream', branch: 'task/406-tweet-gen',
createdAt: now - 40*MIN, updatedAt: now - 8*MIN,
events: [
{ kind: 'error', label: 'レート制限', meta: '60秒後に再試行', time: '10:35' },
],
},
{
id: 405, title: '経費申請書のOCRと仕分け',
body: '添付PDFをOCRし、勘定科目ごとに仕分け。',
status: 'cancelled', piece: 'office-process', worker: null, attempts: 1,
assignee: '@kenta', repo: 'gitea:corp/ops', branch: '—',
createdAt: now - 48*H, updatedAt: now - 20*H,
events: [],
},
];
const INITIAL_MESSAGES = {
412: [
{ role: 'user', content: '毎朝7:00にAI関連のXアカウントの過去24hをまとめて、DMで送ってほしい。1スレッドで。', footer: '10:41 · @daichi' },
{ role: 'assistant', content: '了解。x-ai-digest ピースを使用します。対象アカウント、まとめる観点、文字数制限を確認させてください。' },
{ role: 'ask', content: '❓ 以下を確認させてください:\n\n1. 対象アカウントリストはこのリポジトリの accounts.txt で良いですか?\n2. 1ツイートあたりの上限文字数は280でOK?\n3. 日本語メインの要約で良いですか?' },
{ role: 'user', content: '1. OK\n2. OK\n3. 日本語で。でも原文が英語なら簡潔な英語の引用も残して。' },
{ role: 'progress', content: '/implement — ステップ 8 / 12 (Twitter CLIでタイムライン取得中)' },
],
411: [
{ role: 'user', content: 'Brave Searchで検索結果が取れない。何度もCAPTCHAが出てるっぽい。' },
{ role: 'progress', content: 'noVNCセッションを開いて確認中...' },
{ role: 'ask', content: '❓ CAPTCHAの解決が必要です。noVNCで手動で解決していただけますか?\n\nsession: https://novnc.internal/412\n\n解決後 `/resume 411` と返信してください。' },
],
410: [
{ role: 'user', content: 'Issue #284 の対応お願い。scheduler.tsのタイムアウト処理が不安定。' },
{ role: 'assistant', content: '了解。/brainstorm から始めます。' },
{ role: 'result', content: '✅ 完了しました。\n\n- PR: gitea:daichi/agent-orchestrator#291\n- 変更: scheduler.ts, worker-manager.test.ts, worker.test.ts\n- テスト: 42 passed\n\nレビューお願いします。' },
],
409: [
{ role: 'user', content: '営業部向けに今週のエージェント活用ネタを10個ブレストしてほしい。' },
{ role: 'assistant', content: '了解。キューに入りました。ワーカーの空きが出次第処理します。' },
],
408: [
{ role: 'user', content: 'Q3のSNSエンゲージメントまとめて、折れ線グラフと棒グラフのPNGで。' },
{ role: 'progress', content: 'サブタスク3件の完了を待っています (#408-1, #408-2, #408-3)' },
],
407: [
{ role: 'user', content: '競合3社のリサーチと比較表を作成。' },
{ role: 'assistant', content: '3回試行しましたが、外部サイトの読み込みタイムアウトが続いています。' },
],
406: [
{ role: 'user', content: '今夜のストリーム告知を3案、ハッシュタグ付きで。' },
{ role: 'progress', content: 'レート制限中 — 60秒後に再試行します' },
],
405: [
{ role: 'user', content: '経費PDFのOCRと仕分け。' },
{ role: 'assistant', content: 'キャンセルされました。' },
],
};
function App() {
const [tasks, setTasks] = React.useState(SAMPLE_TASKS);
const [activeId, setActiveId] = React.useState(412);
const [detailOpen, setDetailOpen] = React.useState(true);
const [messages, setMessages] = React.useState(INITIAL_MESSAGES);
const [filters, setFilters] = React.useState({ status: 'all', search: '', sort: 'updated' });
const [page, setPage] = React.useState('tasks');
const active = tasks.find(t => t.id === activeId) || tasks[0];
const counts = {
total: tasks.length,
running: tasks.filter(t => t.status === 'running').length,
waiting: tasks.filter(t => t.status === 'waiting_human' || t.status === 'waiting_subtasks').length,
failed: tasks.filter(t => t.status === 'failed').length,
};
const onSend = (text) => {
setMessages(m => ({
...m,
[activeId]: [...(m[activeId] || []), { role: 'user', content: text, footer: 'たった今 · @daichi' }],
}));
// fake echo after small delay
setTimeout(() => {
setMessages(m => ({
...m,
[activeId]: [...(m[activeId] || []), { role: 'progress', content: 'エージェントが応答を生成中...' }],
}));
}, 400);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<TopBar
page={page} onNavigate={setPage}
counts={counts}
onOpenCreate={() => alert('新しい依頼 (モック)')}
user={{ name: 'Daichi' }}
/>
<div style={{
flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: detailOpen ? '320px 1fr 380px' : '320px 1fr',
background: '#f1f5f9', gap: 1,
}}>
<div style={{ background: '#fff', padding: 12, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<TaskList
tasks={tasks} activeId={activeId} onSelect={setActiveId}
filters={filters} setFilters={setFilters}
/>
</div>
<ChatPane
task={active}
messages={messages[active.id] || []}
onSend={onSend}
onOpenDetail={() => setDetailOpen(v => !v)}
detailOpen={detailOpen}
/>
{detailOpen && <DetailPanel task={active} onClose={() => setDetailOpen(false)} />}
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
@@ -1,197 +0,0 @@
// ChatPane — mirrors ui/src/components/chat/* with user/ask/result/progress bubbles
function Bubble({ role, children, footer }) {
const isUser = role === 'user';
const style = {
maxWidth: '85%',
padding: '10px 14px',
borderRadius: 16,
fontSize: 13,
lineHeight: 1.55,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
};
if (isUser) {
Object.assign(style, { background: '#f1f5f9', color: '#0f172a', borderBottomRightRadius: 4, alignSelf: 'flex-end' });
} else if (role === 'ask') {
Object.assign(style, { background: '#fef9c3', color: '#854d0e', border: '1px solid #fde68a', borderBottomLeftRadius: 4 });
} else if (role === 'result') {
Object.assign(style, { background: '#ecfdf5', color: '#065f46', border: '1px solid #a7f3d0', borderBottomLeftRadius: 4 });
} else {
Object.assign(style, { background: '#fff', color: '#0f172a', border: '1px solid #e2e8f0', borderBottomLeftRadius: 4 });
}
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: isUser ? 'flex-end' : 'flex-start', gap: 4 }}>
<div style={style}>{children}</div>
{footer && <div style={{ fontSize: 10, color: '#94a3b8' }}>{footer}</div>}
</div>
);
}
function ProgressBubble({ text }) {
return (
<div style={{
alignSelf: 'flex-start', background: '#f1f5f9', color: '#475569',
padding: '8px 12px', borderRadius: 12, fontSize: 12,
display: 'inline-flex', alignItems: 'center', gap: 8,
}}>
<Spinner />
<span>{text}</span>
</div>
);
}
function ChatHeader({ task, onOpenDetail, detailOpen }) {
return (
<div style={{
flexShrink: 0, padding: '12px 16px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>
TASK #{task.id}
</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.title}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<StatusBadge status={task.status} />
<button onClick={onOpenDetail} style={{
padding: '6px 10px', borderRadius: 8, border: '1px solid #e2e8f0',
background: detailOpen ? '#eff6ff' : '#fff',
color: detailOpen ? '#1d4ed8' : '#475569',
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>詳細</button>
</div>
</div>
);
}
function Composer({ onSend, disabled }) {
const [text, setText] = React.useState('');
const [sending, setSending] = React.useState(false);
const [error, setError] = React.useState(null);
const send = async () => {
if (!text.trim() || disabled || sending) return;
setError(null); setSending(true);
try {
await Promise.resolve(onSend(text.trim()));
setText('');
} catch (e) {
setError(e?.message || '送信に失敗しました');
} finally {
setSending(false);
}
};
return (
<div style={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', background: '#fff', padding: 12 }}>
{error && (
<div style={{
marginBottom: 8, padding: '8px 10px', background: '#fef2f2', border: '1px solid #fecaca',
color: '#b91c1c', borderRadius: 8, fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
}}>
<span> {error}</span>
<button onClick={send} style={{
padding: '2px 8px', borderRadius: 6, border: '1px solid #fecaca',
background: '#fff', color: '#b91c1c', fontSize: 11, fontWeight: 700,
cursor: 'pointer', fontFamily: 'inherit',
}}>再送信</button>
</div>
)}
<div style={{
display: 'flex', alignItems: 'flex-end', gap: 8, background: '#f8fafc',
border: '1px solid #e2e8f0', borderRadius: 12, padding: 8,
opacity: disabled ? 0.6 : 1,
}}>
<button style={{ padding: 6, background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer' }}>
<IconAttach width={16} height={16} />
</button>
<textarea
value={text}
disabled={disabled || sending}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); } }}
rows={2}
placeholder={disabled ? '送信できません' : 'メッセージを入力 (⌘+Enter で送信)'}
style={{
flex: 1, resize: 'none', border: 'none', outline: 'none', background: 'transparent',
fontFamily: 'inherit', fontSize: 13, color: '#0f172a', lineHeight: 1.5, minHeight: 32,
}}
/>
<button onClick={send} disabled={disabled || sending || !text.trim()} style={{
padding: '6px 14px', background: '#2563eb', color: '#fff', borderRadius: 8,
fontSize: 12, fontWeight: 700, border: 'none',
cursor: (disabled || sending || !text.trim()) ? 'not-allowed' : 'pointer',
opacity: (disabled || sending || !text.trim()) ? 0.5 : 1, fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
{sending && <Spinner />}
{sending ? '送信中' : '送信'}
</button>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#94a3b8', paddingLeft: 4 }}>エージェントは常に /brainstorm /plan /implement のパイプラインで動作します</div>
</div>
);
}
function ChatPane({ task, messages, onSend, onOpenDetail, detailOpen, loading, onOpenCreate }) {
const scrollRef = React.useRef(null);
React.useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages.length, task && task.id]);
if (!task) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fff', justifyContent: 'center' }}>
<EmptyState
icon={<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>}
title="タスクを選択してください"
hint="左のリストから会話を開くか、新しい依頼を作成できます。"
action={onOpenCreate && (
<button onClick={onOpenCreate} style={{
padding: '8px 14px', borderRadius: 10, fontSize: 12, fontWeight: 700,
background: '#2563eb', color: '#fff', border: 'none', cursor: 'pointer',
fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
新しい依頼
</button>
)}
/>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fff' }}>
<ChatHeader task={task} onOpenDetail={onOpenDetail} detailOpen={detailOpen} />
<div ref={scrollRef} style={{
flex: 1, overflowY: 'auto', padding: '16px 20px',
display: 'flex', flexDirection: 'column', gap: 12, minHeight: 0,
}}>
{loading && (
<>
<div style={{ alignSelf: 'flex-end', width: '60%' }}><SkeletonLine height={40} style={{ borderRadius: 16 }} /></div>
<div style={{ alignSelf: 'flex-start', width: '70%' }}><SkeletonLine height={56} style={{ borderRadius: 16 }} /></div>
<div style={{ alignSelf: 'flex-start', width: '40%' }}><SkeletonLine height={32} style={{ borderRadius: 12 }} /></div>
</>
)}
{!loading && messages.length === 0 && (
<EmptyState
compact
title="まだメッセージがありません"
hint="下の入力欄から依頼の詳細を送信してください。エージェントが /brainstorm から開始します。"
/>
)}
{!loading && messages.map((m, i) => (
m.role === 'progress'
? <ProgressBubble key={i} text={m.content} />
: <Bubble key={i} role={m.role} footer={m.footer}>{m.content}</Bubble>
))}
</div>
<Composer onSend={onSend} disabled={task.status === 'cancelled' || task.status === 'succeeded'} />
</div>
);
}
window.ChatPane = ChatPane;
@@ -1,134 +0,0 @@
// DetailPanel — tabs: overview, progress (activity + log surface)
function Tabs({ tab, onTab }) {
const items = [
{ id: 'overview', label: '概要' },
{ id: 'progress', label: '進捗' },
{ id: 'subtasks', label: 'サブタスク' },
];
return (
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid #e2e8f0', background: '#fff' }}>
{items.map(it => (
<button key={it.id} onClick={() => onTab(it.id)} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
background: tab === it.id ? '#eff6ff' : 'transparent',
color: tab === it.id ? '#1d4ed8' : '#64748b',
}}>{it.label}</button>
))}
</div>
);
}
function OverviewTab({ task }) {
const Row = ({ label, value }) => (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, padding: '8px 0', borderBottom: '1px solid #f1f5f9', fontSize: 12 }}>
<span style={{ color: '#64748b' }}>{label}</span>
<span style={{ color: '#0f172a', fontWeight: 600, textAlign: 'right' }}>{value}</span>
</div>
);
return (
<div style={{ padding: 16, overflowY: 'auto', fontSize: 13, color: '#0f172a' }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>DESCRIPTION</div>
<div style={{ marginTop: 6, color: '#334155', lineHeight: 1.6, fontSize: 13 }}>{task.body}</div>
<div style={{ marginTop: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<StatChip label="試行" value={`${task.attempts}/3`} />
<StatChip label="ピース" value={task.piece} color="#2563eb" />
<StatChip label="ワーカー" value={task.worker || '—'} />
</div>
<div style={{ marginTop: 16 }}>
<Row label="リポジトリ" value={<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 11 }}>{task.repo}</span>} />
<Row label="ブランチ" value={<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 11 }}>{task.branch}</span>} />
<Row label="作成日時" value={new Date(task.createdAt).toLocaleString('ja-JP')} />
<Row label="更新日時" value={relativeTime(task.updatedAt)} />
<Row label="担当" value={task.assignee} />
</div>
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
<button style={{
padding: '8px 14px', borderRadius: 10, border: '1px solid #e2e8f0',
background: '#fff', color: '#475569', fontSize: 12, fontWeight: 600,
cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
}}>再試行</button>
<button style={{
padding: '8px 14px', borderRadius: 10, border: '1px solid #fecaca',
background: '#fff', color: '#b91c1c', fontSize: 12, fontWeight: 600,
cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
}}>キャンセル</button>
</div>
</div>
);
}
function ProgressTab({ task }) {
const events = task.events || [];
return (
<div style={{ padding: 16, overflowY: 'auto' }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em', marginBottom: 8 }}>ACTIVITY</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 20 }}>
{events.map((e, i) => (
<div key={i} style={{ display: 'flex', gap: 10, fontSize: 12 }}>
<div style={{ flexShrink: 0, marginTop: 3 }}>
<div style={{ width: 8, height: 8, borderRadius: 9999, background: e.kind === 'error' ? '#dc2626' : e.kind === 'ok' ? '#16a34a' : '#3b82f6' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#0f172a', fontWeight: 600 }}>{e.label}</div>
<div style={{ color: '#64748b', fontSize: 11 }}>{e.meta}</div>
</div>
<div style={{ fontSize: 10, color: '#94a3b8', fontFamily: 'IBM Plex Mono, monospace' }}>{e.time}</div>
</div>
))}
</div>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em', marginBottom: 6 }}>ACTIVITY.LOG</div>
<div style={{
background: '#0f172a', color: '#e2e8f0',
fontFamily: 'IBM Plex Mono, monospace', fontSize: 11, lineHeight: 1.6,
padding: 12, borderRadius: 8, whiteSpace: 'pre', overflowX: 'auto',
}}>
{`[10:42:18] ` + String.fromCharCode(9432) + ` starting worker for task #` + task.id + `
[10:42:19] ` + String.fromCharCode(9432) + ` branch: ` + task.branch + `
[10:42:21] ` + String.fromCharCode(9432) + ` piece: ` + task.piece + `
[10:42:22] ` + String.fromCharCode(9655) + ` /brainstorm
[10:43:04] ` + String.fromCharCode(10003) + ` /plan (12 steps)
[10:43:05] ` + String.fromCharCode(9655) + ` /implement
[10:44:58] ` + String.fromCharCode(10003) + ` tests passed`}
</div>
</div>
);
}
function DetailPanel({ task, onClose }) {
const [tab, setTab] = React.useState('overview');
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#f8fafc', borderLeft: '1px solid #e2e8f0' }}>
<div style={{
flexShrink: 0, padding: '12px 16px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>DETAIL</div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>#{task.id} {task.title}</div>
</div>
<button onClick={onClose} style={{
width: 28, height: 28, borderRadius: 8, border: '1px solid #e2e8f0',
background: '#fff', color: '#64748b', cursor: 'pointer', display: 'inline-flex',
alignItems: 'center', justifyContent: 'center',
}}><IconClose width={12} height={12} /></button>
</div>
<Tabs tab={tab} onTab={setTab} />
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
{tab === 'overview' && <OverviewTab task={task} />}
{tab === 'progress' && <ProgressTab task={task} />}
{tab === 'subtasks' && (
<div style={{ padding: 16, fontSize: 13, color: '#64748b' }}>
サブタスクはこのタスクにありません
</div>
)}
</div>
</div>
);
}
window.DetailPanel = DetailPanel;
@@ -1,169 +0,0 @@
// Shared small primitives for the admin UI kit.
// Status tone + tiny SVG icons + labels matching the codebase.
const STATUS_LABELS = {
queued: 'Inbox', running: 'Running', waiting_human: 'Waiting',
waiting_subtasks: 'Subtasks', retry: 'Retry', succeeded: 'Done',
failed: 'Failed', cancelled: 'Cancelled',
};
const STATUS_TONE = {
running: { bg: '#dcfce7', fg: '#166534' },
waiting_human: { bg: '#fef9c3', fg: '#854d0e' },
waiting_subtasks: { bg: '#e0e7ff', fg: '#3730a3' },
failed: { bg: '#fee2e2', fg: '#b91c1c' },
succeeded: { bg: '#dbeafe', fg: '#1e40af' },
retry: { bg: '#fef3c7', fg: '#92400e' },
queued: { bg: '#e2e8f0', fg: '#475569' },
cancelled: { bg: '#e2e8f0', fg: '#475569' },
};
function StatusBadge({ status, small }) {
const tone = STATUS_TONE[status] || STATUS_TONE.queued;
const label = STATUS_LABELS[status] || status;
const style = {
background: tone.bg, color: tone.fg,
fontSize: small ? 10 : 11, fontWeight: 700,
padding: small ? '1px 8px' : '2px 10px', borderRadius: 9999,
display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap',
};
return <span style={style}>{label}</span>;
}
function StatChip({ label, value, color }) {
return (
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
padding: '8px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
minWidth: 0, flex: '1 1 0', minWidth: 80,
}}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.06em', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>{label}</div>
<div style={{
fontSize: 15, fontWeight: 800, color: color || '#0f172a', marginTop: 2,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{value}</div>
</div>
);
}
function Spinner() {
return <div style={{
width: 16, height: 16, border: '2px solid #e2e8f0', borderTopColor: '#2563eb',
borderRadius: '9999px', animation: 'ao-spin 1s linear infinite', display: 'inline-block',
}} />;
}
function PulseDot() {
return <span style={{
display: 'inline-block', width: 8, height: 8, background: '#3b82f6',
borderRadius: 9999, animation: 'ao-pulse 1.2s ease-in-out infinite',
}} />;
}
function IconSearch(props) {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>;
}
function IconAttach(props) {
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>;
}
function IconClose(props) {
return <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}><path d="M4 4l8 8M12 4l-8 8"/></svg>;
}
// ---- State primitives: loading / empty / error ----
function SkeletonLine({ width = '100%', height = 10, style }) {
return <div style={{
width, height, borderRadius: 6,
background: 'linear-gradient(90deg, #f1f5f9 0%, #e2e8f0 50%, #f1f5f9 100%)',
backgroundSize: '200% 100%',
animation: 'ao-shimmer 1.4s ease-in-out infinite',
...style,
}} />;
}
function SkeletonCard({ lines = 2 }) {
return (
<div style={{
padding: '10px 12px', borderRadius: 12, border: '1px solid #e2e8f0',
background: '#fff', display: 'flex', flexDirection: 'column', gap: 6,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<SkeletonLine width="60%" height={12} />
<SkeletonLine width={44} height={14} style={{ borderRadius: 9999 }} />
</div>
{Array.from({ length: lines }).map((_, i) => (
<SkeletonLine key={i} width={i === lines - 1 ? '40%' : '90%'} height={9} />
))}
</div>
);
}
function SkeletonList({ count = 5, lines = 2 }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{Array.from({ length: count }).map((_, i) => <SkeletonCard key={i} lines={lines} />)}
</div>
);
}
function EmptyState({ icon, title, hint, action, compact }) {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
textAlign: 'center', padding: compact ? '24px 16px' : '48px 24px', gap: 8,
color: '#64748b',
}}>
{icon && <div style={{
width: 40, height: 40, borderRadius: 9999, background: '#f1f5f9',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: '#94a3b8', marginBottom: 4,
}}>{icon}</div>}
{title && <div style={{ fontSize: 13, fontWeight: 700, color: '#334155' }}>{title}</div>}
{hint && <div style={{ fontSize: 12, color: '#64748b', maxWidth: 280, lineHeight: 1.5 }}>{hint}</div>}
{action && <div style={{ marginTop: 8 }}>{action}</div>}
</div>
);
}
function ErrorState({ title = '読み込みに失敗しました', hint, onRetry, compact }) {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
textAlign: 'center', padding: compact ? '24px 16px' : '40px 24px', gap: 8,
}}>
<div style={{
width: 40, height: 40, borderRadius: 9999, background: '#fee2e2',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: '#b91c1c', marginBottom: 4,
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#b91c1c' }}>{title}</div>
{hint && <div style={{ fontSize: 12, color: '#64748b', maxWidth: 320, lineHeight: 1.5 }}>{hint}</div>}
{onRetry && (
<button onClick={onRetry} style={{
marginTop: 4, padding: '6px 14px', borderRadius: 8, fontSize: 12, fontWeight: 700,
background: '#fff', border: '1px solid #e2e8f0', color: '#334155',
cursor: 'pointer', fontFamily: 'inherit',
}}>再試行</button>
)}
</div>
);
}
function relativeTime(ms) {
const mins = Math.floor((Date.now() - ms) / 60000);
if (mins < 1) return 'たった今';
if (mins < 60) return `${mins}分前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}時間前`;
return `${Math.floor(hrs / 24)}日前`;
}
Object.assign(window, {
STATUS_LABELS, STATUS_TONE,
StatusBadge, StatChip, Spinner, PulseDot,
SkeletonLine, SkeletonCard, SkeletonList, EmptyState, ErrorState,
IconSearch, IconAttach, IconClose, relativeTime,
});
@@ -1,34 +0,0 @@
# Admin UI Kit
Agent Orchestrator 管理画面のハイファイ UI キット。`ui/src/` の実装に対応します。
## エントリ
- `index.html` — 4画面シェル(Tasks / Schedules / Users / Settings)。TopBar のナビで切替。
## コンポーネント
| ファイル | 役割 |
|---|---|
| `TopBar.jsx` | ロゴ + ワードマーク + セクションナビ(下線インジケータ)+ ユーザーアバター |
| `TaskList.jsx` | 左パネル最上部の「新しい依頼」ボタン、カウント行、検索バー内にソートアイコン統合、ステータスフィルタ chip、タスクリスト |
| `ChatPane.jsx` | タスク詳細のチャット UIuser / assistant / ask / result / progress バブル) |
| `DetailPanel.jsx` | 右側の詳細パネル(Overview / Progress タブ) |
| `SchedulesPage.jsx` | スケジュール一覧 + 詳細(Cron / Event トリガー、実行履歴) |
| `UsersPage.jsx` | ユーザー一覧 + ロール設定 / プロフィール |
| `SettingsPage.jsx` | 左サイドバー + 中央フォーム(Provider, Pieces, Workers ほか) |
| `Primitives.jsx` | StatusBadge, StatChip, SVG アイコン、スピナー、pulse dot |
## 設計上の決定(v2
- **プライマリアクションは左パネル最上部** — 「新しい依頼」は TopBar 右上ではなく、タスクリスト文脈内の青いフル幅ボタン。
- **カウントはプライマリアクション直下に統合** — 「合計 / 実行中 / 待機 / 失敗」は TaskList の冒頭に置き、TopBar 右側はアバターのみでスッキリ。
- **ソートは検索バー内にアイコン化** — `select` を外し、検索バー右端の小さなアイコンボタンをクリックすると 3 つの並び順からドロップダウンで選べる。リストの縦領域が約 40px 広がる。
- **4画面で同じシェルを共有** — Tasks / Schedules / Users は「左リスト + 中央詳細」、Settings のみ「左サイドバー + 中央フォーム」。ヘッダー → サマリ chip → カードフォーム のリズムで操作導線を揃える。
- **ナビは下線インジケータ** — タブ的な視覚。アクティブ以外はコントラストを落とす。
## 実装との対応
`ui/src/components/` 以下の同名コンポーネントにそのまま対応させる想定です。UI キットはあくまで意思決定のモック — 実 API は未接続、サンプルデータは `index.html` 内にインライン。
旧バージョン(移植前)は `../admin-legacy/` にあります。
@@ -1,427 +0,0 @@
// SchedulesPage — mirrors the Tasks 3-pane shell: list | detail | history
// Data model derives from ui/src/pages/SchedulesPage.tsx.
const DAYS = ['日', '月', '火', '水', '木', '金', '土'];
function parseCronToDisplay(cron) {
if (cron === 'once') return '一回のみ';
const parts = (cron || '').split(' ');
if (parts.length !== 5) return cron;
const [min, hour, dom, , dow] = parts;
const hhmm = `${hour}:${String(min).padStart(2, '0')}`;
if (dom !== '*' && dow === '*') return `毎月${dom}${hhmm}`;
if (dow !== '*' && dom === '*') return `毎週${DAYS[Number(dow)] ?? dow}${hhmm}`;
if (dom === '*' && dow === '*') return `毎日 ${hhmm}`;
return cron;
}
function formatDateShort(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function relativeFromNow(iso) {
if (!iso) return '—';
const diff = new Date(iso).getTime() - Date.now();
const abs = Math.abs(diff);
const mins = Math.round(abs / 60000);
const hrs = Math.round(mins / 60);
const days = Math.round(hrs / 24);
const unit = mins < 60 ? `${mins}` : hrs < 24 ? `${hrs}時間` : `${days}`;
return diff >= 0 ? `${unit}` : `${unit}`;
}
// ── Left: list of schedules ──────────────────────────────────────────────
function ScheduleListItem({ sch, active, onClick }) {
return (
<button onClick={onClick} style={{
width: '100%', textAlign: 'left', padding: '10px 12px', borderRadius: 12,
border: '1px solid ' + (active ? '#3b82f6' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
cursor: 'pointer', transition: 'background .15s', fontFamily: 'inherit',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{
width: 8, height: 8, borderRadius: 9999, flexShrink: 0,
background: sch.isActive ? '#22c55e' : '#cbd5e1',
}} />
<div style={{
flex: 1, minWidth: 0,
fontSize: 13, fontWeight: 700, color: sch.isActive ? '#0f172a' : '#64748b',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{sch.title || 'タイトルなし'}</div>
{sch.triggerKind === 'event' && (
<span style={{
fontSize: 10, fontWeight: 700, color: '#5b21b6', background: '#ede9fe',
padding: '2px 6px', borderRadius: 4, flexShrink: 0,
}}>event</span>
)}
</div>
<div style={{ marginTop: 4, fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{sch.triggerKind === 'event' ? sch.eventSource : parseCronToDisplay(sch.cronExpression)}
</div>
<div style={{ marginTop: 2, fontSize: 10, color: '#94a3b8' }}>
{sch.isActive
? (sch.nextRunAt ? `次回 ${formatDateShort(sch.nextRunAt)} (${relativeFromNow(sch.nextRunAt)})` : '次回未定')
: '停止中'}
</div>
</button>
);
}
function ScheduleListPane({ items, activeId, onSelect, filter, setFilter, search, setSearch, onOpenCreate }) {
const filtered = items.filter(s => {
if (filter === 'active' && !s.isActive) return false;
if (filter === 'paused' && s.isActive) return false;
if (filter === 'event' && s.triggerKind !== 'event') return false;
if (search && !(s.title + s.body).toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
const counts = {
all: items.length,
active: items.filter(s => s.isActive).length,
paused: items.filter(s => !s.isActive).length,
event: items.filter(s => s.triggerKind === 'event').length,
};
const chipStyle = (on) => ({
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
border: '1px solid ' + (on ? '#2563eb' : '#e2e8f0'),
background: on ? '#eff6ff' : '#fff',
color: on ? '#1d4ed8' : '#64748b', fontFamily: 'inherit',
});
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<button onClick={onOpenCreate} style={{
width: '100%', padding: '10px 14px', marginBottom: 10, background: '#2563eb',
color: '#fff', borderRadius: 12, fontSize: 13, fontWeight: 700, border: 'none',
cursor: 'pointer', fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', gap: 6, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
新しいスケジュール
</button>
<div style={{
display: 'flex', alignItems: 'center', gap: 10, fontSize: 11,
color: '#64748b', padding: '0 2px 10px',
}}>
<span><b style={{ color: '#334155', fontWeight: 700 }}>{counts.all}</b> </span>
<span style={{ color: '#cbd5e1' }}>·</span>
<span><b style={{ color: '#16a34a', fontWeight: 700 }}>{counts.active}</b> 有効</span>
{counts.paused > 0 && <span><b style={{ color: '#64748b', fontWeight: 700 }}>{counts.paused}</b> 停止</span>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 12, padding: '6px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<IconSearch width={14} height={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="検索..."
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0 }} />
</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
<button style={chipStyle(filter === 'all')} onClick={() => setFilter('all')}>All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.all}</span></button>
<button style={chipStyle(filter === 'active')} onClick={() => setFilter('active')}>有効 <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.active}</span></button>
<button style={chipStyle(filter === 'paused')} onClick={() => setFilter('paused')}>停止 <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.paused}</span></button>
<button style={chipStyle(filter === 'event')} onClick={() => setFilter('event')}>Event <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.event}</span></button>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
{filtered.map(s => <ScheduleListItem key={s.id} sch={s} active={activeId === s.id} onClick={() => onSelect(s.id)} />)}
{filtered.length === 0 && (
(search || filter !== 'all') ? (
<EmptyState
compact
icon={<IconSearch width={18} height={18} />}
title="該当するスケジュールはありません"
hint="検索やフィルタを変えてみてください。"
action={
<button onClick={() => { setSearch(''); setFilter('all'); }} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 700,
background: '#fff', border: '1px solid #e2e8f0', color: '#334155',
cursor: 'pointer', fontFamily: 'inherit',
}}>フィルタをクリア</button>
}
/>
) : (
<EmptyState
compact
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>}
title="スケジュールがありません"
hint="定期実行やイベントトリガーを登録するとここに表示されます。"
/>
)
)}
</div>
</div>
);
}
// ── Center: schedule detail editor ───────────────────────────────────────
function FormRow({ label, help, children }) {
return (
<label style={{ display: 'block', marginBottom: 14 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#475569', marginBottom: 4 }}>{label}</div>
{children}
{help && <div style={{ fontSize: 10, color: '#94a3b8', marginTop: 4 }}>{help}</div>}
</label>
);
}
function TextInput(props) {
return <input {...props} style={{
width: '100%', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
outline: 'none', color: '#0f172a',
...(props.style || {}),
}} />;
}
function SelectInput({ children, ...props }) {
return <select {...props} style={{
width: '100%', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
outline: 'none', color: '#0f172a',
}}>{children}</select>;
}
function ScheduleDetail({ sch, onPatch, onTrigger, onDelete }) {
if (!sch) {
return (
<div style={{ padding: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<EmptyState
icon={<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>}
title="スケジュールを選択してください"
hint="左のリストから編集したいスケジュールを開きます。"
/>
</div>
);
}
const isEvent = sch.triggerKind === 'event';
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{
flexShrink: 0, padding: '14px 20px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span style={{
width: 10, height: 10, borderRadius: 9999,
background: sch.isActive ? '#22c55e' : '#cbd5e1',
}} />
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase' }}>SCHEDULE #{sch.id}</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{sch.title || 'タイトルなし'}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button onClick={() => onTrigger(sch.id)} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #bfdbfe', color: '#1d4ed8',
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
今すぐ実行
</button>
<button onClick={() => onPatch(sch.id, { isActive: !sch.isActive })} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #e2e8f0', color: '#475569',
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
}}>{sch.isActive ? '停止' : '再開'}</button>
<button onClick={() => onDelete(sch.id)} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #fecaca', color: '#dc2626',
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
}}>削除</button>
</div>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', background: '#f8fafc' }}>
<div style={{ maxWidth: 640, margin: '0 auto' }}>
{/* Summary strip */}
<div style={{
display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap',
}}>
<StatChip label="トリガー" value={isEvent ? 'Event' : 'Cron'} />
<StatChip label={isEvent ? 'ソース' : 'スケジュール'} value={isEvent ? sch.eventSource : parseCronToDisplay(sch.cronExpression)} />
<StatChip label="ピース" value={sch.pieceName} color="#2563eb" />
{sch.isActive
? <StatChip label="次回実行" value={sch.nextRunAt ? relativeFromNow(sch.nextRunAt) : '—'} />
: <StatChip label="ステータス" value="停止中" color="#64748b" />}
</div>
{/* Form card */}
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
基本情報
</div>
<FormRow label="タイトル">
<TextInput value={sch.title || ''} onChange={e => onPatch(sch.id, { title: e.target.value })} placeholder="週次ニュースまとめ" />
</FormRow>
<FormRow label="プロンプト" help="エージェントに送るメッセージ">
<textarea value={sch.body} onChange={e => onPatch(sch.id, { body: e.target.value })} rows={4} style={{
width: '100%', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
outline: 'none', color: '#0f172a', resize: 'vertical', lineHeight: 1.55,
}} />
</FormRow>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormRow label="ピース">
<SelectInput value={sch.pieceName} onChange={e => onPatch(sch.id, { pieceName: e.target.value })}>
<option value="auto">auto</option>
<option value="chat">chat</option>
<option value="research">research</option>
<option value="general">general</option>
<option value="x-ai-digest">x-ai-digest</option>
</SelectInput>
</FormRow>
<FormRow label="出力フォーマット">
<SelectInput value={sch.outputFormat || 'markdown'} onChange={e => onPatch(sch.id, { outputFormat: e.target.value })}>
<option value="markdown">markdown</option>
<option value="plain">plain</option>
<option value="json">json</option>
</SelectInput>
</FormRow>
</div>
</div>
{/* Trigger card */}
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
marginTop: 16, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
トリガー
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
{['cron', 'event'].map(k => (
<button key={k} onClick={() => onPatch(sch.id, { triggerKind: k })} style={{
flex: 1, padding: '10px 12px', borderRadius: 10,
border: '1px solid ' + (sch.triggerKind === k ? '#2563eb' : '#e2e8f0'),
background: sch.triggerKind === k ? '#eff6ff' : '#fff',
color: sch.triggerKind === k ? '#1d4ed8' : '#64748b',
fontWeight: 700, fontSize: 12, cursor: 'pointer', fontFamily: 'inherit',
textAlign: 'left', display: 'flex', flexDirection: 'column', gap: 2,
}}>
<span>{k === 'cron' ? '定期実行 (Cron)' : 'イベントトリガー'}</span>
<span style={{ fontSize: 10, color: sch.triggerKind === k ? '#3b82f6' : '#94a3b8', fontWeight: 500 }}>
{k === 'cron' ? '毎日 / 毎週 / カスタム' : 'GitHub / Mail / Webhook'}
</span>
</button>
))}
</div>
{!isEvent && (
<>
<FormRow label="Cron 式" help="分 時 日 月 曜日 · 例: 0 7 * * * = 毎日 07:00 (UTC)">
<TextInput value={sch.cronExpression} onChange={e => onPatch(sch.id, { cronExpression: e.target.value })}
style={{ fontFamily: 'IBM Plex Mono, monospace' }} placeholder="0 7 * * *" />
</FormRow>
<div style={{ padding: '10px 12px', background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 10, fontSize: 12, color: '#475569' }}>
<b style={{ color: '#0f172a' }}>{parseCronToDisplay(sch.cronExpression)}</b>
{sch.nextRunAt && <span> · 次回 {formatDateShort(sch.nextRunAt)} ({relativeFromNow(sch.nextRunAt)})</span>}
</div>
</>
)}
{isEvent && (
<>
<FormRow label="イベントソース">
<SelectInput value={sch.eventSource || ''} onChange={e => onPatch(sch.id, { eventSource: e.target.value })}>
<option value="github.issue.opened">github.issue.opened</option>
<option value="github.pr.opened">github.pr.opened</option>
<option value="gitea.push">gitea.push</option>
<option value="mail.received">mail.received</option>
<option value="webhook.custom">webhook.custom</option>
</SelectInput>
</FormRow>
<FormRow label="フィルタ条件" help="該当イベントが発火した時のみ実行">
<TextInput value={sch.eventFilter || ''} onChange={e => onPatch(sch.id, { eventFilter: e.target.value })}
style={{ fontFamily: 'IBM Plex Mono, monospace' }} placeholder='repo == "agent-orchestrator" && label == "bug"' />
</FormRow>
</>
)}
</div>
{/* History */}
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
marginTop: 16, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14,
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase' }}>
実行履歴
</div>
<span style={{ fontSize: 11, color: '#94a3b8' }}>直近 {sch.history?.length || 0} </span>
</div>
{(sch.history || []).length === 0 && (
<EmptyState
compact
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>}
title="まだ実行されていません"
hint={sch.isActive ? '次回の実行後にここに履歴が追加されます。' : 'スケジュールは停止中です。「再開」で有効化できます。'}
/>
)}
<div style={{ display: 'flex', flexDirection: 'column' }}>
{(sch.history || []).map((h, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0',
borderTop: i === 0 ? 'none' : '1px solid #f1f5f9',
}}>
<StatusBadge status={h.status} small />
<div style={{ flex: 1, fontSize: 12, color: '#334155' }}>
<a href="#" style={{ color: '#2563eb', fontWeight: 700, textDecoration: 'none' }}>#{h.taskId}</a>
{' · '}{h.summary || '—'}
</div>
<div style={{ fontSize: 11, color: '#94a3b8', whiteSpace: 'nowrap' }}>{formatDateShort(h.at)}</div>
</div>
))}
</div>
</div>
<div style={{ height: 40 }} />
</div>
</div>
</div>
);
}
function SchedulesPage({ schedules, activeId, setActiveId, onPatch, onTrigger, onDelete, onOpenCreate }) {
const [filter, setFilter] = React.useState('all');
const [search, setSearch] = React.useState('');
const active = schedules.find(s => s.id === activeId) || schedules[0];
return (
<div style={{
flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: '320px 1fr',
background: '#f1f5f9', gap: 1,
}}>
<div style={{ background: '#fff', padding: 12, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<ScheduleListPane
items={schedules} activeId={active?.id} onSelect={setActiveId}
filter={filter} setFilter={setFilter} search={search} setSearch={setSearch}
onOpenCreate={onOpenCreate}
/>
</div>
<div style={{ background: '#fff', minWidth: 0 }}>
<ScheduleDetail sch={active} onPatch={onPatch} onTrigger={onTrigger} onDelete={onDelete} />
</div>
</div>
);
}
window.SchedulesPage = SchedulesPage;
@@ -1,318 +0,0 @@
// SettingsPage — sidebar groups + scrollable form (matches existing SettingsSidebar structure)
const SETTINGS_GROUPS = [
{
label: '基本設定',
sections: [
{ id: 'general', label: 'General', desc: 'タイムゾーン・言語' },
{ id: 'provider', label: 'Provider', desc: 'LLM API キー・デフォルトモデル' },
{ id: 'workers', label: 'Workers', desc: '並列数・タイムアウト・リトライ' },
{ id: 'workspace', label: 'Workspace', desc: '作業ディレクトリ・クリーンアップ' },
{ id: 'progress', label: 'Progress', desc: '進捗報告の頻度' },
],
},
{
label: 'セキュリティ・アクセス制御',
sections: [
{ id: 'repos', label: 'Repos', desc: 'Gitea 接続・許可リポジトリ' },
{ id: 'access-control', label: 'Access Control', desc: 'ロール・権限マトリクス' },
{ id: 'search-filter', label: 'Search Filter', desc: 'NGワード・ドメイン制限' },
],
},
{
label: 'ツール設定',
sections: [
{ id: 'tools', label: 'Tools', desc: '利用可能なツールの有効化' },
{ id: 'browser-settings', label: 'Browser', desc: 'noVNC・セッション' },
],
},
{
label: 'エージェント制御',
sections: [
{ id: 'ask-subtasks', label: 'Ask / Subtasks', desc: 'ASK・サブタスクの挙動' },
{ id: 'context', label: 'Context', desc: 'コンテキスト長・注入ルール' },
{ id: 'memory-safety', label: 'Memory / Safety', desc: 'メモリ制限・安全装置' },
],
},
];
const PIECES = ['auto', 'chat', 'research', 'general', 'x-ai-digest', 'brainstorming', 'data-process'];
function SettingsSidebar({ section, onSelect, piece, onSelectPiece }) {
const itemStyle = (active) => ({
display: 'block', width: '100%', textAlign: 'left',
padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: 12, fontFamily: 'inherit', marginBottom: 1,
background: active ? '#eff6ff' : 'transparent',
color: active ? '#1d4ed8' : '#475569',
fontWeight: active ? 700 : 500,
});
return (
<div style={{ height: '100%', overflowY: 'auto', padding: '12px 10px', background: '#fff', borderRight: '1px solid #e2e8f0' }}>
{SETTINGS_GROUPS.map(g => (
<div key={g.label} style={{ marginBottom: 12 }}>
<div style={{
fontSize: 10, fontWeight: 700, color: '#94a3b8', letterSpacing: '.08em',
textTransform: 'uppercase', padding: '4px 10px 4px', marginBottom: 2,
}}>{g.label}</div>
{g.sections.map(s => (
<button key={s.id} style={itemStyle(section === s.id && !piece)} onClick={() => onSelect(s.id)}>
{s.label}
</button>
))}
</div>
))}
<div style={{
fontSize: 10, fontWeight: 700, color: '#94a3b8', letterSpacing: '.08em',
textTransform: 'uppercase', padding: '4px 10px 4px', marginBottom: 2,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span>Pieces</span>
<button title="Piece を追加" style={{
border: 'none', background: 'transparent', color: '#2563eb',
cursor: 'pointer', fontSize: 14, fontWeight: 700, padding: 0, lineHeight: 1,
}}>+</button>
</div>
{PIECES.map(p => (
<button key={p} style={itemStyle(piece === p)} onClick={() => onSelectPiece(p)}>
{p}
</button>
))}
</div>
);
}
function SaveBar({ onDiscard }) {
// 3 states: idle / saving / saved / error — demonstrated via toggle
const [state, setState] = React.useState('idle');
const [dirty, setDirty] = React.useState(false);
// mark dirty when any descendant input changes
const onInput = React.useCallback(() => setDirty(true), []);
React.useEffect(() => {
const h = () => setDirty(true);
document.addEventListener('input', h);
return () => document.removeEventListener('input', h);
}, []);
const save = async () => {
setState('saving');
// mock latency; in 1/5 odds surface an error to showcase failure UI
await new Promise(r => setTimeout(r, 700));
if (Math.random() < 0.2) {
setState('error');
return;
}
setState('saved'); setDirty(false);
setTimeout(() => setState('idle'), 1500);
};
const discard = () => { setDirty(false); setState('idle'); onDiscard?.(); };
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{state === 'saved' && (
<span style={{
fontSize: 11, color: '#166534', background: '#dcfce7', padding: '3px 8px',
borderRadius: 9999, fontWeight: 700, display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4L19 7"/></svg>
保存しました
</span>
)}
{state === 'error' && (
<span style={{
fontSize: 11, color: '#b91c1c', background: '#fee2e2', padding: '3px 8px',
borderRadius: 9999, fontWeight: 700, display: 'inline-flex', alignItems: 'center', gap: 4,
}}> 保存に失敗</span>
)}
<button onClick={discard} disabled={!dirty || state === 'saving'} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #e2e8f0', color: '#475569',
borderRadius: 8, fontSize: 12, fontWeight: 700,
cursor: (!dirty || state === 'saving') ? 'not-allowed' : 'pointer',
opacity: (!dirty || state === 'saving') ? 0.5 : 1,
fontFamily: 'inherit',
}}>Discard</button>
<button onClick={save} disabled={state === 'saving'} style={{
padding: '6px 14px', background: '#2563eb', border: 'none', color: '#fff',
borderRadius: 8, fontSize: 12, fontWeight: 700,
cursor: state === 'saving' ? 'wait' : 'pointer',
opacity: state === 'saving' ? 0.7 : 1,
fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
{state === 'saving' && <Spinner />}
{state === 'saving' ? '保存中…' : 'Save & Apply'}
</button>
</div>
);
}
// Simple mock form surface — shows that the detail pane follows the exact same "card with form" rhythm as Schedules/Users.
function SettingsForm({ section, piece }) {
const meta = (() => {
for (const g of SETTINGS_GROUPS) for (const s of g.sections) if (s.id === section) return s;
return null;
})();
const title = piece ? `Piece: ${piece}` : (meta?.label || section);
const desc = piece ? `${piece} ピースの定義・ムーブメント・ツール設定` : (meta?.desc || '');
// Sample fields per-section (placeholder — the real forms are in gitea-agent-orchestrator/ui)
const fields = piece ? [
{ label: 'Description', kind: 'text', value: piece === 'auto' ? '入力内容から最適なピースを自動選択' : `${piece} 用の定義` },
{ label: 'Max movements', kind: 'number', value: 25 },
{ label: 'Initial movement', kind: 'select', value: 'execute', options: ['execute', 'plan', 'research'] },
] : ({
provider: [
{ label: 'Default provider', kind: 'select', value: 'anthropic', options: ['anthropic', 'openai', 'google', 'bedrock'] },
{ label: 'Model', kind: 'select', value: 'claude-sonnet-4.5', options: ['claude-sonnet-4.5', 'claude-opus-4', 'gpt-4.1'] },
{ label: 'API key', kind: 'password', value: 'sk-ant-•••••••••••••••••••••••', env: true },
{ label: 'Max tokens', kind: 'number', value: 8192 },
{ label: 'Temperature', kind: 'number', value: 0.7, step: 0.1 },
],
workers: [
{ label: 'Parallel workers', kind: 'number', value: 6 },
{ label: 'Per-task timeout (sec)', kind: 'number', value: 900 },
{ label: 'Max retries', kind: 'number', value: 3 },
{ label: 'Retry backoff (sec)', kind: 'number', value: 60 },
],
general: [
{ label: 'System timezone', kind: 'select', value: 'Asia/Tokyo', options: ['Asia/Tokyo', 'UTC', 'America/Los_Angeles'] },
{ label: 'Language', kind: 'select', value: 'ja', options: ['ja', 'en'] },
{ label: 'Allow anonymous task creation', kind: 'toggle', value: false },
],
repos: [
{ label: 'Gitea URL', kind: 'text', value: 'https://gitea.internal' },
{ label: 'Access token', kind: 'password', value: 'gitea_•••••••••••', env: true },
{ label: 'Allowed repos', kind: 'text', value: 'daichi/*, corp/*', help: 'カンマ区切り・グロブ可' },
],
'access-control': [
{ label: 'Admin ロール allow', kind: 'text', value: '*' },
{ label: 'Operator ロール allow', kind: 'text', value: 'tasks.*, schedules.*' },
{ label: 'Viewer ロール allow', kind: 'text', value: 'tasks.read, schedules.read' },
],
tools: [
{ label: 'Read / Write / Edit', kind: 'toggle', value: true },
{ label: 'Bash', kind: 'toggle', value: true },
{ label: 'Browser (noVNC)', kind: 'toggle', value: true },
{ label: 'WebSearch', kind: 'toggle', value: false },
],
'browser-settings': [
{ label: 'noVNC endpoint', kind: 'text', value: 'https://novnc.internal' },
{ label: 'Session timeout (min)', kind: 'number', value: 30 },
{ label: 'Allow CAPTCHA fallback to human', kind: 'toggle', value: true },
],
'ask-subtasks': [
{ label: 'Max ASK depth', kind: 'number', value: 3 },
{ label: 'Auto-resume after ASK timeout (min)', kind: 'number', value: 60 },
{ label: 'Allow parallel subtasks', kind: 'toggle', value: true },
],
context: [
{ label: 'Context window (tokens)', kind: 'number', value: 200000 },
{ label: 'Auto-compact threshold', kind: 'number', value: 80, help: '% で指定' },
],
'memory-safety': [
{ label: 'Memory limit per worker (MB)', kind: 'number', value: 2048 },
{ label: 'Kill on OOM', kind: 'toggle', value: true },
{ label: 'Safe-mode Bash commands only', kind: 'toggle', value: false },
],
progress: [
{ label: 'Progress update interval (sec)', kind: 'number', value: 15 },
{ label: 'Show subtask progress', kind: 'toggle', value: true },
],
workspace: [
{ label: 'Workspace root', kind: 'text', value: '/var/lib/agent/workspace' },
{ label: 'Clean after task completion', kind: 'toggle', value: false },
],
'search-filter': [
{ label: 'Blocked domains', kind: 'text', value: 'example-bad.com, *.malicious.example' },
{ label: 'NG words', kind: 'text', value: '', help: 'カンマ区切り' },
],
}[section] || []);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{
flexShrink: 0, padding: '14px 20px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase' }}>
{piece ? 'PIECE' : 'SETTINGS'}
</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>{title}</div>
{desc && <div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{desc}</div>}
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<SaveBar />
</div>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', background: '#f8fafc' }}>
<div style={{ maxWidth: 640, margin: '0 auto' }}>
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
{fields.map((f, i) => (
<FormRow key={i} label={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{f.label}
{f.env && <span style={{ fontSize: 9, fontWeight: 700, color: '#92400e', background: '#fef3c7', padding: '1px 5px', borderRadius: 3 }}>ENV</span>}
</span>
} help={f.help}>
{f.kind === 'toggle' ? (
<div style={{
display: 'inline-flex', alignItems: 'center', width: 44, height: 24, borderRadius: 9999,
background: f.value ? '#2563eb' : '#cbd5e1', padding: 2,
justifyContent: f.value ? 'flex-end' : 'flex-start', cursor: 'pointer',
}}>
<div style={{ width: 20, height: 20, borderRadius: 9999, background: '#fff', boxShadow: '0 1px 2px rgb(0 0 0 / .2)' }} />
</div>
) : f.kind === 'select' ? (
<SelectInput value={f.value} onChange={() => {}}>
{f.options.map(o => <option key={o} value={o}>{o}</option>)}
</SelectInput>
) : f.kind === 'password' ? (
<TextInput type="password" value={f.value} readOnly={!!f.env}
style={{ fontFamily: 'IBM Plex Mono, monospace', background: f.env ? '#f8fafc' : '#fff' }} />
) : f.kind === 'number' ? (
<TextInput type="number" value={f.value} step={f.step || 1} onChange={() => {}} />
) : (
<TextInput value={f.value} onChange={() => {}} />
)}
</FormRow>
))}
{fields.length === 0 && (
<EmptyState
compact
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></svg>}
title="このセクションは未実装です"
hint="`gitea-agent-orchestrator/ui` 側で設定項目を追加するとここに表示されます。"
/>
)}
</div>
<div style={{ height: 40 }} />
</div>
</div>
</div>
);
}
function SettingsPage({ section, setSection, piece, setPiece }) {
return (
<div style={{
flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: '240px 1fr',
background: '#f1f5f9', gap: 1,
}}>
<SettingsSidebar
section={section} onSelect={(s) => { setSection(s); setPiece(null); }}
piece={piece} onSelectPiece={(p) => setPiece(p)}
/>
<div style={{ background: '#fff', minWidth: 0 }}>
<SettingsForm section={section} piece={piece} />
</div>
</div>
);
}
window.SettingsPage = SettingsPage;
@@ -1,199 +0,0 @@
// TaskList — FilterBar + LocalTaskListItem recreation
const SORT_OPTIONS = [
{ value: 'updated', label: '新しい順' },
{ value: 'status', label: 'ステータス順' },
{ value: 'title', label: 'タイトル順' },
];
function SortMenu({ sort, onSort }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
}, []);
const current = SORT_OPTIONS.find(o => o.value === sort) || SORT_OPTIONS[0];
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={() => setOpen(v => !v)}
title={`並び順: ${current.label}`}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, border: 'none', background: open ? '#eff6ff' : 'transparent',
color: open ? '#1d4ed8' : '#64748b', borderRadius: 8, cursor: 'pointer',
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h13M3 12h9M3 18h5M17 8V4m0 0l-3 3m3-3l3 3"/></svg>
</button>
{open && (
<div style={{
position: 'absolute', right: 0, top: 'calc(100% + 6px)', zIndex: 10,
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
minWidth: 160, padding: 4,
}}>
{SORT_OPTIONS.map(o => (
<button key={o.value} onClick={() => { onSort(o.value); setOpen(false); }} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '6px 10px', borderRadius: 8, border: 'none',
background: sort === o.value ? '#eff6ff' : 'transparent',
color: sort === o.value ? '#1d4ed8' : '#334155',
fontSize: 12, fontWeight: sort === o.value ? 700 : 500, cursor: 'pointer',
fontFamily: 'inherit', textAlign: 'left',
}}>
{o.label}
{sort === o.value && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4L19 7"/></svg>}
</button>
))}
</div>
)}
</div>
);
}
function FilterBar({ status, onStatus, search, onSearch, sort, onSort, counts, total }) {
const columns = ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled'];
const chipStyle = (active) => ({
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
border: '1px solid ' + (active ? '#2563eb' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
color: active ? '#1d4ed8' : '#64748b',
fontFamily: 'inherit',
});
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 6, background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 12, padding: '4px 6px 4px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<IconSearch width={14} height={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
<input value={search} onChange={(e) => onSearch(e.target.value)} placeholder="検索..."
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0, padding: '4px 0' }} />
<div style={{ width: 1, height: 18, background: '#e2e8f0', flexShrink: 0 }} />
<SortMenu sort={sort} onSort={onSort} />
</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
<button style={chipStyle(status === 'all')} onClick={() => onStatus('all')}>
All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{total}</span>
</button>
{columns.map(s => (
<button key={s} style={chipStyle(status === s)} onClick={() => onStatus(s)}>
{STATUS_LABELS[s]} <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts[s] || 0}</span>
</button>
))}
</div>
</div>
);
}
function TaskItem({ task, active, onClick }) {
return (
<button onClick={onClick} style={{
width: '100%', textAlign: 'left', padding: '10px 12px', borderRadius: 12,
border: '1px solid ' + (active ? '#3b82f6' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
cursor: 'pointer', transition: 'background .15s', fontFamily: 'inherit',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
#{task.id} {task.title}
</div>
<StatusBadge status={task.status} small />
</div>
<div style={{ marginTop: 2, fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.body.length > 60 ? task.body.slice(0, 60) + '…' : task.body}
</div>
<div style={{ marginTop: 2, fontSize: 10, color: '#94a3b8' }}>{relativeTime(task.updatedAt)}</div>
</button>
);
}
function TaskList({ tasks, activeId, onSelect, filters, setFilters, onOpenCreate, loading, error, onRetry }) {
const counts = {};
for (const s of ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled']) {
counts[s] = tasks.filter(t => t.status === s).length;
}
const running = counts.running || 0;
const waiting = (counts.waiting_human || 0) + (counts.waiting_subtasks || 0);
const failed = counts.failed || 0;
const filtered = tasks
.filter(t => filters.status === 'all' || t.status === filters.status)
.filter(t => !filters.search || (t.title + t.body).toLowerCase().includes(filters.search.toLowerCase()))
.sort((a, b) => filters.sort === 'title' ? a.title.localeCompare(b.title) : b.updatedAt - a.updatedAt);
const hasSearch = !!filters.search || filters.status !== 'all';
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<button onClick={onOpenCreate} style={{
width: '100%', padding: '10px 14px', marginBottom: 10,
background: '#2563eb', color: '#fff', borderRadius: 12,
fontSize: 13, fontWeight: 700, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', gap: 6, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
transition: 'background .15s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = '#1d4ed8'}
onMouseLeave={(e) => e.currentTarget.style.background = '#2563eb'}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
新しい依頼
</button>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
fontSize: 11, color: '#64748b', padding: '0 2px 10px',
}}>
<span><b style={{ color: '#334155', fontWeight: 700 }}>{tasks.length}</b> </span>
<span style={{ color: '#cbd5e1' }}>·</span>
<span><b style={{ color: '#16a34a', fontWeight: 700 }}>{running}</b> 実行中</span>
<span><b style={{ color: '#d97706', fontWeight: 700 }}>{waiting}</b> 待機</span>
{failed > 0 && <span><b style={{ color: '#dc2626', fontWeight: 700 }}>{failed}</b> 失敗</span>}
</div>
<FilterBar
status={filters.status} onStatus={(s) => setFilters(f => ({ ...f, status: s }))}
search={filters.search} onSearch={(q) => setFilters(f => ({ ...f, search: q }))}
sort={filters.sort} onSort={(s) => setFilters(f => ({ ...f, sort: s }))}
counts={counts} total={tasks.length}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
{loading && <SkeletonList count={6} />}
{!loading && error && (
<ErrorState
title="タスクの読み込みに失敗"
hint={error}
onRetry={onRetry}
compact
/>
)}
{!loading && !error && filtered.map(t => <TaskItem key={t.id} task={t} active={activeId === t.id} onClick={() => onSelect(t.id)} />)}
{!loading && !error && filtered.length === 0 && (
hasSearch ? (
<EmptyState
compact
icon={<IconSearch width={18} height={18} />}
title="該当するタスクはありません"
hint="検索ワードやステータスフィルタを変えてみてください。"
action={
<button onClick={() => setFilters(f => ({ ...f, search: '', status: 'all' }))} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 700,
background: '#fff', border: '1px solid #e2e8f0', color: '#334155',
cursor: 'pointer', fontFamily: 'inherit',
}}>フィルタをクリア</button>
}
/>
) : (
<EmptyState
compact
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 12h6M9 16h6M9 8h6M5 21h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>}
title="まだ依頼がありません"
hint="左上の「新しい依頼」から最初のタスクを作成できます。"
/>
)
)}
</div>
</div>
);
}
window.TaskList = TaskList;
@@ -1,57 +0,0 @@
// TopBar — mirrors ui/src/components/layout/TopBar.tsx
function TopBar({ page, onNavigate, counts, onOpenCreate, user }) {
const navItem = (id, label) => {
const active = page === id;
return (
<button
key={id}
onClick={() => onNavigate(id)}
style={{
position: 'relative',
padding: '10px 4px', marginBottom: -13,
fontSize: 12, fontWeight: active ? 700 : 500,
border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
color: active ? '#0f172a' : '#64748b',
borderBottom: '2px solid ' + (active ? '#2563eb' : 'transparent'),
transition: 'color .15s, border-color .15s',
}}
>{label}</button>
);
};
return (
<div style={{
flexShrink: 0, background: '#fff', borderBottom: '1px solid #e2e8f0',
padding: '12px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<img src="../../assets/logo.svg" width="22" height="22" alt="" />
<span style={{
fontFamily: 'IBM Plex Mono, monospace', fontSize: 11, fontWeight: 700,
color: '#2563eb', textTransform: 'uppercase', letterSpacing: '.16em',
}}>Agent Orchestrator</span>
<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 10, color: '#94a3b8' }}>v1.14.0</span>
<div style={{ display: 'flex', gap: 14, alignItems: 'stretch' }}>
{navItem('tasks', 'Tasks')}
{navItem('schedules', 'Schedules')}
{navItem('settings', 'Settings')}
{navItem('users', 'Users')}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{user && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{
width: 24, height: 24, borderRadius: 9999, background: '#dbeafe', color: '#1d4ed8',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700,
}}>{user.name.charAt(0)}</div>
<span style={{ fontSize: 12, color: '#475569' }}>{user.name}</span>
</div>
)}
</div>
</div>
);
}
window.TopBar = TopBar;
@@ -1,313 +0,0 @@
// UsersPage — left list of users + center profile/role editor
// Data model derives from ui/src/pages/UsersPage.tsx.
const ROLE_TONE = {
admin: { bg: '#ede9fe', fg: '#5b21b6' },
operator: { bg: '#dbeafe', fg: '#1d4ed8' },
viewer: { bg: '#e2e8f0', fg: '#475569' },
};
const USER_STATUS_TONE = {
pending: { bg: '#fef9c3', fg: '#854d0e', label: '承認待ち' },
active: { bg: '#dcfce7', fg: '#166534', label: 'アクティブ' },
disabled: { bg: '#e2e8f0', fg: '#475569', label: '無効' },
};
function UserAvatar({ name, size = 32 }) {
const initial = (name || '?').charAt(0).toUpperCase();
// simple deterministic hue from name
let hue = 0; for (const c of (name || '')) hue = (hue * 31 + c.charCodeAt(0)) % 360;
return (
<div style={{
width: size, height: size, borderRadius: 9999,
background: `hsl(${hue} 60% 92%)`, color: `hsl(${hue} 50% 35%)`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.45, fontWeight: 800, flexShrink: 0,
}}>{initial}</div>
);
}
function UserListItem({ user, active, onClick }) {
return (
<button onClick={onClick} style={{
width: '100%', textAlign: 'left', padding: '10px 12px', borderRadius: 12,
border: '1px solid ' + (active ? '#3b82f6' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
cursor: 'pointer', transition: 'background .15s', fontFamily: 'inherit',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<UserAvatar name={user.name || user.email} size={36} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
<div style={{
flex: 1, minWidth: 0,
fontSize: 13, fontWeight: 700, color: '#0f172a',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{user.name || '(未設定)'}</div>
{user.status === 'pending' && (
<span style={{
fontSize: 10, fontWeight: 700, color: '#854d0e', background: '#fef9c3',
padding: '1px 6px', borderRadius: 4, flexShrink: 0,
}}>承認</span>
)}
</div>
<div style={{ fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{user.email}
</div>
<div style={{ marginTop: 2, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 4,
background: ROLE_TONE[user.role]?.bg, color: ROLE_TONE[user.role]?.fg,
}}>{user.role}</span>
<span style={{ fontSize: 10, color: '#94a3b8' }}>· {user.taskCount} タスク</span>
</div>
</div>
</button>
);
}
function UserListPane({ users, activeId, onSelect, filter, setFilter, search, setSearch, onOpenInvite }) {
const filtered = users.filter(u => {
if (filter !== 'all' && u.role !== filter && u.status !== filter) return false;
if (search && !((u.name || '') + u.email).toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
const counts = {
all: users.length,
admin: users.filter(u => u.role === 'admin').length,
operator: users.filter(u => u.role === 'operator').length,
viewer: users.filter(u => u.role === 'viewer').length,
pending: users.filter(u => u.status === 'pending').length,
};
const chipStyle = (on) => ({
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
border: '1px solid ' + (on ? '#2563eb' : '#e2e8f0'),
background: on ? '#eff6ff' : '#fff',
color: on ? '#1d4ed8' : '#64748b', fontFamily: 'inherit',
});
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<button onClick={onOpenInvite} style={{
width: '100%', padding: '10px 14px', marginBottom: 10, background: '#2563eb',
color: '#fff', borderRadius: 12, fontSize: 13, fontWeight: 700, border: 'none',
cursor: 'pointer', fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', gap: 6, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
ユーザーを招待
</button>
<div style={{
display: 'flex', alignItems: 'center', gap: 10, fontSize: 11,
color: '#64748b', padding: '0 2px 10px',
}}>
<span><b style={{ color: '#334155', fontWeight: 700 }}>{counts.all}</b> </span>
{counts.pending > 0 && <><span style={{ color: '#cbd5e1' }}>·</span>
<span><b style={{ color: '#d97706', fontWeight: 700 }}>{counts.pending}</b> 承認待ち</span></>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 12, padding: '6px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<IconSearch width={14} height={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="名前・メールで検索..."
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0 }} />
</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
<button style={chipStyle(filter === 'all')} onClick={() => setFilter('all')}>All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.all}</span></button>
<button style={chipStyle(filter === 'admin')} onClick={() => setFilter('admin')}>Admin <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.admin}</span></button>
<button style={chipStyle(filter === 'operator')} onClick={() => setFilter('operator')}>Operator <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.operator}</span></button>
<button style={chipStyle(filter === 'viewer')} onClick={() => setFilter('viewer')}>Viewer <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.viewer}</span></button>
{counts.pending > 0 && <button style={chipStyle(filter === 'pending')} onClick={() => setFilter('pending')}>承認待ち <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.pending}</span></button>}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
{filtered.map(u => <UserListItem key={u.id} user={u} active={activeId === u.id} onClick={() => onSelect(u.id)} />)}
{filtered.length === 0 && (
(search || filter !== 'all') ? (
<EmptyState
compact
icon={<IconSearch width={18} height={18} />}
title="該当するユーザーがいません"
hint="検索やフィルタを変えてみてください。"
action={
<button onClick={() => { setSearch(''); setFilter('all'); }} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 700,
background: '#fff', border: '1px solid #e2e8f0', color: '#334155',
cursor: 'pointer', fontFamily: 'inherit',
}}>フィルタをクリア</button>
}
/>
) : (
<EmptyState
compact
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg>}
title="ユーザーがいません"
hint="右上の「ユーザーを招待」から追加できます。"
/>
)
)}
</div>
</div>
);
}
function UserDetail({ user, onPatch, onDelete, onApprove }) {
if (!user) {
return (
<div style={{ padding: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<EmptyState
icon={<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a8 8 0 0116 0v1"/></svg>}
title="ユーザーを選択してください"
hint="左のリストから表示・編集したいユーザーを開きます。"
/>
</div>
);
}
const statusTone = USER_STATUS_TONE[user.status] || USER_STATUS_TONE.active;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{
flexShrink: 0, padding: '14px 20px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
<UserAvatar name={user.name || user.email} size={40} />
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>{user.name || '(未設定)'}</div>
<span style={{
fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 9999,
background: statusTone.bg, color: statusTone.fg,
}}>{statusTone.label}</span>
</div>
<div style={{ fontSize: 12, color: '#64748b' }}>{user.email}</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{user.status === 'pending' && (
<button onClick={() => onApprove(user.id)} style={{
padding: '6px 12px', background: '#16a34a', border: 'none', color: '#fff',
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
}}>承認</button>
)}
<button onClick={() => onDelete(user.id)} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #fecaca', color: '#dc2626',
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
}}>削除</button>
</div>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', background: '#f8fafc' }}>
<div style={{ maxWidth: 640, margin: '0 auto' }}>
{/* Summary strip */}
<div style={{ display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap' }}>
<StatChip label="タスク数" value={user.taskCount} />
<StatChip label="最終ログイン" value={user.lastLogin || '—'} />
<StatChip label="登録日" value={user.createdAt || '—'} />
</div>
{/* Role & permissions */}
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
ロールと権限
</div>
{[
{ id: 'admin', label: 'Admin', desc: '全ての設定変更・ユーザー管理・システム操作' },
{ id: 'operator', label: 'Operator', desc: 'タスク作成・実行・スケジュール管理' },
{ id: 'viewer', label: 'Viewer', desc: '閲覧のみ。タスクの作成・変更不可' },
].map(r => (
<button key={r.id} onClick={() => onPatch(user.id, { role: r.id })} style={{
width: '100%', textAlign: 'left', padding: '12px 14px', borderRadius: 10,
border: '1px solid ' + (user.role === r.id ? '#2563eb' : '#e2e8f0'),
background: user.role === r.id ? '#eff6ff' : '#fff',
cursor: 'pointer', fontFamily: 'inherit', marginBottom: 8,
display: 'flex', alignItems: 'center', gap: 12,
}}>
<span style={{
width: 18, height: 18, borderRadius: 9999, flexShrink: 0,
border: '2px solid ' + (user.role === r.id ? '#2563eb' : '#cbd5e1'),
background: user.role === r.id ? '#2563eb' : '#fff',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
{user.role === r.id && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4L19 7"/></svg>}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a' }}>{r.label}</div>
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2 }}>{r.desc}</div>
</div>
</button>
))}
</div>
{/* Profile */}
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
marginTop: 16, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
プロフィール
</div>
<FormRow label="表示名">
<TextInput value={user.name || ''} onChange={e => onPatch(user.id, { name: e.target.value })} />
</FormRow>
<FormRow label="メールアドレス">
<TextInput value={user.email} readOnly style={{ background: '#f8fafc', color: '#64748b' }} />
</FormRow>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormRow label="タイムゾーン">
<SelectInput value={user.timezone || 'Asia/Tokyo'} onChange={e => onPatch(user.id, { timezone: e.target.value })}>
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="UTC">UTC</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="Europe/London">Europe/London</option>
</SelectInput>
</FormRow>
<FormRow label="デフォルトピース">
<SelectInput value={user.defaultPiece || 'auto'} onChange={e => onPatch(user.id, { defaultPiece: e.target.value })}>
<option value="auto">auto</option>
<option value="chat">chat</option>
<option value="research">research</option>
</SelectInput>
</FormRow>
</div>
</div>
<div style={{ height: 40 }} />
</div>
</div>
</div>
);
}
function UsersPage({ users, activeId, setActiveId, onPatch, onDelete, onApprove, onOpenInvite }) {
const [filter, setFilter] = React.useState('all');
const [search, setSearch] = React.useState('');
const active = users.find(u => u.id === activeId) || users[0];
return (
<div style={{
flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: '320px 1fr',
background: '#f1f5f9', gap: 1,
}}>
<div style={{ background: '#fff', padding: 12, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<UserListPane
users={users} activeId={active?.id} onSelect={setActiveId}
filter={filter} setFilter={setFilter} search={search} setSearch={setSearch}
onOpenInvite={onOpenInvite}
/>
</div>
<div style={{ background: '#fff', minWidth: 0 }}>
<UserDetail user={active} onPatch={onPatch} onDelete={onDelete} onApprove={onApprove} />
</div>
</div>
);
}
window.UsersPage = UsersPage;
@@ -1,399 +0,0 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>Agent Orchestrator — Admin</title>
<link rel="stylesheet" href="../../colors_and_type.css">
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: var(--font-sans);
background: var(--bg-app, #f8fafc);
color: var(--fg1, #0f172a);
font-size: 13px;
-webkit-font-smoothing: antialiased;
}
@keyframes ao-spin { from { transform: rotate(0) } to { transform: rotate(360deg) } }
@keyframes ao-pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.35 } }
@keyframes ao-shimmer { 0% { background-position: 200% 0 } 100% { background-position: -200% 0 } }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border: 2px solid #f8fafc; border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/[email protected]/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/[email protected]/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="./Primitives.jsx"></script>
<script type="text/babel" src="./TopBar.jsx"></script>
<script type="text/babel" src="./TaskList.jsx"></script>
<script type="text/babel" src="./ChatPane.jsx"></script>
<script type="text/babel" src="./DetailPanel.jsx"></script>
<script type="text/babel" src="./SchedulesPage.jsx"></script>
<script type="text/babel" src="./UsersPage.jsx"></script>
<script type="text/babel" src="./SettingsPage.jsx"></script>
<script type="text/babel">
const MIN = 60 * 1000;
const H = 60 * MIN;
const now = Date.now();
const SAMPLE_TASKS = [
{
id: 412, title: 'Xの朝のAIダイジェスト生成',
body: '毎朝 7:00 JST にフォロー中のAI関連アカウントの過去24hをサマリし、DMで送信する。Twitter CLIを使用し、1スレッドにまとめること。',
status: 'running', piece: 'x-ai-digest', worker: 'worker-03', attempts: 1,
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/412-morning-digest',
createdAt: now - 2*H, updatedAt: now - 4*MIN,
events: [
{ kind: 'info', label: '/brainstorm 完了', meta: '12個のアイデアを生成', time: '10:42' },
{ kind: 'info', label: '/plan 完了', meta: '12ステップ · 推定 4分', time: '10:43' },
{ kind: 'info', label: '/implement 実行中', meta: 'ステップ 8 / 12', time: '10:45' },
],
},
{
id: 411, title: 'Brave Search の CAPTCHA 回避',
body: 'noVNC経由でBraveに繰り返しCAPTCHAが発生。ユーザーの介入が必要。',
status: 'waiting_human', piece: 'general', worker: 'worker-01', attempts: 2,
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/411-brave-captcha',
createdAt: now - 3*H, updatedAt: now - 18*MIN,
events: [
{ kind: 'info', label: '/brainstorm 完了', meta: '', time: '08:10' },
{ kind: 'error', label: 'ASK が発行されました', meta: 'CAPTCHAの解決を依頼', time: '08:22' },
],
},
{
id: 410, title: 'GitHub Issue #284 の対応',
body: 'scheduler.ts のタイムアウト処理リファクタ。worker-manager.test.ts を更新。',
status: 'succeeded', piece: 'general', worker: 'worker-02', attempts: 1,
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/410-sched-timeout',
createdAt: now - 8*H, updatedAt: now - 2*H,
events: [
{ kind: 'info', label: '/plan 完了', meta: '7ステップ', time: '02:11' },
{ kind: 'ok', label: 'PR 作成', meta: '#284 テスト通過', time: '04:08' },
],
},
{
id: 409, title: 'ブレスト: 社内AIエージェント活用事例',
body: '営業部向け、週次の活用アイデアを10件ブレストし、優先順位をつけて提出。',
status: 'queued', piece: 'brainstorming', worker: null, attempts: 0,
assignee: '@tomoko', repo: 'gitea:corp/ops', branch: '—',
createdAt: now - 30*MIN, updatedAt: now - 25*MIN,
events: [],
},
{
id: 408, title: '四半期データの集計とグラフ化',
body: 'Q3のSNSエンゲージメントを集計し、CSVとPNGで出力。',
status: 'waiting_subtasks', piece: 'data-process', worker: 'worker-05', attempts: 1,
assignee: '@kenta', repo: 'gitea:corp/analytics', branch: 'task/408-q3-roundup',
createdAt: now - 5*H, updatedAt: now - 45*MIN,
events: [
{ kind: 'info', label: 'サブタスク3件を発行', meta: '#408-1, #408-2, #408-3', time: '09:30' },
],
},
{
id: 407, title: '競合サービスのリサーチ',
body: 'エージェント型ワーカー系SaaSを3社分析し、比較表を作成する。',
status: 'failed', piece: 'research', worker: 'worker-04', attempts: 3,
assignee: '@tomoko', repo: 'gitea:corp/research', branch: 'task/407-competitors',
createdAt: now - 26*H, updatedAt: now - 10*H,
events: [
{ kind: 'error', label: 'タイムアウト', meta: '3回連続で失敗', time: 'yesterday' },
],
},
{
id: 406, title: 'ゲーム実況の告知ツイート生成',
body: '今夜のストリーム用の告知ツイートを3案作成。ハッシュタグ付き。',
status: 'retry', piece: 'game-tweet-generator', worker: 'worker-06', attempts: 2,
assignee: '@daichi', repo: 'gitea:daichi/stream', branch: 'task/406-tweet-gen',
createdAt: now - 40*MIN, updatedAt: now - 8*MIN,
events: [
{ kind: 'error', label: 'レート制限', meta: '60秒後に再試行', time: '10:35' },
],
},
{
id: 405, title: '経費申請書のOCRと仕分け',
body: '添付PDFをOCRし、勘定科目ごとに仕分け。',
status: 'cancelled', piece: 'office-process', worker: null, attempts: 1,
assignee: '@kenta', repo: 'gitea:corp/ops', branch: '—',
createdAt: now - 48*H, updatedAt: now - 20*H,
events: [],
},
];
const INITIAL_MESSAGES = {
412: [
{ role: 'user', content: '毎朝7:00にAI関連のXアカウントの過去24hをまとめて、DMで送ってほしい。1スレッドで。', footer: '10:41 · @daichi' },
{ role: 'assistant', content: '了解。x-ai-digest ピースを使用します。対象アカウント、まとめる観点、文字数制限を確認させてください。' },
{ role: 'ask', content: '❓ 以下を確認させてください:\n\n1. 対象アカウントリストはこのリポジトリの accounts.txt で良いですか?\n2. 1ツイートあたりの上限文字数は280でOK?\n3. 日本語メインの要約で良いですか?' },
{ role: 'user', content: '1. OK\n2. OK\n3. 日本語で。でも原文が英語なら簡潔な英語の引用も残して。' },
{ role: 'progress', content: '/implement — ステップ 8 / 12 (Twitter CLIでタイムライン取得中)' },
],
411: [
{ role: 'user', content: 'Brave Searchで検索結果が取れない。何度もCAPTCHAが出てるっぽい。' },
{ role: 'progress', content: 'noVNCセッションを開いて確認中...' },
{ role: 'ask', content: '❓ CAPTCHAの解決が必要です。noVNCで手動で解決していただけますか?\n\nsession: https://novnc.internal/412\n\n解決後 `/resume 411` と返信してください。' },
],
410: [
{ role: 'user', content: 'Issue #284 の対応お願い。scheduler.tsのタイムアウト処理が不安定。' },
{ role: 'assistant', content: '了解。/brainstorm から始めます。' },
{ role: 'result', content: '✅ 完了しました。\n\n- PR: gitea:daichi/agent-orchestrator#291\n- 変更: scheduler.ts, worker-manager.test.ts, worker.test.ts\n- テスト: 42 passed\n\nレビューお願いします。' },
],
409: [
{ role: 'user', content: '営業部向けに今週のエージェント活用ネタを10個ブレストしてほしい。' },
{ role: 'assistant', content: '了解。キューに入りました。ワーカーの空きが出次第処理します。' },
],
408: [
{ role: 'user', content: 'Q3のSNSエンゲージメントまとめて、折れ線グラフと棒グラフのPNGで。' },
{ role: 'progress', content: 'サブタスク3件の完了を待っています (#408-1, #408-2, #408-3)' },
],
407: [
{ role: 'user', content: '競合3社のリサーチと比較表を作成。' },
{ role: 'assistant', content: '3回試行しましたが、外部サイトの読み込みタイムアウトが続いています。' },
],
406: [
{ role: 'user', content: '今夜のストリーム告知を3案、ハッシュタグ付きで。' },
{ role: 'progress', content: 'レート制限中 — 60秒後に再試行します' },
],
405: [
{ role: 'user', content: '経費PDFのOCRと仕分け。' },
{ role: 'assistant', content: 'キャンセルされました。' },
],
};
const SAMPLE_SCHEDULES = [
{
id: 1, title: '毎朝のAIダイジェスト', body: 'フォロー中のAI関連アカウントの過去24hをサマリし、DMで送る。',
pieceName: 'x-ai-digest', outputFormat: 'markdown',
triggerKind: 'cron', cronExpression: '0 7 * * *',
nextRunAt: new Date(now + 8*H).toISOString(),
lastRunAt: new Date(now - 16*H).toISOString(),
isActive: true,
history: [
{ taskId: 412, status: 'running', summary: '実行中', at: new Date(now - 4*MIN).toISOString() },
{ taskId: 398, status: 'succeeded', summary: '12アカウント ・ 8件のハイライト', at: new Date(now - 16*H).toISOString() },
{ taskId: 385, status: 'succeeded', summary: '9アカウント ・ 5件のハイライト', at: new Date(now - 40*H).toISOString() },
{ taskId: 373, status: 'failed', summary: 'Twitter API レート制限', at: new Date(now - 64*H).toISOString() },
],
},
{
id: 2, title: '週次ニュースまとめ (月曜09:00)', body: '先週の業界ニュースを5本まとめ、社内Slackに投稿。',
pieceName: 'research', outputFormat: 'markdown',
triggerKind: 'cron', cronExpression: '0 9 * * 1',
nextRunAt: new Date(now + 4*24*H).toISOString(),
lastRunAt: new Date(now - 3*24*H).toISOString(),
isActive: true,
history: [
{ taskId: 340, status: 'succeeded', summary: '5本投稿', at: new Date(now - 3*24*H).toISOString() },
],
},
{
id: 3, title: 'GitHub Issue 自動トリアージ', body: '新規 Issue にラベル付けし、優先度を判定してコメント。',
pieceName: 'general', outputFormat: 'json',
triggerKind: 'event', eventSource: 'github.issue.opened', eventFilter: 'repo == "agent-orchestrator"',
lastRunAt: new Date(now - 2*H).toISOString(),
isActive: true,
history: [
{ taskId: 408, status: 'succeeded', summary: '#294 に bugラベルを付与', at: new Date(now - 2*H).toISOString() },
{ taskId: 402, status: 'succeeded', summary: '#293 に enhancementラベル', at: new Date(now - 6*H).toISOString() },
],
},
{
id: 4, title: '月次レポート生成', body: '月末にKPIサマリを作成し、経営会議用PDFを出力。',
pieceName: 'data-process', outputFormat: 'markdown',
triggerKind: 'cron', cronExpression: '0 18 28 * *',
nextRunAt: new Date(now + 10*24*H).toISOString(),
lastRunAt: null,
isActive: false,
history: [],
},
];
const SAMPLE_USERS = [
{ id: 'u1', name: 'Daichi', email: '[email protected]', role: 'admin', status: 'active', taskCount: 142, lastLogin: 'たった今', createdAt: '2025-11-02', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
{ id: 'u2', name: 'Tomoko', email: '[email protected]', role: 'operator', status: 'active', taskCount: 38, lastLogin: '2時間前', createdAt: '2025-12-14', timezone: 'Asia/Tokyo', defaultPiece: 'chat' },
{ id: 'u3', name: 'Kenta', email: '[email protected]', role: 'operator', status: 'active', taskCount: 21, lastLogin: '昨日', createdAt: '2026-01-20', timezone: 'Asia/Tokyo', defaultPiece: 'research' },
{ id: 'u4', name: 'Aya', email: '[email protected]', role: 'viewer', status: 'active', taskCount: 4, lastLogin: '3日前', createdAt: '2026-02-09', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
{ id: 'u5', name: null, email: '[email protected]', role: 'viewer', status: 'pending', taskCount: 0, lastLogin: '—', createdAt: '2026-04-17', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
{ id: 'u6', name: 'Hiro', email: '[email protected]', role: 'viewer', status: 'disabled', taskCount: 2, lastLogin: '1ヶ月前', createdAt: '2026-01-03', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
];
function DemoStateFloater({ state, setState }) {
const [open, setOpen] = React.useState(false);
const STATES = [
{ id: 'normal', label: '通常', hint: 'サンプルデータを表示' },
{ id: 'loading', label: 'Loading', hint: 'スケルトンを表示' },
{ id: 'error', label: 'Error', hint: 'エラー状態と再試行ボタン' },
{ id: 'empty', label: 'Empty', hint: 'データなし・初回利用' },
];
const current = STATES.find(s => s.id === state) || STATES[0];
return (
<div style={{
position: 'fixed', right: 16, bottom: 16, zIndex: 50,
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8,
}}>
{open && (
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
boxShadow: '0 10px 25px -5px rgb(0 0 0 / 0.15), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
padding: 8, minWidth: 220,
}}>
<div style={{
fontSize: 10, fontWeight: 700, color: '#94a3b8', letterSpacing: '.08em',
textTransform: 'uppercase', padding: '4px 10px 6px',
}}>デモ状態</div>
{STATES.map(s => (
<button key={s.id} onClick={() => setState(s.id)} style={{
display: 'block', width: '100%', textAlign: 'left', padding: '8px 10px',
border: 'none', borderRadius: 8, fontFamily: 'inherit', cursor: 'pointer',
background: state === s.id ? '#eff6ff' : 'transparent',
}}>
<div style={{ fontSize: 12, fontWeight: 700, color: state === s.id ? '#1d4ed8' : '#334155' }}>{s.label}</div>
<div style={{ fontSize: 11, color: '#64748b', marginTop: 1 }}>{s.hint}</div>
</button>
))}
</div>
)}
<button onClick={() => setOpen(v => !v)} title="デモ状態を切り替え" style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '8px 14px', borderRadius: 9999,
background: '#0f172a', color: '#fff', border: 'none', cursor: 'pointer',
fontSize: 11, fontWeight: 700, fontFamily: 'inherit',
boxShadow: '0 4px 12px rgb(0 0 0 / 0.25)',
}}>
<span style={{ width: 8, height: 8, borderRadius: 9999,
background: state === 'normal' ? '#22c55e' : state === 'loading' ? '#3b82f6' : state === 'error' ? '#ef4444' : '#94a3b8' }} />
状態: {current.label}
</button>
</div>
);
}
function App() {
const [tasks, setTasks] = React.useState(SAMPLE_TASKS);
const [activeId, setActiveId] = React.useState(412);
const [detailOpen, setDetailOpen] = React.useState(true);
const [messages, setMessages] = React.useState(INITIAL_MESSAGES);
const [filters, setFilters] = React.useState({ status: 'all', search: '', sort: 'updated' });
const [page, setPage] = React.useState('tasks');
const [schedules, setSchedules] = React.useState(SAMPLE_SCHEDULES);
const [activeScheduleId, setActiveScheduleId] = React.useState(1);
const patchSchedule = (id, patch) => setSchedules(xs => xs.map(s => s.id === id ? { ...s, ...patch } : s));
const triggerSchedule = (id) => alert('#' + id + ' を今すぐ実行 (モック)');
const deleteSchedule = (id) => setSchedules(xs => xs.filter(s => s.id !== id));
const [users, setUsers] = React.useState(SAMPLE_USERS);
const [activeUserId, setActiveUserId] = React.useState('u1');
const patchUser = (id, patch) => setUsers(xs => xs.map(u => u.id === id ? { ...u, ...patch } : u));
const deleteUser = (id) => setUsers(xs => xs.filter(u => u.id !== id));
const approveUser = (id) => patchUser(id, { status: 'active' });
const [settingsSection, setSettingsSection] = React.useState('provider');
const [settingsPiece, setSettingsPiece] = React.useState(null);
// ── Demo state switch (loading / error / empty) — just a small floater, not part of the product ──
const [demoState, setDemoState] = React.useState(() => localStorage.getItem('admin-demo-state') || 'normal');
React.useEffect(() => { localStorage.setItem('admin-demo-state', demoState); }, [demoState]);
const isLoading = demoState === 'loading';
const hasError = demoState === 'error';
const isEmpty = demoState === 'empty';
const viewTasks = isLoading || hasError ? [] : (isEmpty ? [] : tasks);
const viewActiveId = isEmpty ? null : activeId;
const viewSchedules = isLoading || isEmpty ? [] : schedules;
const viewUsers = isLoading || isEmpty ? [] : users;
const active = tasks.find(t => t.id === activeId) || tasks[0];
const counts = {
total: tasks.length,
running: tasks.filter(t => t.status === 'running').length,
waiting: tasks.filter(t => t.status === 'waiting_human' || t.status === 'waiting_subtasks').length,
failed: tasks.filter(t => t.status === 'failed').length,
};
const onSend = (text) => {
setMessages(m => ({
...m,
[activeId]: [...(m[activeId] || []), { role: 'user', content: text, footer: 'たった今 · @daichi' }],
}));
// fake echo after small delay
setTimeout(() => {
setMessages(m => ({
...m,
[activeId]: [...(m[activeId] || []), { role: 'progress', content: 'エージェントが応答を生成中...' }],
}));
}, 400);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<TopBar
page={page} onNavigate={setPage}
counts={counts}
onOpenCreate={() => alert('新しい依頼 (モック)')}
user={{ name: 'Daichi' }}
/>
{page === 'tasks' && (
<div style={{
flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: detailOpen ? '320px 1fr 380px' : '320px 1fr',
background: '#f1f5f9', gap: 1,
}}>
<div style={{ background: '#fff', padding: 12, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<TaskList
tasks={viewTasks} activeId={viewActiveId} onSelect={setActiveId}
filters={filters} setFilters={setFilters}
onOpenCreate={() => alert('新しい依頼 (モック)')}
loading={isLoading}
error={hasError ? 'ネットワークエラー: 接続を確認してください' : null}
onRetry={() => setDemoState('normal')}
/>
</div>
<ChatPane
task={isLoading || hasError || isEmpty ? null : active}
messages={!active ? [] : (messages[active.id] || [])}
onSend={onSend}
onOpenDetail={() => setDetailOpen(v => !v)}
detailOpen={detailOpen}
loading={isLoading}
onOpenCreate={() => alert('新しい依頼 (モック)')}
/>
{detailOpen && !isLoading && !hasError && !isEmpty && <DetailPanel task={active} onClose={() => setDetailOpen(false)} />}
</div>
)}
{page === 'schedules' && (
<SchedulesPage
schedules={viewSchedules} activeId={isEmpty || isLoading ? null : activeScheduleId} setActiveId={setActiveScheduleId}
onPatch={patchSchedule} onTrigger={triggerSchedule} onDelete={deleteSchedule}
onOpenCreate={() => alert('新しいスケジュール (モック)')}
/>
)}
{page === 'users' && (
<UsersPage
users={viewUsers} activeId={isEmpty || isLoading ? null : activeUserId} setActiveId={setActiveUserId}
onPatch={patchUser} onDelete={deleteUser} onApprove={approveUser}
onOpenInvite={() => alert('ユーザーを招待 (モック)')}
/>
)}
{page === 'settings' && (
<SettingsPage
section={settingsSection} setSection={setSettingsSection}
piece={settingsPiece} setPiece={setSettingsPiece}
/>
)}
<DemoStateFloater state={demoState} setState={setDemoState} />
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
@@ -1,31 +0,0 @@
<!doctype html>
<html lang="ja"><head><meta charset="utf-8">
<title>比較: 新しい依頼ボタン配置</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #f1f5f9; font-family: var(--font-sans); }
.wrap { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; height: 100vh; background: #cbd5e1; }
.col { display: flex; flex-direction: column; background: #fff; overflow: hidden; }
.label {
flex-shrink: 0; padding: 10px 16px; background: #0f172a; color: #f1f5f9;
font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 700;
letter-spacing: .1em; text-transform: uppercase;
display: flex; justify-content: space-between; align-items: center;
}
.label .tag { color: #94a3b8; font-weight: 400; }
.label.a { background: #1d4ed8; }
iframe { flex: 1; width: 100%; border: none; min-height: 0; }
</style></head>
<body>
<div class="wrap">
<div class="col">
<div class="label"><span>現状</span><span class="tag">TopBar 右上</span></div>
<iframe src="admin/index.html"></iframe>
</div>
<div class="col">
<div class="label a"><span>案 A</span><span class="tag">左パネル最上部</span></div>
<iframe src="admin-v2/index.html"></iframe>
</div>
</div>
</body></html>
+109
View File
@@ -0,0 +1,109 @@
[English](getting-started.md) | 日本語
# Getting Started
MAESTRO を起動して最初のタスクを動かすまでのガイド。設定項目の詳細は
[configuration.md](configuration.md)、全体構造は [architecture.md](architecture.md) を参照。
## 1. 前提
- **Node.js 22 以上**
- **OpenAI 互換の LLM エンドポイント** — 例: [Ollama](https://ollama.com/)`http://localhost:11434/v1`)、vLLM など。MAESTRO 自体のビルド/テストには不要だが、タスク実行には必要。
- **任意(Bash サンドボックス用)**: `bwrap`bubblewrap, 非特権 user namespace が有効なこと)と `python3`/`pip`。マルチユーザー運用では有効化を推奨([operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md))。
## 2. インストール(ソースから)
```bash
git clone https://gitea.example.com/your-org/maestro.git
cd maestro
npm ci # バックエンド依存
npm --prefix ui ci # UI 依存
```
## 3. 最小設定(対話ウィザード)
`npm run setup` で LLM 接続先を対話的に設定し、最小の `config.yaml` を生成する。
```bash
npm run setup
```
- 接続タイプ(`direct` = Ollama/vLLM 等 / `aao_gateway` = 別 MAESTRO Gateway 経由)を選ぶ。
- LLM endpoint URL(例 `http://localhost:11434/v1`)を入力。接続を確認し、見つかったモデルから選択できる(接続できなくてもモデル名を手入力して続行可能)。
- `aao_gateway` の場合は API キー(`sk-aao-...`)も入力する(`config.yaml` に保存され、権限は 0600)。
- 最後に MAESTRO サーバーの listen port(既定 9876)を設定する。
非対話(Docker / CI:
```bash
SETUP_LLM_ENDPOINT=http://localhost:11434/v1 SETUP_MODEL=qwen3:14b npm run setup -- --yes
```
```bash
# 別 MAESTRO Gateway 経由の場合
SETUP_CONNECTION_TYPE=aao_gateway \
SETUP_LLM_ENDPOINT=http://gateway-host:9876/v1 \
SETUP_LLM_API_KEY=sk-aao-... \
SETUP_MODEL=qwen3:14b \
npm run setup -- --yes
```
詳細設定(複数ワーカー・tools・auth など)は生成後に `config.yaml` を直接編集するか、起動後の Settings UI で行う。`config.yaml.example` に全項目の説明がある。
## 4. ビルドと起動
```bash
scripts/build-all.sh # バックエンド(dist/) と UI(ui/dist/) をビルド
scripts/server.sh start # ビルド + 起動(PID 管理付き)
```
ブラウザで **http://localhost:9876** を開く。
サーバー管理:
```bash
scripts/server.sh status # 状態確認
scripts/server.sh logs # ログを tail -f
scripts/server.sh restart
scripts/server.sh stop
```
> `scripts/build-all.sh` は最後に Bash サンドボックス用 Python パッケージ
> `runtime/python-requirements.txt`)を自動でプリベイクする。スキップするには
> `--skip-python`。システム Python への書き込みに権限が要る環境では
> `sudo bash scripts/prebake-python.sh` を別途実行する。
## 5. Docker で起動
```bash
cp .env.example .env # OLLAMA_BASE_URL / OLLAMA_MODEL を設定
docker compose up -d
# http://localhost:9876
```
DB とワークスペースは named volume`maestro-data` / `maestro-workspaces`)に永続化される。Compose は既定で `127.0.0.1:9876` のみに公開する。`config.yaml` をホストからマウントする場合は `docker-compose.yml` のコメントを参照。
## 6. 最初のタスク
1. UI を開き、新規タスクを作成(タイトル + 依頼内容を入力)。
2. LLM がタスクを分類し、適切な Piece(ワークフロー)へ自動ルーティングする。
3. 進捗タブで Movement の進行とツール呼び出しを確認、成果物は Output/Files タブでプレビューできる。
## 7. 認証を有効にする(任意)
既定では認証なしで動作する。Google / Gitea の OAuth を使う場合は `config.yaml`
`auth` セクションを設定する(クライアント ID/シークレット/コールバック URL)。詳細は
[configuration.md の auth セクション](configuration.md#auth) を参照。
認証を有効にするまでは信頼できないネットワークへ公開しないこと。外部公開時は TLS
対応のリバースプロキシも使用する。運用上の注意は [../SECURITY.md](../SECURITY.md) を参照。
## 8. Bash サンドボックスを有効にする(任意・マルチユーザー推奨)
エージェントの Bash 実行をタスク単位で隔離する。本番では:
1. ホストに Python パッケージをプリベイク: `sudo bash scripts/prebake-python.sh`
2. `config.yaml``safety.bash_sandbox: always`
3. サーバー再起動
手順とトラブルシュートは [operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md) を参照。
+50 -48
View File
@@ -1,44 +1,46 @@
English | [日本語](getting-started.ja.md)
# Getting Started
MAESTRO を起動して最初のタスクを動かすまでのガイド。設定項目の詳細は
[configuration.md](configuration.md)、全体構造は [architecture.md](architecture.md) を参照。
A guide that takes you from launching MAESTRO to running your first task. For setting details see
[configuration.md](configuration.md), and for the overall structure see [architecture.md](architecture.md).
## 1. 前提
## 1. Prerequisites
- **Node.js 22 以上**
- **OpenAI 互換の LLM エンドポイント** — 例: [Ollama](https://ollama.com/)`http://localhost:11434/v1`)、vLLM など。MAESTRO 自体のビルド/テストには不要だが、タスク実行には必要。
- **任意(Bash サンドボックス用)**: `bwrap`bubblewrap, 非特権 user namespace が有効なこと)と `python3`/`pip`。マルチユーザー運用では有効化を推奨([operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md))。
- **Node.js 22 or later**
- **An OpenAI-compatible LLM endpoint** — e.g. [Ollama](https://ollama.com/) (`http://localhost:11434/v1`), vLLM, etc. Not needed to build/test MAESTRO itself, but required to run tasks.
- **Optional (for the Bash sandbox)**: `bwrap` (bubblewrap, with unprivileged user namespaces enabled) and `python3`/`pip`. Enabling it is recommended for multi-user operation ([operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md)).
## 2. インストール(ソースから)
## 2. Install (from source)
```bash
git clone https://gitea.example.com/your-org/maestro.git
cd maestro
npm ci # バックエンド依存
npm --prefix ui ci # UI 依存
npm ci # backend dependencies
npm --prefix ui ci # UI dependencies
```
## 3. 最小設定(対話ウィザード)
## 3. Minimal configuration (interactive wizard)
`npm run setup` で LLM 接続先を対話的に設定し、最小の `config.yaml` を生成する。
`npm run setup` interactively configures the LLM connection target and generates a minimal `config.yaml`.
```bash
npm run setup
```
- 接続タイプ(`direct` = Ollama/vLLM / `aao_gateway` = MAESTRO Gateway 経由)を選ぶ。
- LLM endpoint URL(例 `http://localhost:11434/v1`)を入力。接続を確認し、見つかったモデルから選択できる(接続できなくてもモデル名を手入力して続行可能)。
- `aao_gateway` の場合は API キー(`sk-aao-...`)も入力する(`config.yaml` に保存され、権限は 0600)。
- 最後に MAESTRO サーバーの listen port(既定 9876)を設定する。
- Choose the connection type (`direct` = Ollama/vLLM, etc. / `aao_gateway` = via a separate MAESTRO Gateway).
- Enter the LLM endpoint URL (e.g. `http://localhost:11434/v1`). It checks the connection and lets you select from the discovered models (you can continue by entering a model name manually even if the connection fails).
- For `aao_gateway`, also enter the API key (`sk-aao-...`) (it is saved in `config.yaml` with permission 0600).
- Finally, set the listen port of the MAESTRO server (default 9876).
非対話(Docker / CI:
Non-interactive (Docker / CI):
```bash
SETUP_LLM_ENDPOINT=http://localhost:11434/v1 SETUP_MODEL=qwen3:14b npm run setup -- --yes
```
```bash
# MAESTRO Gateway 経由の場合
# Via a separate MAESTRO Gateway
SETUP_CONNECTION_TYPE=aao_gateway \
SETUP_LLM_ENDPOINT=http://gateway-host:9876/v1 \
SETUP_LLM_API_KEY=sk-aao-... \
@@ -46,62 +48,62 @@ SETUP_CONNECTION_TYPE=aao_gateway \
npm run setup -- --yes
```
詳細設定(複数ワーカー・toolsauth など)は生成後に `config.yaml` を直接編集するか、起動後の Settings UI で行う。`config.yaml.example` に全項目の説明がある。
For advanced settings (multiple workers, tools, auth, etc.), edit `config.yaml` directly after generation, or use the Settings UI after launch. `config.yaml.example` documents every option.
## 4. ビルドと起動
## 4. Build and launch
```bash
scripts/build-all.sh # バックエンド(dist/) UI(ui/dist/) をビルド
scripts/server.sh start # ビルド + 起動(PID 管理付き)
scripts/build-all.sh # build the backend (dist/) and the UI (ui/dist/)
scripts/server.sh start # build + launch (with PID management)
```
ブラウザで **http://localhost:9876** を開く。
Open **http://localhost:9876** in your browser.
サーバー管理:
Server management:
```bash
scripts/server.sh status # 状態確認
scripts/server.sh logs # ログを tail -f
scripts/server.sh status # check status
scripts/server.sh logs # tail -f the logs
scripts/server.sh restart
scripts/server.sh stop
```
> `scripts/build-all.sh` は最後に Bash サンドボックス用 Python パッケージ
> `runtime/python-requirements.txt`)を自動でプリベイクする。スキップするには
> `--skip-python`。システム Python への書き込みに権限が要る環境では
> `sudo bash scripts/prebake-python.sh` を別途実行する。
> At the end, `scripts/build-all.sh` automatically pre-bakes the Python packages for the Bash sandbox
> (`runtime/python-requirements.txt`). To skip this, use
> `--skip-python`. In environments where writing to the system Python requires permissions, run
> `sudo bash scripts/prebake-python.sh` separately.
## 5. Docker で起動
## 5. Launch with Docker
```bash
cp .env.example .env # OLLAMA_BASE_URL / OLLAMA_MODEL を設定
cp .env.example .env # set OLLAMA_BASE_URL / OLLAMA_MODEL
docker compose up -d
# http://localhost:9876
```
DB とワークスペースは named volume`maestro-data` / `maestro-workspaces`)に永続化される。Compose は既定で `127.0.0.1:9876` のみに公開する。`config.yaml` をホストからマウントする場合は `docker-compose.yml` のコメントを参照。
The DB and workspaces are persisted in named volumes (`maestro-data` / `maestro-workspaces`). By default Compose exposes only `127.0.0.1:9876`. If you want to mount `config.yaml` from the host, see the comments in `docker-compose.yml`.
## 6. 最初のタスク
## 6. Your first task
1. UI を開き、新規タスクを作成(タイトル + 依頼内容を入力)。
2. LLM がタスクを分類し、適切な Piece(ワークフロー)へ自動ルーティングする。
3. 進捗タブで Movement の進行とツール呼び出しを確認、成果物は Output/Files タブでプレビューできる。
1. Open the UI and create a new task (enter a title + the request body).
2. The LLM classifies the task and automatically routes it to the appropriate Piece (workflow).
3. Check the Movement progress and tool calls in the Progress tab; preview deliverables in the Output/Files tabs.
## 7. 認証を有効にする(任意)
## 7. Enable authentication (optional)
既定では認証なしで動作する。Google / Gitea OAuth を使う場合は `config.yaml`
`auth` セクションを設定する(クライアント ID/シークレット/コールバック URL)。詳細は
[configuration.md の auth セクション](configuration.md#auth) を参照。
By default it runs without authentication. To use Google / Gitea OAuth, configure the
`auth` section of `config.yaml` (client ID/secret/callback URL). For details, see the
[auth section of configuration.md](configuration.md#auth).
認証を有効にするまでは信頼できないネットワークへ公開しないこと。外部公開時は TLS
対応のリバースプロキシも使用する。運用上の注意は [../SECURITY.md](../SECURITY.md) を参照。
Do not expose it to an untrusted network until authentication is enabled. When exposing it externally, also use a TLS-enabled
reverse proxy. For operational caveats, see [../SECURITY.md](../SECURITY.md).
## 8. Bash サンドボックスを有効にする(任意・マルチユーザー推奨)
## 8. Enable the Bash sandbox (optional, recommended for multi-user)
エージェントの Bash 実行をタスク単位で隔離する。本番では:
Isolates the agent's Bash execution per task. In production:
1. ホストに Python パッケージをプリベイク: `sudo bash scripts/prebake-python.sh`
2. `config.yaml` `safety.bash_sandbox: always`
3. サーバー再起動
1. Pre-bake the Python packages on the host: `sudo bash scripts/prebake-python.sh`
2. Set `safety.bash_sandbox: always` in `config.yaml`
3. Restart the server
手順とトラブルシュートは [operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md) を参照。
For the procedure and troubleshooting, see [operations/bash-sandbox-provisioning.md](operations/bash-sandbox-provisioning.md).
+370
View File
@@ -0,0 +1,370 @@
# OSS Documentation Cleanup Plan (MAESTRO)
Status: PLAN (audit + execution checklist). Branch: `docs/oss-cleanup`.
Author: docs audit pass, 2026-06-10.
This plan audits MAESTRO's documentation against GitHub/OSS publishing
conventions and the repo owner's HTML-staged-docs preference, and lays out a
concrete, ordered execution path. It does **not** change any shipping docs; the
only new files on this branch are this plan and two clearly-labeled DRAFT stubs
(`docs/README.en.draft.md`, `docs/SECURITY.draft.md`).
## How OSS publishing actually works (verified)
`scripts/oss-sync.sh` builds the public tree from `git archive HEAD`, then:
1. removes every path in `oss/exclude.txt` (CLAUDE.md, `docs/superpowers`,
`docs/plans`, dated internal docs, `oss/` itself, sync tooling, editor configs);
2. scrubs internal hostnames/IPs via `oss/scrub.sed`;
3. strips dead links to excluded docs via `oss/scrub-deadlinks.py`;
4. overlays `oss/overlay/.` last (public-only files **win**);
5. runs a release gate (`oss/verify_release.py` + `oss/forbidden.txt`).
So the **public** doc set = (tracked docs that survive exclude) **+** the
overlay. The overlay is the source of truth for the public-facing files. The
tracked root `README.md` is the **private** README and is *replaced* by
`oss/overlay/README.md` (README is overlaid, not excluded). Anything authored
for the public must land in `oss/overlay/` or in a tracked `docs/` path that is
**not** excluded.
Implication for this cleanup: most "public-facing" doc work means editing files
under `oss/overlay/`, not the tracked root files.
---
## A. INVENTORY
Audience = who the doc is for. OSS-ships = `overlay` (public-only file),
`tracked` (survives exclude and ships as-is), or `excluded` (removed by
`oss/exclude.txt`). Language as of today.
### Top-level files
| Path | Audience | Lang | OSS-ships? | Verdict |
|------|----------|------|-----------|---------|
| `README.md` (tracked root) | internal | JA | replaced by overlay README | keep as private; **not** public |
| `oss/overlay/README.md` | public | **JA** | overlay (becomes public README) | **translate→EN-first** + add badges/screenshot |
| `oss/overlay/AGENTS.md` | public (contributors) | EN | overlay | keep |
| `oss/overlay/CONTRIBUTING.md` | public | EN | overlay | keep (add CoC + DCO/CLA note) |
| `oss/overlay/SECURITY.md` | public | EN | overlay | keep (good) |
| `oss/overlay/CHANGELOG.md` | public | EN | overlay | keep (date stale: dated 2026-06-02) |
| `oss/overlay/LICENSE` | public | EN | overlay | keep (Apache-2.0) |
| `oss/overlay/NOTICE` | public | EN | overlay | keep |
| `AGENTS.md` (tracked root) | internal | EN | tracked (not excluded) — **collides** w/ overlay | see note 1 |
| `GEMINI.md` (tracked root) | internal | — | tracked (not excluded!) | **add to exclude.txt** (internal editor config) |
| `CLAUDE.md` | internal | JA | excluded | keep excluded |
Note 1 — `AGENTS.md` exists both tracked-at-root and in the overlay. The overlay
copy wins (overlaid last), so the public ships the overlay version. Confirm the
tracked root `AGENTS.md` is acceptable to ship *if* the overlay ever stops
shipping it; today it is harmless but a maintenance trap (two AGENTS.md to keep
in sync). Recommend: keep only the overlay AGENTS.md public; the tracked root one
is internal — fine, but document the duplication.
### `docs/` overlay (public)
| Path | Audience | Lang | OSS-ships? | Verdict |
|------|----------|------|-----------|---------|
| `oss/overlay/docs/getting-started.md` | public | JA | overlay | translate→EN |
| `oss/overlay/docs/configuration.md` | public | JA | overlay | translate→EN |
| `oss/overlay/docs/architecture.md` | public | JA | overlay | translate→EN |
### `docs/` tracked (ship unless excluded)
| Path | Audience | Lang | OSS-ships? | Verdict |
|------|----------|------|-----------|---------|
| `docs/architecture.md` | mixed | JA | tracked | reconcile vs overlay arch (see note 2) |
| `docs/tools/*.md` (34) | public | JA | tracked | keep; translate top-N later |
| `docs/operations/bash-sandbox-provisioning.md` | public | JA | tracked | keep |
| `docs/operations/index.html` + `initial-setup.html` + `guide.css` | public | JA | tracked (HTML) | see RECONCILIATION |
| `docs/design/**` (ui_kits_reference jsx/html/css) | internal | mixed | tracked | **exclude** (dev design refs, not user docs) |
| `docs/aao-gateway-overview.md` | public | JA | tracked | keep |
| `docs/mcp.md`, `docs/skills.md`, `docs/ssh.md`, `docs/bench.md` | public | JA | tracked | keep |
| `docs/context-flow.md`, `docs/user-folder-layout.md` | public | JA | tracked | keep |
| `docs/maintenance-checklist.md` | internal (contributors) | JA | tracked | keep (referenced by CONTRIBUTING) |
Note 2 — duplicate architecture docs: `docs/architecture.md` (tracked) AND
`oss/overlay/docs/architecture.md` (overlay). The overlay wins for the public.
Decide which is canonical and have the other redirect/stub, or exclude the
tracked one to avoid drift.
### docs-wip branch (in-progress reorg — NOT yet on main)
`docs-wip` does two things:
2. **Adds** a consolidated, curated doc set: `docs/reference/*.md` (18 files,
Japanese — feature reference: scheduler, config, mcp, ssh, memory, gateway,
pieces, skills, notifications, media-tools, etc.), plus an HTML build system:
`docs/build_html.py` (Markdown→staged HTML), generated `docs/html/**`, and
manifest JSON (`.investigate-status.json`, `.consolidate-manifest.json`).
`docs/build_html.py` reads each doc's implementation status from
`.investigate-status.json` and renders staged HTML (design=blue /
implementation=amber / completed=green) with an `index.html` nav — exactly the
owner's HTML-staged convention. The `docs/reference/*.md` consolidation is the
single most valuable doc asset for OSS: a clean, deduplicated feature reference
replacing scattered dated design specs.
**Recommendation:** land `docs-wip`'s `docs/reference/*.md` consolidation
(highest-value, low-risk) ahead of the OSS push, but treat the *generated*
`docs/html/**` output and the `*.json` manifests as build artifacts (see
RECONCILIATION — keep the generator, gitignore/exclude the output).
---
## B. GAP ANALYSIS — GitHub OSS readiness
Verified-present: `LICENSE` (Apache-2.0), `NOTICE`, `CONTRIBUTING.md`,
`SECURITY.md`, `CHANGELOG.md`, `AGENTS.md`, `Dockerfile`, `docker-compose.yml`,
`.env.example`, `.dockerignore`.
Gaps found:
| Gap | Severity | Notes |
|-----|----------|-------|
| **No `CODE_OF_CONDUCT.md`** | high | Standard for OSS; GitHub surfaces it in the community profile. Add Contributor Covenant 2.1 to `oss/overlay/`. |
| **No `.github/` community files** | high | Missing issue templates (bug/feature), `PULL_REQUEST_TEMPLATE.md`, `FUNDING.yml` (optional). Note: repo is published to **Gitea** (`swallow/maestro`), not GitHub — Gitea reads `.gitea/ISSUE_TEMPLATE/` (and also `.github/`). Add templates under a host-appropriate dir in the overlay. |
| **README is JA-only** | high | First-time visitor on an English-default host can't read it. EN-first is the single biggest readiness fix. |
| **README has 1 badge only** | medium | Only a static license badge. Add: build/CI status, release/version, Node version, "PRs welcome". Avoid badges that need a live service. |
| **No screenshots/GIF in README** | medium | An agent UI sells itself visually. Add 12 screenshots (task detail / settings) under `oss/overlay/docs/assets/` and embed in README. |
| **No architecture diagram image** | low | The execution-flow ASCII block is fine; a simple diagram would help. Optional. |
| **CHANGELOG date stale** | low | `v0.1.0 — 2026-06-02` predates current HEAD; refresh on release. |
| **No top-level "Documentation" landing for the curated set** | medium | If `docs/reference/*` lands, README/getting-started should link an index. |
| **License headers in source** | low | Apache-2.0 doesn't require per-file headers, but `NOTICE` + a short header policy in CONTRIBUTING avoids questions. Optional. |
| **`GEMINI.md` would leak** | medium | Tracked at root, NOT in `oss/exclude.txt` → ships publicly. It's an internal editor/assistant config like CLAUDE.md. Add to exclude. |
| **`docs/design/ui_kits_reference/**` would ship** | low-medium | Internal design references (JSX prototypes, legacy admin kit). Not user docs. Add `docs/design` to exclude. |
README quality for a first-time visitor (overlay README): structure is good
(features → quickstart → requirements → docs → security → license). What's
missing for a strong first impression: English, a screenshot, a one-line
"what/why" hook in English at the very top, and CI/release badges.
---
## C. i18n PLAN
Current state: all public docs (overlay README + getting-started + configuration
+ architecture) and tracked `docs/**` are Japanese. GitHub/Gitea OSS audiences
default to English. The product also targets 多言語対応 as a goal.
**Convention (recommended): English-first with a `.ja` sibling.**
This is the lowest-friction, most-recognized GitHub pattern.
- `README.md` → English (the public README, i.e. `oss/overlay/README.md`).
- `README.ja.md` → Japanese (current content moved here).
- Cross-link at the top of each: `[English](README.md) | [日本語](README.ja.md)`.
For `docs/`, use a suffix convention to avoid a parallel directory tree:
```
oss/overlay/docs/
getting-started.md # EN (canonical)
getting-started.ja.md # JA
configuration.md # EN
configuration.ja.md # JA
architecture.md # EN
architecture.ja.md # JA
```
Rationale: a `docs/en/` + `docs/ja/` split doubles directory depth and breaks
relative links on every move. The `.ja.md` suffix keeps EN as the default a
visitor hits and keeps JA one click away. (If a richer i18n site is built later
— e.g. Docusaurus/MkDocs — migrate then; don't over-engineer now.)
**Translate-first order (highest visitor impact first):**
1. `README.md` (overlay) — the storefront. **Do first.**
2. `docs/getting-started.md` — clone→running path.
3. `docs/configuration.md``config.yaml` reference.
4. `docs/architecture.md` — for evaluators/contributors.
5. `CHANGELOG.md` (already EN), `SECURITY.md` (already EN), `CONTRIBUTING.md`
(already EN), `AGENTS.md` (already EN) — no action.
6. Later/optional: top 58 `docs/tools/*.md` by usage (bash, websearch, browseweb,
spawnsubtask, office) and the `docs/reference/*` set if landed.
**Tooling/convention:**
- Keep EN canonical, JA as translation. Do not auto-generate JA at build time;
hand-maintain the high-value pages, accept staleness on the long tail.
- Add a short "Translations" note in CONTRIBUTING describing the `.ja.md`
convention and that EN is canonical (PRs that change EN should flag the JA
sibling as needing update — don't block on it).
- The existing JA overlay docs are already written and accurate — they become
the `.ja.md` siblings essentially for free. The work is the EN translation,
not throwing away the JA.
---
## D. DOCKER DOCS PLAN
From `git clone` to running via Docker, a new user needs (and currently has):
| Step | Covered today? | Where |
|------|----------------|-------|
| `cp .env.example .env` | yes | README quickstart + getting-started §5 |
| Set LLM endpoint | yes | `.env.example` comments (`OLLAMA_BASE_URL`, `OLLAMA_MODEL`) |
| `docker compose up -d` | yes | README + getting-started |
| Where the UI is | yes | `http://localhost:9876` |
| Data persistence | yes | named volumes `maestro-data` / `maestro-workspaces` |
| Security default (localhost-only) | yes | README + getting-started + SECURITY |
What's **missing / unclear** for a Docker-first OSS user:
1. **`host.docker.internal` on Linux.** `.env.example` defaults to
`http://host.docker.internal:11434/v1`. On Linux Docker this name is not
resolvable by default (works on Docker Desktop mac/win). New Linux users will
hit "connection refused" with no hint. **Add a note**: on Linux use the host
gateway IP or `--add-host=host.docker.internal:host-gateway` (compose:
`extra_hosts`), or point at the LAN IP of the Ollama host.
2. **No "verify it's running" step.** Add a healthcheck/`docker compose logs -f`
+ "open the UI, create a task" smoke check to getting-started §5.
3. **Mounting `config.yaml` into the container** is referenced ("see the comments
in docker-compose.yml") but not shown inline. New users benefit from one
explicit example of mounting a host `config.yaml` and where setup runs in the
container (does the image run `npm run setup`, or only env-var config?).
4. **Bash sandbox in Docker.** bwrap needs unprivileged user namespaces; in a
container that may need extra flags or `--privileged`-adjacent settings, or
the hardened fallback applies. Getting-started §8 covers host provisioning but
not the containerized story. Add one paragraph: what `bash_sandbox` mode the
Docker image ships with and any caveats.
5. **Image build vs prebuilt.** Is there a published image, or is
`docker compose up` building locally from the `Dockerfile` every time? State
it. If build-from-source, note the first-run build time.
6. **GPU / external Ollama.** Make explicit that MAESTRO's container does **not**
run the LLM; users point it at an existing OpenAI-compatible endpoint. The
README says it but the Docker section could restate it (common confusion).
Recommend a dedicated `oss/overlay/docs/docker.md` (EN) consolidating the above,
linked from README + getting-started §5, rather than growing getting-started.
---
## E. RECONCILIATION — HTML-staged docs vs GitHub Markdown
The tension: the owner's convention wants `docs/` as **staged HTML**
(design/implementation/completed, color badges, `index.html` nav). GitHub/OSS
convention wants **Markdown** README + `docs/` that render on the repo host.
These are not actually in conflict if we treat HTML as a **generated artifact**,
not the source:
1. **Markdown is the single source of truth.** Author everything as `.md`
(`README.md`, `docs/**/*.md`, the `docs/reference/*.md` set from `docs-wip`).
Markdown renders natively on Gitea/GitHub and is what OSS contributors expect.
2. **`docs/build_html.py` (from `docs-wip`) generates the staged HTML view** from
that Markdown for the owner's internal browsing/handoff workflow. The
generator stays; its **output (`docs/html/**`) is a build artifact** — do not
hand-edit it, and keep it out of OSS:
- add `docs/html/` to `.gitignore` (don't commit generated output), and
- add `docs/html` + the `*.json` manifests to `oss/exclude.txt` as belt-and-
suspenders so even a stray commit never ships generated HTML publicly.
This gives the owner the staged-HTML experience locally (`python3
docs/build_html.py`) while the OSS repo ships clean Markdown — **no
duplication of content**, only a generation step.
3. **The few hand-written HTML docs that exist today**
(`docs/operations/index.html`, `initial-setup.html`, `guide.css`) are the
exception: they're authored HTML, not generated. Two options —
(a) convert them to Markdown so they fit the generated-from-MD model
(preferred for OSS consistency), or
(b) keep them as authored HTML but exclude them from OSS and let the public
read the Markdown equivalents (`docs/operations/bash-sandbox-provisioning.md`
already exists). Recommend (a) long-term, (b) as the immediate no-risk choice.
4. **Stage metadata** (design/impl/completed) lives in the Markdown frontmatter
or the manifest JSON, consumed only by `build_html.py`. OSS readers never see
stages; they see finished Markdown. Internal readers get the staged HTML view.
Net: keep `docs-wip`'s generator + the `docs/reference/*.md` consolidation; gate
the generated HTML behind `.gitignore` + `oss/exclude.txt`. One source (MD), two
renderings (host-native MD for OSS, staged HTML for internal).
---
## F. PRIORITIZED EXECUTION CHECKLIST
Effort: S ≤30min, M ≤2h, L ≤half-day. Tags: [public-facing] ships to OSS;
[internal] private-repo / tooling only.
### P0 — leak/correctness fixes (do before any OSS push)
1. [internal] **Add `GEMINI.md` to `oss/exclude.txt`** (internal assistant
config, currently ships). — S
2. [internal] **Add `docs/design` to `oss/exclude.txt`** (JSX/HTML UI prototypes,
not user docs). — S
3. [internal] **Resolve duplicate architecture doc**: pick canonical
(`oss/overlay/docs/architecture.md`), exclude or stub the tracked
`docs/architecture.md`. — S
4. [internal] **Decide AGENTS.md duplication** (overlay wins; document that the
tracked root copy is internal). — S
5. [internal] Run `scripts/oss-sync.sh --dry-run --local-only` and read the
release-gate output + diff stat to confirm nothing internal leaks. — S
### P1 — English README + storefront (highest visitor impact)
6. [public-facing] **Translate `oss/overlay/README.md` to English**; move JA to
`oss/overlay/README.ja.md`; add the `EN | 日本語` switcher line. Use
`docs/README.en.draft.md` (this branch) as the starting skeleton. — M
7. [public-facing] **Add badges** to README: CI/build, release/version, Node 22+,
license (keep), PRs-welcome. Only badges that don't require a live host. — S
8. [public-facing] **Add 12 screenshots** (`oss/overlay/docs/assets/`) and embed
under a "Screenshots" section. — SM (needs capturing UI)
### P2 — community health files
9. [public-facing] **Add `CODE_OF_CONDUCT.md`** (Contributor Covenant 2.1) to
`oss/overlay/`. — S
10. [public-facing] **Add issue + PR templates** under the host-appropriate dir
in the overlay (`.gitea/ISSUE_TEMPLATE/{bug,feature}.md` + a
`PULL_REQUEST_TEMPLATE.md`; also `.github/` if mirroring to GitHub). — M
11. [public-facing] **Promote `SECURITY.draft.md`** (this branch) — confirm the
existing `oss/overlay/SECURITY.md` is sufficient (it is); the draft is a
redundant stub, delete it once confirmed. — S
### P3 — i18n of core docs
12. [public-facing] Translate `docs/getting-started.md` → EN, move JA to
`getting-started.ja.md`. — M
13. [public-facing] Translate `docs/configuration.md` → EN (+ `.ja.md`). — ML
14. [public-facing] Translate `docs/architecture.md` → EN (+ `.ja.md`). — M
15. [public-facing] Add a "Translations" note to CONTRIBUTING describing the
`.ja.md` convention (EN canonical). — S
### P4 — Docker docs
16. [public-facing] Add `oss/overlay/docs/docker.md` (EN) covering the 6 gaps in
section D (esp. Linux `host.docker.internal`, verify-running, config mount,
sandbox-in-Docker, build-vs-prebuilt, external LLM). Link from README +
getting-started §5. — M
### P5 — docs reorg reconciliation (coordinate with `docs-wip`)
17. [internal] **Land `docs-wip`'s `docs/reference/*.md` consolidation** onto main
(the 18 curated feature docs; drop the generated `docs/html/**` from the
merge). — L (review of 18 docs)
18. [internal] **Add `docs/html/` to `.gitignore`** and **`docs/html` +
`docs/.investigate-status.json` + `docs/.*-manifest.json` to
`oss/exclude.txt`** (generated artifacts; section E). — S
19. [public-facing] Add a `docs/README.md` (or section in main README) indexing
the `docs/reference/*` set so the curated docs are discoverable. — S
20. [public-facing] Convert `docs/operations/*.html` to Markdown (or exclude from
OSS); section E option (a)/(b). — M
### P6 — polish (optional, post-launch)
21. [public-facing] Translate top 58 `docs/tools/*.md`. — L
22. [public-facing] Refresh `CHANGELOG.md` date/contents at actual release. — S
23. [public-facing] Add an architecture diagram image to README. — M
---
## Drafts created on this branch (NOT replacing anything)
- `docs/README.en.draft.md` — English README skeleton (starting point for item 6).
- `docs/SECURITY.draft.md` — redundant stub pointing at the existing policy;
exists only to confirm coverage (item 11) and should be deleted once the
existing `oss/overlay/SECURITY.md` is accepted.
Both are clearly labeled DRAFT and live under `docs/` so they do not collide with
or overwrite the shipping overlay files.
+4
View File
@@ -285,3 +285,7 @@ BrowseWithSession({
## SSRF 保護
ローカル/プライベート IP127.x.x.x, 10.x.x.x, 172.16-31.x.x, 192.168.x.x, ::1, fc00::/7 等)へのアクセスはデフォルトでブロックされる。社内ホストへアクセスする必要がある場合は、Settings UI の「SSRF Allowed Hosts」に追加する。
### 既知の残留リスク: 時間差 DNS rebinding (TOCTOU)
リクエスト傍受時に全 DNS レコードを検証してから `route.continue()` するが、その後の接続では Chromium が独自に DNS を再解決する。検証と接続の間(サブ秒 TTL)でレコードを書き換える rebinding ホストは、理論上プライベート IP への接続を成立させられる。Playwright の route 傍受には接続先アドレスを pin する手段がなく、この方式に内在する制約として受容している(issue #467)。WebFetch / DownloadFile / MCP はソケットを pin する `pinnedFetch``src/net/ssrf-strict.ts`)経由のため影響を受けない。完全に塞ぐには pinning forward proxy が必要だが、意図的に導入していない。