Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoju f13fb012d6 chore: remove cross-repo json-cas workspace deps, use npm registry
- Remove ../json-cas/packages/* from root workspaces
- Change @uncaged/json-cas{,-fs} from workspace:^ to ^0.1.1
- Remove stale pnpm-lock.yaml
- Clean Gitea registry from .npmrc
2026-05-18 10:22:36 +00:00
102 changed files with 1230 additions and 6871 deletions
-1
View File
@@ -9,4 +9,3 @@ bunfig.toml
xiaoju/
solve-issue-entry.ts
packages/workflow-template-develop/develop.esm.js
.DS_Store
+16
View File
@@ -0,0 +1,16 @@
import { createCursorAgent } from "./packages/workflow-agent-cursor/src/index.js";
import { createWorkflow } from "./packages/workflow-runtime/src/create-workflow.js";
import {
buildDevelopDescriptor,
developWorkflowDefinition,
} from "./packages/workflow-template-develop/src/index.js";
const agent = createCursorAgent({
command: "/home/azureuser/.local/bin/cursor-agent",
model: "auto",
timeout: 300_000,
workspace: null,
});
export const descriptor = buildDevelopDescriptor();
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
+2 -5
View File
@@ -8,7 +8,7 @@
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
The implementation lives in **21** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
The implementation lives in **15** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
## Package map
@@ -26,13 +26,10 @@ Grouped by responsibility (npm name → folder).
| CLI | `@uncaged/cli-workflow``cli-workflow` | `uncaged-workflow` binary (depends on engine, registry, CAS, protocol, util, runtime). |
| Agent adapters | `@uncaged/workflow-agent-cursor``workflow-agent-cursor` | `AgentFn` via `cursor-agent` CLI + workspace extraction. |
| | `@uncaged/workflow-agent-hermes``workflow-agent-hermes` | `AgentFn` via `hermes chat` CLI. |
| | `@uncaged/workflow-agent-office``workflow-agent-office` | `AdapterFn` via `office-agent` CLI; generates or edits Word documents, stores outputs per threadId. |
| | `@uncaged/workflow-agent-docx-diff``workflow-agent-docx-diff` | `AdapterFn` via `docx-diff` CLI; produces Word-format diff reports for document edit workflows. |
| | `@uncaged/workflow-agent-llm``workflow-agent-llm` | `AgentFn` via OpenAI-compatible HTTP (`LlmProvider` from runtime). |
| Agent shared | `@uncaged/workflow-util-agent``workflow-util-agent` | `buildAgentPrompt`, `spawnCli` for CLI-backed agents. |
| Templates | `@uncaged/workflow-template-develop``workflow-template-develop` | Develop workflow definition, roles, descriptor builder. |
| | `@uncaged/workflow-template-solve-issue``workflow-template-solve-issue` | Solve-issue workflow definition, roles, descriptor builder. |
| | `@uncaged/workflow-template-document``workflow-template-document` | Document generation/editing workflow definition (writer + differ roles, moderator table, descriptor). |
| Dashboard | `@uncaged/workflow-dashboard``workflow-dashboard` | Private Vite + React app (`src/main.tsx`); only `react` / `react-dom` dependencies — no workspace packages. |
## Dependency graph (workspace packages)
@@ -268,4 +265,4 @@ Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNM
| **Single-file ESM** | Hash = version, self-contained bundle |
| **No daemon** | OS handles process lifecycle |
| **Crockford Base32** | Filesystem-safe, readable, compact |
| **21-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
File diff suppressed because it is too large Load Diff
@@ -1,387 +0,0 @@
# 设计文档:office-agent 文档生成/编辑 Workflow 体系
**日期:** 2026-05-18
---
## 概述
在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。
| 包 | npm name | 职责 |
|---|---|---|
| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor |
| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI |
| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI |
Template 只定义结构,不含执行逻辑。执行器与 template 解耦。
---
## 一、`workflow-template-document`
### Thread 启动输入
```typescript
// src/types.ts
type DocumentStartInput = {
prompt: string; // 用户指令
inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式
};
```
start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。
### 角色与 Meta
`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式:
```typescript
const writerMetaSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("generate"),
outputDocx: z.string(), // 生成产物绝对路径
sourceDocx: z.null(),
}),
z.object({
mode: z.literal("edit"),
outputDocx: z.string(), // 修改后产物:<outputDir>/modified.docx
sourceDocx: z.string(), // 原始副本:<outputDir>/original.docx
}),
]);
type WriterMeta = z.infer<typeof writerMetaSchema>;
// differ:仅编辑模式执行
const differMetaSchema = z.object({
sourceDocx: z.string(),
modifiedDocx: z.string(),
diffDocx: z.string(),
});
type DifferMeta = z.infer<typeof differMetaSchema>;
```
两个角色的 `systemPrompt` 均为 `""`
### 调度表
```
START → writer ──(mode = "edit")──→ differ → END
↘(mode = "generate")→ END
```
### 公开导出
template 导出两个对象供消费方使用:
- `documentWorkflowDefinition: WorkflowDefinition<DocumentMeta>` — 传入 `createWorkflow``def` 参数
- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用
```typescript
// bundle 侧用法
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides });
```
### 包文件结构
```
packages/workflow-template-document/
src/
types.ts # DocumentStartInput
roles/
writer.ts # writerMetaSchema, WriterMeta, writerRole
differ.ts # differMetaSchema, DifferMeta, differRole
index.ts
roles.ts # DocumentMeta, documentRoles
moderator.ts # writerIsEditMode condition + documentTable
definition.ts # documentWorkflowDefinition
descriptor.ts # buildDocumentDescriptor()
index.ts
__tests__/
moderator.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"zod": "^4.0.0"
}
```
---
## 二、`workflow-agent-office`
### office-agent CLI 接口
```bash
# 生成模式:在 CWD 生成 output.docx
office-agent create "<prompt>" -o output.docx
# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写)
office-agent edit modified.docx "<instruction>"
```
- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成)
- 输出文件落到调用方设定的 CWD
- 退出码 0 = 成功,非零 = 失败
### 文件命名约定
| 模式 | 文件 | 路径 |
|---|---|---|
| generate | 输出 | `<outputDir>/output.docx` |
| edit | 原始副本(workflow-owned 快照) | `<outputDir>/original.docx` |
| edit | 修改后产物 | `<outputDir>/modified.docx` |
edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx``original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。
### 执行流程
**生成模式(`inputDocx = null`):**
1. `mkdir -p <outputDir>``<config.outputDir>/<ctx.threadId>`
2. `const command = config.command ?? "office-agent"`
3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })`
4. 验证 `outputDir/output.docx` 存在
5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })`
**编辑模式(`inputDocx ≠ null`):**
1. `mkdir -p <outputDir>`
2. `copyFile(inputDocx, <outputDir>/original.docx)`
3. `copyFile(inputDocx, <outputDir>/modified.docx)`
4. `const command = config.command ?? "office-agent"`
5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })`
6. 验证 `outputDir/modified.docx` 存在
7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })`
### AdapterFn 实现(直接实现,不经过 runtime.extract)
CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction:
```typescript
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。
### 配置
```typescript
type OfficeAgentConfig = {
outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录
command: string | null; // null → runner 内 resolve 为 "office-agent"
timeout: number | null; // null → 不设超时;单位 ms
};
```
### 错误处理
```typescript
if (!result.ok) {
const e = result.error;
if (e.kind === "non_zero_exit")
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("office-agent: timed out");
// "spawn_failed"
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
if (!existsSync(expectedPath))
throw new Error(`office-agent: output file not found: ${expectedPath}`);
```
### packageDescriptor
```typescript
// src/package-descriptor.ts
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-office",
version: "0.1.0",
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
configSchema: {
type: "object",
required: ["outputDir"],
properties: {
outputDir: { type: "string", description: "Root directory for workflow outputs." },
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." },
timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-office/
src/
types.ts # OfficeAgentConfig, OfficeAgentOpt
runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证)
agent.ts # createOfficeAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
}
```
---
## 三、`workflow-agent-docx-diff`
`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。
### docx-diff 退出码约定
| 退出码 | 含义 | runner 处理 |
|---|---|---|
| 0 | 无差异 | 正常,验证 diffDocx 存在 |
| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 |
| 2+ | 错误 | throw |
runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。
### 执行流程
```
1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta
2. 验证 mode === "edit"(否则 throw)
3. diffDocx = join(dirname(writer.outputDocx), "diff.docx")
4. const command = config.command ?? "docx-diff"
5. spawnCli(command,
[writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx],
{ cwd: null, timeoutMs: null })
exit 0 或 1 → 验证 diffDocx 存在
exit 2+ → throw
6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx })
```
### AdapterFn 实现(直接实现,不经过 runtime.extract)
```typescript
export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const writerStep = ctx.steps.find(s => s.role === "writer");
if (!writerStep) throw new Error("differ: no writer step found");
const writerMeta = writerStep.meta as WriterMeta;
if (writerMeta.mode !== "edit")
throw new Error("differ: writer did not run in edit mode");
const raw = await runDocxDiff(config, writerMeta);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
### 配置
```typescript
type DocxDiffAgentConfig = {
command: string | null; // null → runner 内 resolve 为 "docx-diff"
};
```
### packageDescriptor
```typescript
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-docx-diff",
version: "0.1.0",
capabilities: ["docx-diff-cli", "docx-diff-report"],
configSchema: {
type: "object",
properties: {
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-docx-diff/
src/
types.ts # DocxDiffAgentConfig
runner.ts # runDocxDiff()(exit 1 处理 + 文件验证)
agent.ts # createDocxDiffAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-template-document": "workspace:^"
}
```
---
## 四、外部 bundle(外部 workspace 消费)
```typescript
import { createOfficeAgent } from "@uncaged/workflow-agent-office";
import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff";
import {
buildDocumentDescriptor,
documentWorkflowDefinition,
} from "@uncaged/workflow-template-document";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
import { join } from "node:path";
const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs");
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, {
adapter: createOfficeAgent({ outputDir, command: null, timeout: null }),
overrides: { differ: createDocxDiffAgent() },
});
```
---
## 不在范围内
- 重试逻辑(失败直接 throw)
- office-agent server 的启停管理(假设 server 已在运行)
- docx-diff HTML/terminal 格式输出(仅 docx)
- 跨机器执行(`inputDocx` 须为本机有效绝对路径)
+2 -2
View File
@@ -11,8 +11,8 @@
"uwf": "./src/cli.ts"
},
"dependencies": {
"@uncaged/json-cas": "^0.3.0",
"@uncaged/json-cas-fs": "^0.3.0",
"@uncaged/json-cas": "^0.1.1",
"@uncaged/json-cas-fs": "^0.1.1",
"@uncaged/uwf-agent-kit": "workspace:^",
"@uncaged/uwf-moderator": "workspace:^",
"@uncaged/uwf-protocol": "workspace:^",
Executable → Regular
+10 -153
View File
@@ -10,23 +10,10 @@ import {
cmdThreadStep,
} from "./commands/thread.js";
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import {
cmdCasGet,
cmdCasHas,
cmdCasPut,
cmdCasReindex,
cmdCasRefs,
cmdCasSchemaGet,
cmdCasSchemaList,
cmdCasWalk,
} from "./commands/cas.js";
import { resolveStorageRoot } from "./store.js";
import { type OutputFormat, formatOutput } from "./format.js";
function writeOutput(data: unknown): void {
const fmt = program.opts().format as OutputFormat;
process.stdout.write(`${formatOutput(data, fmt)}\n`);
function writeJson(data: unknown): void {
process.stdout.write(`${JSON.stringify(data)}\n`);
}
function runAction(action: () => Promise<void>): void {
@@ -40,7 +27,6 @@ function runAction(action: () => Promise<void>): void {
const program = new Command();
program.name("uwf").description("Stateless workflow CLI");
program.option("--format <fmt>", "Output format: json or yaml", "json");
const workflow = program.command("workflow").description("Workflow registry and CAS");
@@ -52,7 +38,7 @@ workflow
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowPut(storageRoot, file);
writeOutput(result);
writeJson(result);
});
});
@@ -64,7 +50,7 @@ workflow
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowShow(storageRoot, id);
writeOutput(result);
writeJson(result);
});
});
@@ -75,7 +61,7 @@ workflow
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowList(storageRoot);
writeOutput(result);
writeJson(result);
});
});
@@ -90,7 +76,7 @@ thread
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt);
writeOutput(result);
writeJson(result);
});
});
@@ -104,7 +90,7 @@ thread
runAction(async () => {
const agentOverride = opts.agent ?? null;
const result = await cmdThreadStep(storageRoot, threadId, agentOverride);
writeOutput(result);
writeJson(result);
});
});
@@ -116,7 +102,7 @@ thread
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadShow(storageRoot, threadId);
writeOutput(result);
writeJson(result);
});
});
@@ -128,7 +114,7 @@ thread
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadList(storageRoot, opts.all);
writeOutput(result);
writeJson(result);
});
});
@@ -140,136 +126,7 @@ thread
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadKill(storageRoot, threadId);
writeOutput(result);
});
});
program
.command("setup")
.description("Configure provider, model, and agent")
.option("--provider <name>", "Provider name")
.option("--base-url <url>", "OpenAI-compatible API base URL")
.option("--api-key <key>", "API key")
.option("--model <name>", "Default model name")
.option("--agent <name>", "Default agent alias")
.action((opts: {
provider?: string;
baseUrl?: string;
apiKey?: string;
model?: string;
agent?: string;
}) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
const result = await cmdSetup({
provider: opts.provider,
baseUrl: opts.baseUrl,
apiKey: opts.apiKey,
model: opts.model,
agent: opts.agent ?? undefined,
storageRoot,
});
writeOutput(result);
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
await cmdSetupInteractive(storageRoot);
} else {
throw new Error(
"Non-interactive setup requires all of: --provider, --base-url, --api-key, --model",
);
}
});
});
const cas = program.command("cas").description("Content-addressable storage operations");
cas
.command("get")
.description("Read a CAS node (type + payload; use --timestamp to include timestamp)")
.argument("<hash>", "CAS hash (13 char)")
.option("--timestamp", "Include timestamp in output")
.action((hash: string, opts: { timestamp?: boolean }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasGet(storageRoot, hash, opts));
});
});
cas
.command("put")
.description("Store a node, print its hash")
.argument("<type-hash>", "Type (schema) hash")
.argument("<data>", "JSON file path or inline JSON string")
.action((typeHash: string, data: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasPut(storageRoot, typeHash, data));
});
});
cas
.command("has")
.description("Check if a hash exists")
.argument("<hash>", "CAS hash (13 char)")
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasHas(storageRoot, hash));
});
});
cas
.command("refs")
.description("List direct CAS references from a node")
.argument("<hash>", "CAS hash (13 char)")
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasRefs(storageRoot, hash));
});
});
cas
.command("walk")
.description("Recursive traversal from a node")
.argument("<hash>", "CAS hash (13 char)")
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasWalk(storageRoot, hash));
});
});
cas
.command("reindex")
.description("Rebuild type index from all CAS nodes")
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasReindex(storageRoot));
});
});
const casSchema = cas.command("schema").description("CAS schema operations");
casSchema
.command("list")
.description("List all registered schemas")
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasSchemaList(storageRoot));
});
});
casSchema
.command("get")
.description("Show a schema by its type hash")
.argument("<hash>", "Schema type hash")
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasSchemaGet(storageRoot, hash));
writeJson(result);
});
});
-139
View File
@@ -1,139 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
import { bootstrap, getSchema, refs, walk } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
// ---- Helpers ----
function openStore(storageRoot: string): Store {
return createFsStore(join(storageRoot, "cas"));
}
function readJsonArg(fileOrInline: string): unknown {
try {
return JSON.parse(fileOrInline);
} catch {
try {
return JSON.parse(readFileSync(fileOrInline, "utf-8"));
} catch (e) {
throw new Error(`Cannot parse JSON from "${fileOrInline}": ${e}`);
}
}
}
// ---- Commands (all return JSON-serializable data) ----
export async function cmdCasGet(
storageRoot: string,
hash: string,
opts: { timestamp?: boolean },
): Promise<unknown> {
const store = openStore(storageRoot);
const node = store.get(hash);
if (node === null) {
throw new Error(`Node not found: ${hash}`);
}
if (opts.timestamp) {
return node;
}
const { timestamp: _, ...rest } = node as Record<string, unknown>;
return rest;
}
export async function cmdCasPut(
storageRoot: string,
typeHash: string,
data: string,
): Promise<{ hash: string }> {
const store = openStore(storageRoot);
const payload = readJsonArg(data);
const hash = await store.put(typeHash, payload);
return { hash };
}
export async function cmdCasHas(
storageRoot: string,
hash: string,
): Promise<{ exists: boolean }> {
const store = openStore(storageRoot);
return { exists: store.has(hash) };
}
export async function cmdCasRefs(
storageRoot: string,
hash: string,
): Promise<{ refs: string[] }> {
const store = openStore(storageRoot);
const node = store.get(hash);
if (node === null) {
throw new Error(`Node not found: ${hash}`);
}
return { refs: refs(store, node) };
}
export async function cmdCasWalk(
storageRoot: string,
hash: string,
): Promise<{ hashes: string[] }> {
const store = openStore(storageRoot);
const result: string[] = [];
walk(store, hash, (h) => {
result.push(h);
});
return { hashes: result };
}
export type SchemaListEntry = {
hash: string;
title: string;
};
export async function cmdCasSchemaList(
storageRoot: string,
): Promise<SchemaListEntry[]> {
const store = openStore(storageRoot);
const metaHash = await bootstrap(store);
const entries: SchemaListEntry[] = [];
// Include meta-schema itself
entries.push({ hash: metaHash, title: "(meta-schema)" });
for (const hash of store.listByType(metaHash)) {
if (hash === metaHash) continue;
const node = store.get(hash);
if (node !== null) {
const schema = node.payload as JSONSchema;
const title =
(schema.title as string | undefined) ??
(schema.description as string | undefined) ??
"(unnamed)";
entries.push({ hash, title });
}
}
return entries;
}
export async function cmdCasReindex(
storageRoot: string,
): Promise<{ status: string }> {
const indexDir = join(storageRoot, "cas", "_index");
const { rmSync } = await import("node:fs");
rmSync(indexDir, { recursive: true, force: true });
// Re-open store to trigger migration rebuild
openStore(storageRoot);
return { status: "reindexed" };
}
export async function cmdCasSchemaGet(
storageRoot: string,
hash: string,
): Promise<unknown> {
const store = openStore(storageRoot);
const schema = getSchema(store, hash);
if (schema === null) {
throw new Error(`Schema not found: ${hash}`);
}
return schema;
}
-332
View File
@@ -1,332 +0,0 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { stringify, parse } from "yaml";
/**
* Preset provider list — embedded to avoid runtime YAML loading dependency.
* Keep in sync with providers.yaml in cli-workflow.
*/
const PRESET_PROVIDERS = [
// International
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
{ name: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1" },
{ name: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1" },
// China
{ name: "dashscope", label: "DashScope (Alibaba)", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
{ name: "siliconflow", label: "SiliconFlow", baseUrl: "https://api.siliconflow.cn/v1" },
{ name: "volcengine", label: "Volcengine (ByteDance)", baseUrl: "https://ark.cn-beijing.volces.com/api/v3" },
{ name: "kimi", label: "Kimi (Moonshot)", baseUrl: "https://api.moonshot.cn/v1" },
{ name: "glm", label: "GLM (Zhipu AI)", baseUrl: "https://open.bigmodel.cn/api/paas/v4" },
{ name: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" },
{ name: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/v1" },
// Local
{ name: "ollama", label: "Ollama (local)", baseUrl: "http://localhost:11434/v1" },
] as const;
type SetupArgs = {
provider: string;
baseUrl: string;
apiKey: string;
model: string;
agent?: string | undefined;
storageRoot: string;
};
function getConfigPath(root: string): string {
return join(root, "config.yaml");
}
function getEnvPath(root: string): string {
return join(root, ".env");
}
/**
* Load existing config.yaml or return empty structure.
*/
function loadExistingConfig(configPath: string): Record<string, unknown> {
try {
if (existsSync(configPath)) {
const raw = parse(readFileSync(configPath, "utf8")) as unknown;
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
return raw as Record<string, unknown>;
}
}
} catch {
// ignore parse errors, start fresh
}
return {};
}
/**
* Load existing .env as key=value map.
*/
function loadEnvFile(envPath: string): Record<string, string> {
const env: Record<string, string> = {};
try {
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (trimmed === "" || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq > 0) {
env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
}
}
}
} catch {
// ignore
}
return env;
}
function saveEnvFile(envPath: string, env: Record<string, string>): void {
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
writeFileSync(envPath, `${lines.join("\n")}\n`, "utf8");
}
function apiKeyEnvName(providerName: string): string {
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
}
/**
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
*/
function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record<string, unknown> {
const providers = (typeof existing.providers === "object" && existing.providers !== null
? { ...(existing.providers as Record<string, unknown>) }
: {}) as Record<string, unknown>;
const envName = apiKeyEnvName(args.provider);
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
const models = (typeof existing.models === "object" && existing.models !== null
? { ...(existing.models as Record<string, unknown>) }
: {}) as Record<string, unknown>;
models.default = { provider: args.provider, name: args.model };
const agents = (typeof existing.agents === "object" && existing.agents !== null
? { ...(existing.agents as Record<string, unknown>) }
: {}) as Record<string, unknown>;
const agentName = args.agent ?? "hermes";
if (Object.keys(agents).length === 0) {
agents.hermes = { command: "uwf-hermes", args: [] };
}
return {
...existing,
providers,
models,
agents,
defaultAgent: existing.defaultAgent ?? agentName,
defaultModel: existing.defaultModel ?? "default",
};
}
/**
* Non-interactive setup. All required args provided via CLI flags.
*/
export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>> {
const { storageRoot } = args;
mkdirSync(storageRoot, { recursive: true });
const configPath = getConfigPath(storageRoot);
const envPath = getEnvPath(storageRoot);
const existing = loadExistingConfig(configPath);
const merged = mergeConfig(existing, args);
writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8");
// Write API key to .env
const envName = apiKeyEnvName(args.provider);
const envData = loadEnvFile(envPath);
envData[envName] = args.apiKey;
saveEnvFile(envPath, envData);
return {
configPath,
envPath,
provider: args.provider,
model: args.model,
defaultAgent: merged.defaultAgent,
};
}
/** Read a line with terminal echo disabled (for secrets). */
async function promptSecret(label: string): Promise<string> {
process.stdout.write(label);
return new Promise((resolve) => {
const rawWasSet = process.stdin.isRaw;
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding("utf8");
let buf = "";
const onData = (chunk: string) => {
for (const c of chunk.toString()) {
if (c === "\n" || c === "\r" || c === "\u0004") {
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
process.stdin.pause();
process.stdin.removeListener("data", onData);
process.stdout.write("\n");
resolve(buf.trim());
return;
}
if (c === "\u007F" || c === "\b") {
if (buf.length > 0) {
buf = buf.slice(0, -1);
process.stdout.write("\b \b");
}
continue;
}
if (c === "\u0003") {
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
process.exit(130);
}
buf += c;
process.stdout.write("*");
}
};
process.stdin.on("data", onData);
});
}
/** Fetch available models from an OpenAI-compatible /models endpoint. */
async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
try {
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return [];
const body = (await res.json()) as { data?: { id: string }[] };
if (!Array.isArray(body.data)) return [];
const NON_CHAT = /speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
return body.data.map((m) => m.id).filter((id) => !NON_CHAT.test(id)).sort();
} catch {
return [];
}
}
/**
* Interactive setup — prompts user for provider, API key, model.
*/
export async function cmdSetupInteractive(storageRoot: string): Promise<Record<string, unknown>> {
const rl = createInterface({ input, output });
try {
console.log("Configure LLM provider for uwf workflow agents.\n");
// 1. Provider selection
const numWidth = String(PRESET_PROVIDERS.length + 1).length;
console.log("Select a provider:\n");
for (let i = 0; i < PRESET_PROVIDERS.length; i++) {
const p = PRESET_PROVIDERS[i];
if (!p) continue;
const num = String(i + 1).padStart(numWidth);
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
}
const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth);
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
const choiceNum = Number.parseInt(choice, 10);
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
throw new Error(`Invalid choice: ${choice}`);
}
let providerName: string;
let baseUrl: string;
if (choiceNum <= PRESET_PROVIDERS.length) {
const selected = PRESET_PROVIDERS[choiceNum - 1];
if (!selected) throw new Error("Invalid selection");
providerName = selected.name;
baseUrl = selected.baseUrl;
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
} else {
providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
if (!providerName) throw new Error("Provider name required");
baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
if (!baseUrl) throw new Error("Base URL required");
}
// 2. API key
rl.close();
const apiKey = await promptSecret("API key: ");
if (!apiKey) throw new Error("API key required");
// 3. Model selection
const rl2 = createInterface({ input, output });
console.log("\nFetching available models...");
const models = await fetchModels(baseUrl, apiKey);
let model: string;
if (models.length > 0) {
console.log(`\nAvailable models (${models.length}):\n`);
const nw = String(models.length).length;
// Multi-column layout
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
const colWidth = nw + 2 + maxLen + 4; // " N) name "
const termCols = process.stdout.columns || 100;
const cols = Math.max(1, Math.floor(termCols / colWidth));
const rows = Math.ceil(models.length / cols);
for (let r = 0; r < rows; r++) {
let line = "";
for (let c = 0; c < cols; c++) {
const idx = c * rows + r;
if (idx >= models.length) break;
const num = String(idx + 1).padStart(nw);
const name = (models[idx] ?? "").padEnd(maxLen);
line += ` ${num}) ${name} `;
}
console.log(line.trimEnd());
}
console.log(`\nChoose a number, or type a model name directly.`);
const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim();
if (!modelInput) throw new Error("Model required");
const modelNum = Number.parseInt(modelInput, 10);
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
model = models[modelNum - 1] ?? modelInput;
} else {
model = modelInput;
}
} else {
console.log("Could not fetch models. Enter model name manually.");
model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
if (!model) throw new Error("Model required");
}
rl2.close();
console.log(`${providerName}/${model}\n`);
await cmdSetup({
provider: providerName,
baseUrl,
apiKey,
model,
storageRoot,
});
console.log("Setup complete! Get started:\n");
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
console.log(' uwf thread start <name> -p "..." Start a thread');
console.log(" uwf thread step <thread-id> Execute next step");
console.log("");
return null as unknown as Record<string, unknown>;
} finally {
rl.close();
}
}
+15 -11
View File
@@ -13,7 +13,7 @@ import {
saveWorkflowRegistry,
type UwfStore,
} from "../store.js";
import { parseWorkflowPayload } from "../validate.js";
import { isCasRef, parseWorkflowPayload } from "../validate.js";
export type WorkflowListEntry = {
name: string;
@@ -44,16 +44,21 @@ function isJsonSchema(value: unknown): value is JSONSchema {
async function resolveOutputSchemaRef(
uwf: UwfStore,
roleName: string,
outputSchema: unknown,
outputSchema: string | JSONSchema,
): Promise<CasRef> {
if (!isJsonSchema(outputSchema)) {
fail(`role "${roleName}": outputSchema must be a JSON Schema object`);
if (typeof outputSchema === "string") {
if (!isCasRef(outputSchema)) {
fail(`invalid outputSchema cas_ref: ${outputSchema}`);
}
if (!uwf.store.has(outputSchema)) {
fail(`outputSchema not found in CAS: ${outputSchema}`);
}
return outputSchema;
}
const schema: JSONSchema = outputSchema.title === undefined
? { ...outputSchema, title: roleName }
: outputSchema;
return putSchema(uwf.store, schema);
if (!isJsonSchema(outputSchema)) {
fail("outputSchema must be a cas_ref string or JSON Schema object");
}
return putSchema(uwf.store, outputSchema);
}
async function materializeWorkflowPayload(
@@ -64,8 +69,7 @@ async function materializeWorkflowPayload(
for (const [roleName, role] of Object.entries(raw.roles)) {
const outputSchema = await resolveOutputSchemaRef(
uwf,
`${raw.name}.${roleName}`,
role.outputSchema,
role.outputSchema as string | JSONSchema,
);
roles[roleName] = {
description: role.description,
-12
View File
@@ -1,12 +0,0 @@
import { stringify } from "yaml";
export type OutputFormat = "json" | "yaml";
export function formatOutput(data: unknown, format: OutputFormat): string {
switch (format) {
case "json":
return JSON.stringify(data);
case "yaml":
return stringify(data).trimEnd();
}
}
+3 -1
View File
@@ -15,7 +15,9 @@ function isRoleDefinition(value: unknown): boolean {
return false;
}
const outputSchema = value.outputSchema;
const schemaOk = isRecord(outputSchema) && typeof outputSchema.type === "string";
const schemaOk =
typeof outputSchema === "string" ||
(isRecord(outputSchema) && typeof outputSchema.type === "string");
return (
typeof value.description === "string" && typeof value.systemPrompt === "string" && schemaOk
);
@@ -1,121 +0,0 @@
import { describe, expect, test } from "bun:test";
import { createMemoryStore, refs, validate, walk } from "@uncaged/json-cas";
import {
computeDurationMs,
extractLastAssistantContent,
messageToTurnPayload,
parseSessionIdFromStdout,
storeHermesSessionDetail,
} from "../src/session-detail.js";
import type { HermesSessionJson, HermesSessionMessage } from "../src/types.js";
describe("parseSessionIdFromStdout", () => {
test("reads session_id from the last non-empty line", () => {
const stdout = "Done.\n\nsession_id: 20260518_223724_45ab80\n";
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_223724_45ab80");
});
test("returns null when trailing line is not session_id", () => {
expect(parseSessionIdFromStdout("only assistant text\n")).toBeNull();
});
});
describe("messageToTurnPayload", () => {
test("maps assistant tool_calls to toolCalls", () => {
const msg: HermesSessionMessage = {
role: "assistant",
content: "",
reasoning: null,
tool_calls: [{ function: { name: "read_file", arguments: '{"path":"x"}' } }],
};
const turn = messageToTurnPayload(msg, 0);
expect(turn).toEqual({
index: 0,
role: "assistant",
content: "",
toolCalls: [{ name: "read_file", args: '{"path":"x"}' }],
reasoning: null,
});
});
test("skips user messages", () => {
const msg: HermesSessionMessage = {
role: "user",
content: "hi",
reasoning: null,
tool_calls: null,
};
expect(messageToTurnPayload(msg, 0)).toBeNull();
});
});
describe("extractLastAssistantContent", () => {
test("returns the last non-empty assistant content", () => {
const messages: HermesSessionMessage[] = [
{ role: "assistant", content: "first", reasoning: null, tool_calls: null },
{ role: "tool", content: "tool output", reasoning: null, tool_calls: null },
{ role: "assistant", content: "", reasoning: null, tool_calls: null },
{ role: "assistant", content: "final answer", reasoning: null, tool_calls: null },
];
expect(extractLastAssistantContent(messages)).toBe("final answer");
});
});
describe("computeDurationMs", () => {
test("computes elapsed time from session_start", () => {
const now = Date.parse("2026-05-18T13:32:59.028640Z");
const duration = computeDurationMs("2026-05-18T13:31:59.028640Z", now);
expect(duration).toBe(60_000);
});
});
describe("storeHermesSessionDetail", () => {
test("stores hermes-detail root with cas_ref turns walkable", async () => {
const session: HermesSessionJson = {
session_id: "20260518_133159_6a84e8",
model: "claude-opus-4.6",
session_start: "2026-05-18T13:31:59.028640",
messages: [
{ role: "user", content: "task", reasoning: null, tool_calls: null },
{
role: "assistant",
content: "",
reasoning: "thinking",
tool_calls: [{ function: { name: "terminal", arguments: "{}" } }],
},
{ role: "tool", content: "ok", reasoning: null, tool_calls: null },
{ role: "assistant", content: "done", reasoning: null, tool_calls: null },
],
};
const store = createMemoryStore();
const now = Date.parse("2026-05-18T13:32:59.028640");
const { detailHash, output } = await storeHermesSessionDetail(store, session, now);
expect(output).toBe("done");
const detailNode = store.get(detailHash);
expect(detailNode).not.toBeNull();
if (detailNode === null) {
return;
}
expect(validate(store, detailNode)).toBe(true);
expect(detailNode.payload).toMatchObject({
sessionId: "20260518_133159_6a84e8",
model: "claude-opus-4.6",
duration: 60_000,
turnCount: 3,
});
const turnRefs = refs(store, detailNode);
expect(turnRefs).toHaveLength(3);
const visited: string[] = [];
walk(store, detailHash, (hash) => visited.push(hash));
expect(visited).toContain(detailHash);
for (const turnHash of turnRefs) {
expect(visited).toContain(turnHash);
}
});
});
-1
View File
@@ -21,7 +21,6 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.3.0",
"@uncaged/uwf-agent-kit": "workspace:^"
},
"devDependencies": {
View File
+3 -23
View File
@@ -1,13 +1,6 @@
import { spawn } from "node:child_process";
import { type AgentContext, type AgentRunResult, createAgent } from "@uncaged/uwf-agent-kit";
import {
loadHermesSession,
parseSessionIdFromStdout,
storeHermesRawOutput,
storeHermesSessionDetail,
} from "./session-detail.js";
import { type AgentContext, createAgent } from "@uncaged/uwf-agent-kit";
const HERMES_COMMAND = "hermes";
const HERMES_MAX_TURNS = 90;
@@ -83,22 +76,9 @@ function spawnHermesChat(prompt: string): Promise<string> {
});
}
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
async function runHermes(ctx: AgentContext): Promise<string> {
const fullPrompt = buildHermesPrompt(ctx);
const rawOutput = await spawnHermesChat(fullPrompt);
const { store } = ctx;
const sessionId = parseSessionIdFromStdout(rawOutput);
if (sessionId !== null) {
const session = await loadHermesSession(sessionId);
if (session !== null) {
const { detailHash, output } = await storeHermesSessionDetail(store, session);
return { output, detailHash };
}
}
const detailHash = await storeHermesRawOutput(store, rawOutput);
return { output: rawOutput, detailHash };
return spawnHermesChat(fullPrompt);
}
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
-57
View File
@@ -1,57 +0,0 @@
import type { JSONSchema } from "@uncaged/json-cas";
const HERMES_TOOL_CALL_SCHEMA: JSONSchema = {
type: "object",
required: ["name", "args"],
properties: {
name: { type: "string" },
args: { type: "string" },
},
additionalProperties: false,
};
export const HERMES_TURN_SCHEMA: JSONSchema = {
title: "hermes-turn",
type: "object",
required: ["index", "role", "content"],
properties: {
index: { type: "integer" },
role: { type: "string", enum: ["assistant", "tool"] },
content: { type: "string" },
toolCalls: {
anyOf: [{ type: "array", items: HERMES_TOOL_CALL_SCHEMA }, { type: "null" }],
},
reasoning: {
anyOf: [{ type: "string" }, { type: "null" }],
},
},
additionalProperties: false,
};
export const HERMES_DETAIL_SCHEMA: JSONSchema = {
title: "hermes-detail",
type: "object",
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" },
model: { type: "string" },
duration: { type: "integer" },
turnCount: { type: "integer" },
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
};
/** Fallback detail when Hermes session file is unavailable. */
export const HERMES_RAW_OUTPUT_SCHEMA: JSONSchema = {
title: "hermes-raw-output",
type: "object",
required: ["text"],
properties: {
text: { type: "string" },
},
additionalProperties: false,
};
@@ -1,228 +0,0 @@
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import { HERMES_DETAIL_SCHEMA, HERMES_RAW_OUTPUT_SCHEMA, HERMES_TURN_SCHEMA } from "./schemas.js";
import type {
HermesDetailPayload,
HermesSessionJson,
HermesSessionMessage,
HermesToolCall,
HermesTurnPayload,
HermesTurnRole,
} from "./types.js";
const SESSION_ID_LINE = /^session_id:\s*(\S+)\s*$/i;
export function getHermesSessionsDir(): string {
return join(homedir(), ".hermes", "sessions");
}
export function getHermesSessionPath(sessionId: string): string {
return join(getHermesSessionsDir(), `session_${sessionId}.json`);
}
/** Parse `session_id: …` from the last non-empty line of Hermes stdout. */
export function parseSessionIdFromStdout(stdout: string): string | null {
const lines = stdout.split(/\r?\n/).map((line) => line.trim());
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line === undefined || line === "") {
continue;
}
const match = SESSION_ID_LINE.exec(line);
if (match?.[1] !== undefined) {
return match[1];
}
break;
}
return null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseToolCalls(raw: unknown): HermesSessionMessage["tool_calls"] {
if (!Array.isArray(raw) || raw.length === 0) {
return null;
}
const calls: NonNullable<HermesSessionMessage["tool_calls"]> = [];
for (const entry of raw) {
if (!isRecord(entry)) {
continue;
}
const fn = entry.function;
if (!isRecord(fn)) {
continue;
}
const name = fn.name;
const args = fn.arguments;
if (typeof name !== "string" || typeof args !== "string") {
continue;
}
calls.push({ function: { name, arguments: args } });
}
return calls.length > 0 ? calls : null;
}
function normalizeMessage(raw: unknown): HermesSessionMessage | null {
if (!isRecord(raw)) {
return null;
}
const role = raw.role;
if (role !== "assistant" && role !== "tool" && role !== "user") {
return null;
}
const content = typeof raw.content === "string" ? raw.content : raw.content === null ? null : "";
const reasoning =
typeof raw.reasoning === "string"
? raw.reasoning
: raw.reasoning === null || raw.reasoning === undefined
? null
: null;
const tool_calls = parseToolCalls(raw.tool_calls);
return { role, content, reasoning, tool_calls };
}
function parseSessionJson(raw: unknown): HermesSessionJson | null {
if (!isRecord(raw)) {
return null;
}
const session_id = raw.session_id;
const model = raw.model;
const session_start = raw.session_start;
const messagesRaw = raw.messages;
if (
typeof session_id !== "string" ||
typeof model !== "string" ||
typeof session_start !== "string" ||
!Array.isArray(messagesRaw)
) {
return null;
}
const messages: HermesSessionMessage[] = [];
for (const entry of messagesRaw) {
const msg = normalizeMessage(entry);
if (msg !== null) {
messages.push(msg);
}
}
return { session_id, model, session_start, messages };
}
export async function loadHermesSession(sessionId: string): Promise<HermesSessionJson | null> {
const path = getHermesSessionPath(sessionId);
try {
const text = await readFile(path, "utf8");
const raw = JSON.parse(text) as unknown;
return parseSessionJson(raw);
} catch {
return null;
}
}
export function computeDurationMs(sessionStart: string, nowMs: number = Date.now()): number {
const startMs = Date.parse(sessionStart);
if (Number.isNaN(startMs)) {
return 0;
}
return Math.max(0, nowMs - startMs);
}
function mapSessionToolCalls(
toolCalls: HermesSessionMessage["tool_calls"],
): HermesToolCall[] | null {
if (toolCalls === null || toolCalls.length === 0) {
return null;
}
return toolCalls.map((call) => ({
name: call.function.name,
args: call.function.arguments,
}));
}
export function messageToTurnPayload(
message: HermesSessionMessage,
index: number,
): HermesTurnPayload | null {
if (message.role !== "assistant" && message.role !== "tool") {
return null;
}
const role = message.role as HermesTurnRole;
return {
index,
role,
content: message.content ?? "",
toolCalls: mapSessionToolCalls(message.tool_calls),
reasoning: message.reasoning,
};
}
/** Last assistant message with non-empty text content (walks backward). */
export function extractLastAssistantContent(messages: HermesSessionMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg === undefined) {
continue;
}
if (msg.role === "assistant" && msg.content !== null && msg.content.trim() !== "") {
return msg.content;
}
}
return "";
}
type HermesSchemaHashes = {
turn: string;
detail: string;
rawOutput: string;
};
async function registerHermesSchemas(store: Store): Promise<HermesSchemaHashes> {
await bootstrap(store);
const [turn, detail, rawOutput] = await Promise.all([
putSchema(store, HERMES_TURN_SCHEMA),
putSchema(store, HERMES_DETAIL_SCHEMA),
putSchema(store, HERMES_RAW_OUTPUT_SCHEMA),
]);
return { turn, detail, rawOutput };
}
export async function storeHermesSessionDetail(
store: Store,
session: HermesSessionJson,
nowMs: number = Date.now(),
): Promise<{ detailHash: string; output: string }> {
const schemas = await registerHermesSchemas(store);
const turnHashes: string[] = [];
let turnIndex = 0;
for (const message of session.messages) {
const turn = messageToTurnPayload(message, turnIndex);
if (turn === null) {
continue;
}
const hash = await store.put(schemas.turn, turn);
turnHashes.push(hash);
turnIndex += 1;
}
const detail: HermesDetailPayload = {
sessionId: session.session_id,
model: session.model,
duration: computeDurationMs(session.session_start, nowMs),
turnCount: turnHashes.length,
turns: turnHashes,
};
const detailHash = await store.put(schemas.detail, detail);
const output = extractLastAssistantContent(session.messages);
return { detailHash, output };
}
export async function storeHermesRawOutput(store: Store, rawOutput: string): Promise<string> {
const schemas = await registerHermesSchemas(store);
return store.put(schemas.rawOutput, { text: rawOutput });
}
-43
View File
@@ -1,43 +0,0 @@
export type HermesTurnRole = "assistant" | "tool";
export type HermesToolCall = {
name: string;
args: string;
};
export type HermesTurnPayload = {
index: number;
role: HermesTurnRole;
content: string;
toolCalls: HermesToolCall[] | null;
reasoning: string | null;
};
export type HermesDetailPayload = {
sessionId: string;
model: string;
duration: number;
turnCount: number;
turns: string[];
};
export type HermesSessionToolCall = {
function: {
name: string;
arguments: string;
};
};
export type HermesSessionMessage = {
role: string;
content: string | null;
tool_calls: HermesSessionToolCall[] | null;
reasoning: string | null;
};
export type HermesSessionJson = {
session_id: string;
model: string;
session_start: string;
messages: HermesSessionMessage[];
};
+2 -2
View File
@@ -18,8 +18,8 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.3.0",
"@uncaged/json-cas-fs": "^0.3.0",
"@uncaged/json-cas": "^0.1.1",
"@uncaged/json-cas-fs": "^0.1.1",
"@uncaged/uwf-protocol": "workspace:^",
"dotenv": "^16.6.1",
"yaml": "^2.8.4"
+8 -12
View File
@@ -1,4 +1,3 @@
import type { Store } from "@uncaged/json-cas";
import type {
CasRef,
StartNodePayload,
@@ -7,7 +6,6 @@ import type {
ThreadId,
} from "@uncaged/uwf-protocol";
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
import type { AgentStore } from "./storage.js";
import type { AgentContext } from "./types.js";
type ChainState = {
@@ -22,8 +20,8 @@ function fail(message: string): never {
}
function walkChain(
store: Store,
schemas: AgentStore["schemas"],
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"],
headHash: CasRef,
): ChainState {
const headNode = store.get(headHash);
@@ -79,7 +77,7 @@ function walkChain(
}
function expandOutput(
store: Store,
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
outputRef: CasRef,
): unknown {
const node = store.get(outputRef);
@@ -90,7 +88,7 @@ function expandOutput(
}
async function buildHistory(
store: Store,
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
stepsNewestFirst: StepNodePayload[],
): Promise<StepContext[]> {
const chronological = [...stepsNewestFirst].reverse();
@@ -107,8 +105,8 @@ async function buildHistory(
}
async function loadWorkflow(
store: Store,
schemas: AgentStore["schemas"],
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"],
workflowRef: CasRef,
) {
const node = store.get(workflowRef);
@@ -152,14 +150,13 @@ export async function buildContext(threadId: ThreadId, role: string): Promise<Ag
prompt: chain.start.prompt,
history,
workflow,
store,
};
}
export type BuildContextMeta = {
storageRoot: string;
store: Store;
schemas: AgentStore["schemas"];
store: Awaited<ReturnType<typeof createAgentStore>>["store"];
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"];
headHash: CasRef;
chain: ChainState;
};
@@ -197,7 +194,6 @@ export async function buildContextWithMeta(
prompt: chain.start.prompt,
history,
workflow,
store,
meta: { storageRoot, store, schemas, headHash, chain },
};
}
+2 -2
View File
@@ -1,5 +1,6 @@
export type { BuildContextMeta } from "./context.js";
export { buildContext, buildContextWithMeta } from "./context.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
export type { ExtractResult, ResolvedLlmProvider } from "./extract.js";
export {
extract,
@@ -7,5 +8,4 @@ export {
resolveModel,
} from "./extract.js";
export { createAgent } from "./run.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } from "./types.js";
export type { AgentContext, AgentOptions, AgentRunFn } from "./types.js";
+8 -7
View File
@@ -6,7 +6,7 @@ import { buildContextWithMeta } from "./context.js";
import { extract } from "./extract.js";
import type { AgentStore } from "./storage.js";
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js";
import type { AgentContext, AgentOptions } from "./types.js";
function fail(message: string): never {
process.stderr.write(`${message}\n`);
@@ -65,7 +65,7 @@ async function writeStepNode(options: {
return hash;
}
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<AgentRunResult> {
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<string> {
return runWithMessage("agent run failed", () => options.run(ctx));
}
@@ -85,11 +85,12 @@ async function extractOutput(
async function persistStep(options: {
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>;
rawOutput: string;
outputHash: CasRef;
detailHash: CasRef;
agentName: string;
}): Promise<CasRef> {
const { store, schemas, chain, headHash } = options.ctx.meta;
const detailHash = await store.put(null, options.rawOutput);
return writeStepNode({
store,
schemas,
@@ -97,7 +98,7 @@ async function persistStep(options: {
prevHash: chain.headIsStart ? null : headHash,
role: options.ctx.role,
outputHash: options.outputHash,
detailHash: options.detailHash,
detailHash,
agentName: options.agentName,
});
}
@@ -120,12 +121,12 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
fail(`unknown role: ${role}`);
}
const agentResult = await runAgent(options, ctx);
const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot);
const rawOutput = await runAgent(options, ctx);
const outputHash = await extractOutput(rawOutput, roleDef.outputSchema, storageRoot);
const stepHash = await persistStep({
ctx,
rawOutput,
outputHash,
detailHash: agentResult.detailHash,
agentName: agentLabel(options.name),
});
+5 -1
View File
@@ -1,6 +1,10 @@
import type { Hash, Store } from "@uncaged/json-cas";
import { putSchema } from "@uncaged/json-cas";
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/uwf-protocol";
import {
START_NODE_SCHEMA,
STEP_NODE_SCHEMA,
WORKFLOW_SCHEMA,
} from "@uncaged/uwf-protocol";
export type UwfAgentSchemaHashes = {
workflow: Hash;
+1 -8
View File
@@ -1,4 +1,3 @@
import type { Store } from "@uncaged/json-cas";
import type { StepContext, ThreadId, WorkflowPayload } from "@uncaged/uwf-protocol";
export type AgentContext = {
@@ -8,15 +7,9 @@ export type AgentContext = {
prompt: string;
history: StepContext[];
workflow: WorkflowPayload;
store: Store;
};
export type AgentRunResult = {
output: string;
detailHash: string;
};
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
export type AgentRunFn = (ctx: AgentContext) => Promise<string>;
export type AgentOptions = {
name: string;
+1 -1
View File
@@ -15,7 +15,7 @@
}
},
"dependencies": {
"@uncaged/json-cas-fs": "^0.3.0"
"@uncaged/json-cas": "^0.1.1"
},
"devDependencies": {
"typescript": "^5.8.3"
-3
View File
@@ -32,7 +32,6 @@ const TRANSITION: JSONSchema = {
};
export const WORKFLOW_SCHEMA: JSONSchema = {
title: "Workflow",
type: "object",
required: ["name", "description", "roles", "conditions", "graph"],
properties: {
@@ -58,7 +57,6 @@ export const WORKFLOW_SCHEMA: JSONSchema = {
};
export const START_NODE_SCHEMA: JSONSchema = {
title: "StartNode",
type: "object",
required: ["workflow", "prompt"],
properties: {
@@ -69,7 +67,6 @@ export const START_NODE_SCHEMA: JSONSchema = {
};
export const STEP_NODE_SCHEMA: JSONSchema = {
title: "StepNode",
type: "object",
required: ["start", "prev", "role", "output", "detail", "agent"],
properties: {
@@ -1,22 +0,0 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/package-descriptor.js";
import { createDocxDiffAgent } from "../src/agent.js";
describe("createDocxDiffAgent", () => {
test("returns an AdapterFn (function)", () => {
const agent = createDocxDiffAgent({ command: null });
expect(typeof agent).toBe("function");
});
test("AdapterFn returns a RoleFn (function)", () => {
const agent = createDocxDiffAgent({ command: null });
const roleFn = agent("", expect.anything() as never);
expect(typeof roleFn).toBe("function");
});
});
describe("packageDescriptor", () => {
test("has correct name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-docx-diff");
});
});
@@ -1,113 +0,0 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, mock, test } from "bun:test";
import { ok, err } from "@uncaged/workflow-util";
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
import { runDocxDiff } from "../src/runner.js";
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
function makeSpawn(result: MockSpawnResult) {
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
}
function tempDir(): string {
const dir = join(tmpdir(), `diff-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe("runDocxDiff", () => {
test("exit 0: success, returns DifferMeta JSON", async () => {
const dir = tempDir();
const sourceDocx = join(dir, "original.docx");
const modifiedDocx = join(dir, "modified.docx");
const diffDocx = join(dir, "diff.docx");
writeFileSync(sourceDocx, "");
writeFileSync(modifiedDocx, "");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// simulate docx-diff creating the diff file
writeFileSync(diffDocx, "");
const raw = await runDocxDiff(
{ command: "docx-diff" },
sourceDocx,
modifiedDocx,
diffDocx,
spawnFn,
);
const meta = JSON.parse(raw);
expect(meta.sourceDocx).toBe(sourceDocx);
expect(meta.modifiedDocx).toBe(modifiedDocx);
expect(meta.diffDocx).toBe(diffDocx);
expect(spawnFn.mock.calls[0][1]).toEqual([
sourceDocx,
modifiedDocx,
"--output",
"docx",
"--out-file",
diffDocx,
]);
});
test("exit 1 (changes found): treated as success", async () => {
const dir = tempDir();
const sourceDocx = join(dir, "s.docx");
const modifiedDocx = join(dir, "m.docx");
const diffDocx = join(dir, "diff.docx");
writeFileSync(sourceDocx, "");
writeFileSync(modifiedDocx, "");
writeFileSync(diffDocx, "");
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "" }) as MockSpawnResult,
);
await expect(
runDocxDiff({ command: "docx-diff" }, sourceDocx, modifiedDocx, diffDocx, spawnFn),
).resolves.toBeDefined();
});
test("exit 2: throws error", async () => {
const dir = tempDir();
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 2, stdout: "", stderr: "fatal error" }) as MockSpawnResult,
);
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
).rejects.toThrow("docx-diff failed");
});
test("timeout: throws error", async () => {
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
).rejects.toThrow("timed out");
});
test("throws when diff file not created", async () => {
const dir = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// do NOT create diffDocx
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", join(dir, "missing.docx"), spawnFn),
).rejects.toThrow("diff file not found");
});
test("uses PATH docx-diff when command is null", async () => {
const dir = tempDir();
const diffDocx = join(dir, "diff.docx");
writeFileSync(diffDocx, "");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
await runDocxDiff({ command: null }, "s.docx", "m.docx", diffDocx, spawnFn);
expect(spawnFn.mock.calls[0][0]).toBe("docx-diff");
});
});
@@ -1,29 +0,0 @@
{
"name": "@uncaged/workflow-agent-docx-diff",
"version": "0.1.0",
"files": ["src", "dist", "package.json"],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-template-document": "workspace:^",
"zod": "^4.0.0"
},
"devDependencies": {
"@uncaged/workflow-util": "workspace:^"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,29 +0,0 @@
import * as z from "zod/v4";
import { dirname, join } from "node:path";
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import type { WriterMeta } from "@uncaged/workflow-template-document";
import { runDocxDiff } from "./runner.js";
import type { DocxDiffAgentConfig } from "./types.js";
export function createDocxDiffAgent(config: DocxDiffAgentConfig): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const writerStep = ctx.steps.find((s) => s.role === "writer");
if (writerStep === undefined) throw new Error("differ: no writer step found");
const writerMeta = writerStep.meta as WriterMeta;
if (writerMeta.mode !== "edit")
throw new Error("differ: writer did not run in edit mode");
const diffDocx = join(dirname(writerMeta.outputDocx), "diff.docx");
const raw = await runDocxDiff(
config,
writerMeta.sourceDocx,
writerMeta.outputDocx,
diffDocx,
);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
@@ -1,3 +0,0 @@
export { createDocxDiffAgent } from "./agent.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { DocxDiffAgentConfig } from "./types.js";
@@ -1,17 +0,0 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-docx-diff",
version: "0.1.0",
capabilities: ["docx-diff-cli", "docx-diff-report"],
configSchema: {
type: "object",
properties: {
command: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Path to docx-diff CLI binary; null uses PATH.",
},
},
additionalProperties: false,
},
};
@@ -1,47 +0,0 @@
import { stat } from "node:fs/promises";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
import type { DocxDiffAgentConfig } from "./types.js";
type SpawnCliFn = typeof spawnCli;
function throwSpawnError(e: SpawnCliError): never {
if (e.kind === "non_zero_exit")
throw new Error(`docx-diff failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("docx-diff: timed out");
throw new Error(`docx-diff: spawn failed: ${e.message}`);
}
export async function runDocxDiff(
config: DocxDiffAgentConfig,
sourceDocx: string,
modifiedDocx: string,
diffDocx: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<string> {
const command = config.command ?? "docx-diff";
const result = await spawnCliFn(
command,
[sourceDocx, modifiedDocx, "--output", "docx", "--out-file", diffDocx],
{ cwd: null, timeoutMs: null },
);
if (!result.ok) {
const e = result.error;
// exit 1 = changes found (normal for docx-diff)
if (e.kind === "non_zero_exit" && e.exitCode === 1) {
// fall through to file check
} else {
throwSpawnError(e);
}
}
try {
await stat(diffDocx);
} catch {
throw new Error(`docx-diff: diff file not found: ${diffDocx}`);
}
return JSON.stringify({ sourceDocx, modifiedDocx, diffDocx });
}
@@ -1,3 +0,0 @@
export type DocxDiffAgentConfig = {
command: string | null;
};
@@ -1,15 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util-agent" },
{ "path": "../workflow-template-document" }
]
}
@@ -1,27 +0,0 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/package-descriptor.js";
import { createOfficeAgent } from "../src/agent.js";
describe("createOfficeAgent", () => {
test("returns an AdapterFn (function)", () => {
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
expect(typeof agent).toBe("function");
});
test("AdapterFn returns a RoleFn (function)", () => {
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
const roleFn = agent("", expect.anything() as never);
expect(typeof roleFn).toBe("function");
});
});
describe("packageDescriptor", () => {
test("has correct name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-office");
});
test("has outputDir in configSchema required", () => {
const schema = packageDescriptor.configSchema as { required: string[] };
expect(schema.required).toContain("outputDir");
});
});
@@ -1,129 +0,0 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, mock, test } from "bun:test";
import { ok, err } from "@uncaged/workflow-util";
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
import { editDocument, generateDocument } from "../src/runner.js";
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
function makeSpawn(result: MockSpawnResult) {
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
}
function tempDir(): string {
const dir = join(tmpdir(), `office-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe("generateDocument", () => {
test("calls office-agent create with correct args and returns outputDocx path", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("agent reply") as MockSpawnResult);
// Simulate CLI creating the file
const outFile = join(base, "thread1", "output.docx");
mkdirSync(join(base, "thread1"), { recursive: true });
writeFileSync(outFile, "");
const result = await generateDocument(
{ outputDir: base, command: "office-agent", timeout: null },
"thread1",
"Write a report",
spawnFn,
);
expect(result.outputDocx).toBe(outFile);
expect(result.sourceDocx).toBeNull();
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
expect(spawnFn.mock.calls[0][1]).toEqual(["create", "Write a report", "-o", "output.docx"]);
expect(spawnFn.mock.calls[0][2].cwd).toBe(join(base, "thread1"));
});
test("uses PATH office-agent when command is null", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
mkdirSync(join(base, "t2"), { recursive: true });
writeFileSync(join(base, "t2", "output.docx"), "");
await generateDocument(
{ outputDir: base, command: null, timeout: null },
"t2",
"Generate",
spawnFn,
);
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
});
test("throws on non_zero_exit", async () => {
const base = tempDir();
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "error" }) as MockSpawnResult,
);
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t3", "fail", spawnFn),
).rejects.toThrow("office-agent failed (exit 1)");
});
test("throws on timeout", async () => {
const base = tempDir();
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t4", "slow", spawnFn),
).rejects.toThrow("office-agent: timed out");
});
test("throws when output file not created", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// Do NOT create output.docx
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t5", "no file", spawnFn),
).rejects.toThrow("output file not found");
});
});
describe("editDocument", () => {
test("copies input to original.docx and modified.docx, calls edit, returns paths", async () => {
const base = tempDir();
// Create a fake inputDocx
const inputFile = join(base, "source.docx");
writeFileSync(inputFile, "original content");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// Simulate CLI overwriting modified.docx
const outDir = join(base, "te1");
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "modified.docx"), "modified content");
const result = await editDocument(
{ outputDir: base, command: "office-agent", timeout: null },
"te1",
"Edit the doc",
inputFile,
spawnFn,
);
expect(result.outputDocx).toBe(join(outDir, "modified.docx"));
expect(result.sourceDocx).toBe(join(outDir, "original.docx"));
expect(spawnFn.mock.calls[0][1]).toEqual(["edit", "modified.docx", "Edit the doc"]);
});
test("throws on spawn_failed", async () => {
const base = tempDir();
const inputFile = join(base, "src.docx");
writeFileSync(inputFile, "");
const spawnFn = makeSpawn(
err({ kind: "spawn_failed", message: "not found" }) as MockSpawnResult,
);
await expect(
editDocument({ outputDir: base, command: null, timeout: null }, "te2", "edit", inputFile, spawnFn),
).rejects.toThrow("spawn failed");
});
});
@@ -1,25 +0,0 @@
{
"name": "@uncaged/workflow-agent-office",
"version": "0.1.0",
"files": ["src", "dist", "package.json"],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,44 +0,0 @@
import * as z from "zod/v4";
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import { editDocument, generateDocument } from "./runner.js";
import type { OfficeAgentConfig } from "./types.js";
const log = createLogger({ sink: { kind: "stderr" } });
type ParsedInput = { prompt: string; inputDocx: string | null };
function parseStartInput(content: string): ParsedInput {
try {
const parsed = JSON.parse(content) as Record<string, unknown>;
if (typeof parsed.prompt === "string") {
return {
prompt: parsed.prompt,
inputDocx: typeof parsed.inputDocx === "string" ? parsed.inputDocx : null,
};
}
} catch {
// not JSON — treat whole content as prompt, generate mode
}
return { prompt: content, inputDocx: null };
}
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
log("8FQKP3NV", `office-agent: mode=${inputDocx === null ? "generate" : "edit"} thread=${ctx.threadId}`);
let raw: string;
if (inputDocx === null) {
const result = await generateDocument(config, ctx.threadId, prompt);
raw = JSON.stringify({ mode: "generate", outputDocx: result.outputDocx, sourceDocx: null });
} else {
const result = await editDocument(config, ctx.threadId, prompt, inputDocx);
raw = JSON.stringify({ mode: "edit", outputDocx: result.outputDocx, sourceDocx: result.sourceDocx });
}
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
@@ -1,3 +0,0 @@
export { createOfficeAgent } from "./agent.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { OfficeAgentConfig } from "./types.js";
@@ -1,26 +0,0 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-office",
version: "0.1.0",
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
configSchema: {
type: "object",
required: ["outputDir"],
properties: {
outputDir: {
type: "string",
description: "Root directory for workflow outputs; subdirs are created per threadId.",
},
command: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Path to office-agent CLI binary; null uses PATH.",
},
timeout: {
anyOf: [{ type: "number" }, { type: "null" }],
description: "Timeout in milliseconds; null means no limit.",
},
},
additionalProperties: false,
},
};
@@ -1,65 +0,0 @@
import { copyFile, mkdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
import type { OfficeAgentConfig } from "./types.js";
type SpawnCliFn = typeof spawnCli;
function throwSpawnError(e: SpawnCliError): never {
if (e.kind === "non_zero_exit")
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("office-agent: timed out");
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
async function assertFileExists(path: string): Promise<void> {
try {
await stat(path);
} catch {
throw new Error(`office-agent: output file not found: ${path}`);
}
}
export async function generateDocument(
config: OfficeAgentConfig,
threadId: string,
prompt: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<{ outputDocx: string; sourceDocx: null }> {
const outputDir = join(config.outputDir, threadId);
await mkdir(outputDir, { recursive: true });
const command = config.command ?? "office-agent";
const result = await spawnCliFn(command, ["create", prompt, "-o", "output.docx"], {
cwd: outputDir,
timeoutMs: config.timeout,
});
if (!result.ok) throwSpawnError(result.error);
const outputDocx = join(outputDir, "output.docx");
await assertFileExists(outputDocx);
return { outputDocx, sourceDocx: null };
}
export async function editDocument(
config: OfficeAgentConfig,
threadId: string,
prompt: string,
inputDocx: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<{ outputDocx: string; sourceDocx: string }> {
const outputDir = join(config.outputDir, threadId);
await mkdir(outputDir, { recursive: true });
const originalDocx = join(outputDir, "original.docx");
const modifiedDocx = join(outputDir, "modified.docx");
await copyFile(inputDocx, originalDocx);
await copyFile(inputDocx, modifiedDocx);
const command = config.command ?? "office-agent";
const result = await spawnCliFn(command, ["edit", "modified.docx", prompt], {
cwd: outputDir,
timeoutMs: config.timeout,
});
if (!result.ok) throwSpawnError(result.error);
await assertFileExists(modifiedDocx);
return { outputDocx: modifiedDocx, sourceDocx: originalDocx };
}
@@ -1,5 +0,0 @@
export type OfficeAgentConfig = {
outputDir: string;
command: string | null;
timeout: number | null;
};
@@ -1,15 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util" },
{ "path": "../workflow-util-agent" }
]
}
-8
View File
@@ -4,14 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow Dashboard</title>
<script>
(function () {
var t = localStorage.getItem("theme");
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
}
})();
</script>
</head>
<body>
<div id="root"></div>
+1 -13
View File
@@ -13,23 +13,11 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
"react-router": "^7.15.1",
"shiki": "^4.0.2",
"tailwind-merge": "^3.6.0"
"shiki": "^4.0.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
@@ -1,362 +0,0 @@
import { createFilter, type Plugin } from "vite";
type LimitLineOverride = {
files: string;
maxReactFCLines: number | null;
maxFileLines: number | null;
};
type LimitLineOptions = {
maxReactFCLines: number;
maxFileLines: number;
include: RegExp;
exclude: RegExp | null;
overrides: Array<LimitLineOverride>;
};
const DEFAULT_OPTIONS: LimitLineOptions = {
maxReactFCLines: 300,
maxFileLines: 600,
include: /\.[tj]sx$/,
exclude: null,
overrides: [],
};
type ResolvedLimits = {
maxReactFCLines: number | null;
maxFileLines: number | null;
};
type ComponentInfo = {
name: string;
startLine: number;
lineCount: number;
};
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
// --- AST types (Rolldown ESTree subset) ---
type Identifier = {
type: "Identifier";
name: string;
};
type MemberExpression = {
type: "MemberExpression";
object: AstExpression;
property: Identifier;
};
type CallExpression = {
type: "CallExpression";
callee: AstExpression;
arguments: Array<AstExpression>;
};
type AstExpression = Identifier | MemberExpression | CallExpression | {
type: string;
[key: string]: unknown;
};
type VariableDeclarator = {
id: Identifier | null;
init: AstExpression | null;
};
type AstStatement = {
type: string;
id: Identifier | null;
declaration: AstStatement | null;
declarations: Array<VariableDeclarator>;
body: Array<AstStatement>;
[key: string]: unknown;
};
type AstProgram = {
type: "Program";
body: Array<AstStatement>;
};
// --- AST helpers ---
function isFunctionLike(node: AstExpression): boolean {
return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
}
const WRAPPER_NAMES = new Set(["memo", "forwardRef", "lazy"]);
function isWrapperCall(node: AstExpression): boolean {
if (node.type !== "CallExpression") return false;
const call = node as CallExpression;
const callee = call.callee;
if (callee.type === "Identifier") {
return WRAPPER_NAMES.has((callee as Identifier).name);
}
if (callee.type === "MemberExpression") {
const member = callee as MemberExpression;
return member.property.type === "Identifier" && WRAPPER_NAMES.has(member.property.name);
}
return false;
}
function extractComponentNames(ast: AstProgram): Array<string> {
const names: Array<string> = [];
for (const node of ast.body) {
if (node.type === "FunctionDeclaration" && node.id && PASCAL_CASE.test(node.id.name)) {
names.push(node.id.name);
continue;
}
if (node.type === "ExportNamedDeclaration" && node.declaration) {
const decl = node.declaration;
if (decl.type === "FunctionDeclaration" && decl.id && PASCAL_CASE.test(decl.id.name)) {
names.push(decl.id.name);
continue;
}
if (decl.type === "VariableDeclaration") {
collectNamesFromVarDeclaration(decl, names);
continue;
}
}
if (node.type === "VariableDeclaration") {
collectNamesFromVarDeclaration(node, names);
}
}
return names;
}
function collectNamesFromVarDeclaration(node: AstStatement, names: Array<string>): void {
for (const declarator of node.declarations ?? []) {
if (!declarator.id || !PASCAL_CASE.test(declarator.id.name) || !declarator.init) continue;
const init = declarator.init;
if (isFunctionLike(init)) {
names.push(declarator.id.name);
} else if (isWrapperCall(init)) {
const args = (init as CallExpression).arguments;
if (args.length > 0 && isFunctionLike(args[0])) {
names.push(declarator.id.name);
}
}
}
}
// --- Source measurement ---
function measureComponentInSource(name: string, lines: Array<string>): ComponentInfo | null {
const fnPattern = new RegExp(`^(?:export\\s+)?function\\s+${name}\\s*[(<]`);
const varPattern = new RegExp(`^(?:export\\s+)?const\\s+${name}\\s*[=:]`);
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trimStart();
const isFnDecl = fnPattern.test(trimmed);
const isVarDecl = varPattern.test(trimmed);
if (!isFnDecl && !isVarDecl) continue;
if (isFnDecl) {
const result = measureFromParams(i, lines);
if (result) return { ...result, name };
return null;
}
const result = measureFromArrow(i, lines);
if (result) return { ...result, name };
return null;
}
return null;
}
// function Foo(...) { ... } — skip params via parens, then brace-match the body
function measureFromParams(startLine: number, lines: Array<string>): ComponentInfo | null {
let parenDepth = 0;
let pastParams = false;
let braceDepth = 0;
for (let j = startLine; j < lines.length; j++) {
for (const ch of lines[j]) {
if (!pastParams) {
if (ch === "(") parenDepth++;
else if (ch === ")") {
parenDepth--;
if (parenDepth === 0) pastParams = true;
}
} else {
if (ch === "{") braceDepth++;
else if (ch === "}") {
braceDepth--;
if (braceDepth === 0) {
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
}
}
}
}
}
return null;
}
// const Foo = (...) => { ... } / const Foo = memo((...) => { ... })
// Find `=>` first, then brace-match from there to skip type annotations in params
function measureFromArrow(startLine: number, lines: Array<string>): ComponentInfo | null {
let arrowFound = false;
let braceDepth = 0;
let foundBrace = false;
for (let j = startLine; j < lines.length; j++) {
const line = lines[j];
for (let c = 0; c < line.length; c++) {
if (!arrowFound) {
if (line[c] === "=" && line[c + 1] === ">") {
arrowFound = true;
c++;
}
continue;
}
if (line[c] === "{") {
braceDepth++;
foundBrace = true;
} else if (line[c] === "}") {
braceDepth--;
if (foundBrace && braceDepth === 0) {
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
}
}
}
}
return null;
}
// --- Config resolution ---
function createLimitResolver(options: LimitLineOptions): (id: string) => ResolvedLimits {
const matchers = options.overrides.map((override) => ({
match: createFilter(override.files),
maxReactFCLines: override.maxReactFCLines,
maxFileLines: override.maxFileLines,
}));
return (id: string): ResolvedLimits => {
let maxReactFCLines: number | null = options.maxReactFCLines;
let maxFileLines: number | null = options.maxFileLines;
for (const matcher of matchers) {
if (matcher.match(id)) {
maxReactFCLines = matcher.maxReactFCLines;
maxFileLines = matcher.maxFileLines;
}
}
return { maxReactFCLines, maxFileLines };
};
}
function shouldProcess(id: string, options: LimitLineOptions): boolean {
return options.include.test(id) && !id.includes("node_modules") && (options.exclude === null || !options.exclude.test(id));
}
// --- Plugin ---
function viteLimitLinePlugin(
userOptions: Partial<LimitLineOptions> = {},
): Array<Plugin> {
const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] };
const resolve = createLimitResolver(options);
const rawCodeCache = new Map<string, string>();
return [
{
name: "vite-plugin-limit-line:pre",
enforce: "pre",
transform(code, id) {
if (!shouldProcess(id, options)) return null;
rawCodeCache.set(id, code);
const limits = resolve(id);
if (limits.maxFileLines === null) return null;
const totalLines = code.split("\n").length;
if (totalLines > limits.maxFileLines) {
this.error(
[
`[vite-limit-line] File too long: ${totalLines} lines (limit: ${limits.maxFileLines})`,
` file: ${id}`,
"",
"How to fix:",
" Split this file into smaller modules — extract related types, helpers,",
" or sub-components into separate files and re-export from an index.ts.",
].join("\n"),
);
}
return null;
},
},
{
name: "vite-plugin-limit-line:fc",
transform(code, id) {
if (!shouldProcess(id, options)) return null;
const limits = resolve(id);
if (limits.maxReactFCLines === null) return null;
const ast = this.parse(code) as unknown as AstProgram;
const componentNames = extractComponentNames(ast);
if (componentNames.length === 0) return null;
const raw = rawCodeCache.get(id) ?? code;
rawCodeCache.delete(id);
const rawLines = raw.split("\n");
const maxFCLines = limits.maxReactFCLines;
const violations: Array<ComponentInfo> = [];
for (const name of componentNames) {
const info = measureComponentInSource(name, rawLines);
if (info && info.lineCount > maxFCLines) {
violations.push(info);
}
}
if (violations.length > 0) {
const details = violations
.map(
(v) =>
` ${v.name} (line ${v.startLine}): ${v.lineCount} lines (limit: ${maxFCLines})`,
)
.join("\n");
this.error(
[
`[vite-limit-line] React component too long in ${id}:`,
details,
"",
"How to fix:",
" Break each oversized component into smaller ones. Extract reusable",
" sections into child components, move complex logic into custom hooks,",
" and keep each component focused on a single responsibility.",
].join("\n"),
);
}
return null;
},
buildEnd() {
rawCodeCache.clear();
},
},
];
}
export { viteLimitLinePlugin };
export type { LimitLineOptions, LimitLineOverride };
+49 -12
View File
@@ -1,38 +1,75 @@
import { useState } from "react";
import { Navigate, Outlet, useParams } from "react-router";
import { clearApiKey, hasApiKey } from "./api.ts";
import { LoginPage } from "./components/login.tsx";
import { RunDialog } from "./components/run-dialog.tsx";
import { Sidebar } from "./components/sidebar.tsx";
import { StatusBar } from "./components/status-bar.tsx";
import { useTheme } from "./hooks/use-theme.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { WorkflowDetail } from "./components/workflow-detail.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
import { useHashRoute } from "./use-hash-route.ts";
export function Layout() {
export function App() {
const [authed, setAuthed] = useState(hasApiKey());
const { client } = useParams();
const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } =
useHashRoute();
const [showRun, setShowRun] = useState(false);
const { theme, toggleTheme } = useTheme();
if (!authed) {
return <Navigate to="/login" replace />;
return <LoginPage onLogin={() => setAuthed(true)} />;
}
return (
<div className="flex h-screen bg-background">
<div className="flex h-screen">
<Sidebar
view={view}
client={client}
onViewChange={setView}
onClientChange={setClient}
onLogout={() => {
clearApiKey();
setAuthed(false);
}}
theme={theme}
onToggleTheme={toggleTheme}
/>
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
<StatusBar client={client} onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
<Outlet />
{!client && (
<div className="flex items-center justify-center h-full">
<p style={{ color: "var(--color-text-muted)" }}>
Select an client from the sidebar to get started.
</p>
</div>
)}
{client && view === "threads" && threadId === null && (
<ThreadList client={client} onSelect={setThreadId} />
)}
{client && view === "threads" && threadId !== null && (
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
)}
{client && view === "workflows" && workflowName === null && (
<WorkflowList client={client} onSelect={setWorkflowName} />
)}
{client && view === "workflows" && workflowName !== null && (
<WorkflowDetail
client={client}
workflowName={workflowName}
onBack={() => setWorkflowName(null)}
/>
)}
</div>
</main>
{client && <RunDialog client={client} open={showRun} onOpenChange={setShowRun} />}
{showRun && client && (
<RunDialog
client={client}
onClose={() => setShowRun(false)}
onCreated={(id) => {
setShowRun(false);
setThreadId(id);
}}
/>
)}
</div>
);
}
@@ -1,31 +0,0 @@
import { Loader2, Users } from "lucide-react";
import { Navigate } from "react-router";
import { listClients } from "../api.ts";
import { useFetch } from "../hooks.ts";
export function ClientRedirect() {
const { status, data } = useFetch(() => listClients(), []);
if (status === "loading") {
return (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading clients...</p>
</div>
);
}
if (status === "ok" && data.length > 0) {
return <Navigate to={`/${data[0].name}/threads`} replace />;
}
return (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Users className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm font-medium">No client selected</p>
<p className="text-xs text-muted-foreground">
Select a client from the sidebar to get started.
</p>
</div>
);
}
@@ -1,18 +1,14 @@
import { AlertCircle, Loader2, Moon, Settings, Sun } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { setApiKey } from "../api.ts";
import { useTheme } from "../hooks/use-theme.tsx";
import { Button } from "./ui/button.tsx";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card.tsx";
import { Input } from "./ui/input.tsx";
export function LoginPage() {
const navigate = useNavigate();
type Props = {
onLogin: () => void;
};
export function LoginPage({ onLogin }: Props) {
const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { theme, toggleTheme } = useTheme();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -21,6 +17,7 @@ export function LoginPage() {
setLoading(true);
setError(null);
// Test the key by hitting the endpoints list
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
try {
const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, {
@@ -43,59 +40,56 @@ export function LoginPage() {
}
setApiKey(key.trim());
navigate("/", { replace: true });
onLogin();
}
return (
<div className="min-h-screen flex items-center justify-center bg-background relative">
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 transition-colors duration-200"
onClick={toggleTheme}
<div
className="min-h-screen flex items-center justify-center"
style={{ background: "var(--color-bg)" }}
>
<div
className="p-8 rounded-lg border w-full max-w-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</Button>
<Card className="w-full max-w-sm shadow-lg transition-all duration-200 hover:shadow-xl hover:border-primary/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl tracking-tight">
<Settings className="h-5 w-5" />
Workflow Dashboard
</CardTitle>
<CardDescription>Enter your API key to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API Key"
className="transition-all duration-200"
/>
{error && (
<p className="text-xs text-destructive flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{error}
</p>
)}
<Button
type="submit"
disabled={loading || !key.trim()}
className="w-full transition-all duration-200"
>
{loading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Verifying
</span>
) : (
"Login"
)}
</Button>
</form>
</CardContent>
</Card>
<h1 className="text-xl font-bold mb-1" style={{ color: "var(--color-accent)" }}>
Workflow Dashboard
</h1>
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
Enter your API key to continue
</p>
<form onSubmit={handleSubmit}>
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API Key"
className="w-full px-3 py-2 rounded border text-sm mb-3 outline-none"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
/>
{error && (
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !key.trim()}
className="w-full px-3 py-2 rounded text-sm font-medium"
style={{
background: "var(--color-accent)",
color: "var(--color-bg)",
opacity: loading || !key.trim() ? 0.5 : 1,
}}
>
{loading ? "Verifying..." : "Login"}
</button>
</form>
</div>
</div>
);
}
@@ -52,23 +52,19 @@ function CodeBlock({ className, children }: { className?: string; children?: Rea
if (html !== null) {
return (
<div className="relative rounded-lg border border-border overflow-hidden my-3">
{lang !== "text" && (
<span className="absolute top-2 right-2 text-[10px] uppercase tracking-wider text-muted-foreground/70 font-mono">
{lang}
</span>
)}
<div
className="overflow-x-auto text-xs"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
<div
className="rounded overflow-x-auto text-xs my-2"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
return (
<pre className="rounded-lg overflow-x-auto text-xs my-3 p-3 bg-muted/50 border border-border">
<pre
className="rounded overflow-x-auto text-xs my-2 p-3"
style={{ background: "var(--color-bg)" }}
>
<code>{code}</code>
</pre>
);
@@ -84,7 +80,8 @@ export function Markdown({ content }: { content: string }) {
if (isInline) {
return (
<code
className="bg-muted rounded px-1.5 py-0.5 text-[13px] font-mono text-foreground"
className="text-xs px-1 py-0.5 rounded"
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
{...props}
>
{children}
@@ -94,7 +91,7 @@ export function Markdown({ content }: { content: string }) {
return <CodeBlock className={className}>{children}</CodeBlock>;
},
p({ children }) {
return <p className="my-2 leading-relaxed">{children}</p>;
return <p className="my-1.5 leading-relaxed">{children}</p>;
},
ul({ children }) {
return <ul className="list-disc pl-4 my-1.5">{children}</ul>;
@@ -103,25 +100,20 @@ export function Markdown({ content }: { content: string }) {
return <ol className="list-decimal pl-4 my-1.5">{children}</ol>;
},
h1({ children }) {
return (
<h1 className="text-lg font-bold mt-3 mb-2 border-b border-border pb-1">
{children}
</h1>
);
return <h1 className="text-lg font-bold mt-3 mb-1">{children}</h1>;
},
h2({ children }) {
return (
<h2 className="text-base font-bold mt-2 mb-2 border-b border-border pb-1">
{children}
</h2>
);
return <h2 className="text-base font-bold mt-2 mb-1">{children}</h2>;
},
h3({ children }) {
return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>;
},
blockquote({ children }) {
return (
<blockquote className="border-l-2 border-ring pl-3 my-2 text-sm text-muted-foreground bg-muted/30 rounded-r-md py-2">
<blockquote
className="border-l-2 pl-3 my-2 text-sm"
style={{ borderColor: "var(--color-accent)", color: "var(--color-text-muted)" }}
>
{children}
</blockquote>
);
@@ -1,26 +1,14 @@
import { CheckCircle2, Clock, MessageSquare, Rocket, User, XCircle } from "lucide-react";
import type { RoleRecord, ThreadRecord, ThreadStartRecord, WorkflowResultRecord } from "../api.ts";
import { cn } from "../lib/utils.ts";
import { Markdown } from "./markdown.tsx";
import { Badge } from "./ui/badge.tsx";
import { Card } from "./ui/card.tsx";
const ROLE_HUES = [262, 210, 35, 150, 330, 180, 15, 280, 55, 195, 345, 120, 240, 75, 305];
const ROLE_COLORS: Record<string, string> = {
preparer: "#8b5cf6",
client: "#3b82f6",
extractor: "#f59e0b",
};
function roleHue(role: string): number {
let hash = 0;
for (let i = 0; i < role.length; i++) {
hash = (hash * 31 + role.charCodeAt(i)) | 0;
}
return ROLE_HUES[Math.abs(hash) % ROLE_HUES.length];
}
function roleBadgeStyle(role: string): { backgroundColor: string; borderColor: string } {
const hue = roleHue(role);
return {
backgroundColor: `oklch(0.58 0.12 ${hue} / 0.85)`,
borderColor: `oklch(0.58 0.12 ${hue} / 0.25)`,
};
function roleColor(role: string): string {
return ROLE_COLORS[role] ?? "var(--color-accent)";
}
function formatTime(ts: number | null): string | null {
@@ -30,86 +18,99 @@ function formatTime(ts: number | null): string | null {
function StartCard({ record }: { record: ThreadStartRecord }) {
return (
<Card className="p-4 transition-all duration-200 overflow-hidden relative">
<div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-primary/80 via-primary/40 to-transparent" />
<div
className="p-4 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-2">
<Rocket className="h-5 w-5 text-primary" />
<span className="font-semibold text-foreground">{record.workflow}</span>
<Badge variant={record.status === "active" ? "success" : "secondary"}>
<span className="text-lg">🚀</span>
<span className="font-semibold" style={{ color: "var(--color-accent)" }}>
{record.workflow}
</span>
<span
className="text-xs px-2 py-0.5 rounded"
style={{
background: record.status === "active" ? "var(--color-success)" : "var(--color-border)",
color: record.status === "active" ? "var(--color-bg)" : "var(--color-text-muted)",
}}
>
{record.status}
</Badge>
</span>
</div>
{record.prompt !== null && (
<div className="mt-2 p-3 rounded-md text-sm border-l-2 border-ring bg-muted/50">
<div className="text-xs mb-1 text-muted-foreground flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
<div
className="mt-2 p-3 rounded text-sm border-l-2"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-accent)",
color: "var(--color-text)",
}}
>
<div className="text-xs mb-1" style={{ color: "var(--color-text-muted)" }}>
Prompt
</div>
<Markdown content={record.prompt} />
</div>
)}
</Card>
</div>
);
}
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
const style = roleBadgeStyle(record.role);
const color = roleColor(record.role);
return (
<Card
className={cn(
"p-3 text-sm transition-all duration-200 border-l-4",
highlighted && "wf-record-card-highlight",
)}
style={{ borderLeftColor: style.borderColor }}
<div
className={`p-3 rounded-lg border text-sm ${highlighted ? "wf-record-card-highlight" : ""}`}
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-2">
<span
className="text-xs px-2 py-0.5 rounded font-mono font-medium text-white shadow-sm inline-flex items-center gap-1"
style={{ backgroundColor: style.backgroundColor }}
className="text-xs px-2 py-0.5 rounded font-mono font-medium"
style={{ background: color, color: "#fff" }}
>
<User className="h-3 w-3" />
{record.role}
</span>
{formatTime(record.timestamp) !== null && (
<span className="text-xs ml-auto text-muted-foreground tabular-nums flex items-center gap-1">
<Clock className="h-3 w-3" />
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{formatTime(record.timestamp)}
</span>
)}
</div>
<Markdown content={record.content} />
</Card>
</div>
);
}
function ResultCard({ record }: { record: WorkflowResultRecord }) {
const success = record.returnCode === 0;
return (
<Card
className={cn(
"p-4 transition-all duration-200 border-l-4",
success ? "border-l-success" : "border-l-destructive",
)}
<div
className="p-4 rounded-lg border"
style={{
background: "var(--color-surface)",
borderColor: success ? "var(--color-success)" : "var(--color-error)",
}}
>
<div className="flex items-center gap-2 mb-2">
{success ? (
<CheckCircle2 className="h-5 w-5 text-success" />
) : (
<XCircle className="h-5 w-5 text-destructive" />
)}
<span className="text-lg">{success ? "✅" : "❌"}</span>
<span className="font-semibold text-sm">{success ? "Completed" : "Failed"}</span>
<Badge variant="outline" className="font-mono">
<span
className="text-xs px-2 py-0.5 rounded font-mono"
style={{
background: success ? "var(--color-success)" : "var(--color-error)",
color: "#fff",
}}
>
exit {record.returnCode}
</Badge>
</span>
{formatTime(record.timestamp) !== null && (
<span className="text-xs ml-auto text-muted-foreground tabular-nums flex items-center gap-1">
<Clock className="h-3 w-3" />
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{formatTime(record.timestamp)}
</span>
)}
</div>
<Markdown content={record.content} />
</Card>
</div>
);
}
@@ -1,27 +1,14 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { listWorkflows, runThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { Button } from "./ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select.tsx";
import { Textarea } from "./ui/textarea.tsx";
type Props = {
client: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onClose: () => void;
onCreated: (threadId: string) => void;
};
export function RunDialog({ client, open, onOpenChange }: Props) {
const navigate = useNavigate();
export function RunDialog({ client, onClose, onCreated }: Props) {
const workflows = useFetch(() => listWorkflows(client), [client]);
const [workflow, setWorkflow] = useState("");
const [prompt, setPrompt] = useState("");
@@ -35,8 +22,7 @@ export function RunDialog({ client, open, onOpenChange }: Props) {
setError(null);
try {
const result = await runThread(client, workflow, prompt);
onOpenChange(false);
navigate(`/${client}/threads/${result.threadId}`);
onCreated(result.threadId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSubmitting(false);
@@ -44,54 +30,95 @@ export function RunDialog({ client, open, onOpenChange }: Props) {
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Run Thread</DialogTitle>
<DialogDescription>Start a new thread on {client}</DialogDescription>
</DialogHeader>
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ background: "rgba(0,0,0,0.6)" }}
>
<div
className="w-full max-w-lg p-6 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<h3 className="text-lg font-semibold mb-4">Run Thread on {client}</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="run-workflow" className="text-sm block mb-1.5 text-muted-foreground">
<label
htmlFor="run-workflow"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Workflow
</label>
<Select value={workflow} onValueChange={setWorkflow}>
<SelectTrigger>
<SelectValue placeholder="Select a workflow..." />
</SelectTrigger>
<SelectContent>
{workflows.status === "ok" &&
workflows.data.workflows.map((w) => (
<SelectItem key={w.name} value={w.name}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
<select
id="run-workflow"
value={workflow}
onChange={(e) => setWorkflow(e.target.value)}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
>
<option value="">Select a workflow...</option>
{workflows.status === "ok" &&
workflows.data.workflows.map((w) => (
<option key={w.name} value={w.name}>
{w.name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="run-prompt" className="text-sm block mb-1.5 text-muted-foreground">
<label
htmlFor="run-prompt"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Prompt
</label>
<Textarea
<textarea
id="run-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={4}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
placeholder="Enter the task prompt..."
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{error && (
<p className="text-sm" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border"
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
Cancel
</Button>
<Button type="submit" disabled={submitting || !workflow || !prompt}>
</button>
<button
type="submit"
disabled={submitting || !workflow || !prompt}
className="px-4 py-2 text-sm rounded"
style={{
background: submitting ? "var(--color-accent-dim)" : "var(--color-accent)",
color: "#fff",
opacity: !workflow || !prompt ? 0.5 : 1,
}}
>
{submitting ? "Starting..." : "Run"}
</Button>
</DialogFooter>
</button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</div>
);
}
@@ -1,131 +1,109 @@
import { Loader2, LogOut, Moon, Package, Sun, Zap } from "lucide-react";
import { useLocation, useNavigate, useParams } from "react-router";
import { useEffect } from "react";
import type { ClientEndpoint } from "../api.ts";
import { listClients } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { cn } from "../lib/utils.ts";
import { Button } from "./ui/button.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select.tsx";
import { Separator } from "./ui/separator.tsx";
type Props = {
view: "threads" | "workflows";
client: string | null;
onViewChange: (v: "threads" | "workflows") => void;
onClientChange: (a: string | null) => void;
onLogout: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
};
export function Sidebar({ onLogout, theme, onToggleTheme }: Props) {
const { client } = useParams();
const navigate = useNavigate();
const location = useLocation();
export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }: Props) {
const { status, data } = useFetch(() => listClients(), []);
const clients: ClientEndpoint[] = status === "ok" ? data : [];
const view = location.pathname.includes("/workflows") ? "workflows" : "threads";
// Auto-select first client when none is selected
useEffect(() => {
if (client === null && clients.length > 0) {
onClientChange(clients[0].name);
}
}, [client, clients, onClientChange]);
const viewItems = [
{ key: "threads" as const, label: "Threads", icon: Zap },
{ key: "workflows" as const, label: "Workflows", icon: Package },
{ key: "threads" as const, label: "Threads", icon: "⚡" },
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
];
return (
<aside className="w-56 border-r border-border flex flex-col bg-sidebar">
<div className="p-4 border-b border-primary/20">
<h1 className="text-xl font-bold text-foreground tracking-tight">Workflow</h1>
<p className="text-xs text-muted-foreground mt-0.5 tracking-wide uppercase">Dashboard</p>
<aside
className="w-56 border-r flex flex-col"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="p-4 border-b" style={{ borderColor: "var(--color-border)" }}>
<h1 className="text-lg font-semibold" style={{ color: "var(--color-accent)" }}>
Workflow
</h1>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
Dashboard
</p>
</div>
<div className="px-3 py-3">
{/* Client selector */}
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
<label
className="block text-xs font-medium mb-1.5 text-muted-foreground"
className="block text-xs font-medium mb-1"
style={{ color: "var(--color-text-muted)" }}
htmlFor="client-select"
>
Client
</label>
{status === "loading" ? (
<div className="h-9 rounded-md border border-input bg-transparent px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading
</div>
) : clients.length === 0 ? (
<div className="h-9 rounded-md border border-input bg-transparent px-3 py-2 text-xs text-muted-foreground flex items-center">
No clients online
</div>
) : (
<Select
value={client ?? ""}
onValueChange={(name) => {
if (name) navigate(`/${name}/${view}`);
}}
>
<SelectTrigger className="h-8 text-xs transition-colors duration-200">
<SelectValue placeholder="Select client…" />
</SelectTrigger>
<SelectContent>
{clients.map((a) => (
<SelectItem key={a.name} value={a.name} className="text-xs">
<span className="flex items-center gap-2">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
a.status === "online" ? "bg-success animate-pulse" : "bg-destructive",
)}
/>
{a.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<select
id="client-select"
className="w-full rounded px-2 py-1.5 text-xs"
style={{
background: "var(--color-bg)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
value={client ?? ""}
onChange={(e) => onClientChange(e.target.value || null)}
disabled={status === "loading"}
>
{status === "loading" ? (
<option value="">Loading</option>
) : clients.length === 0 ? (
<option value="">No clients online</option>
) : (
clients.map((a) => (
<option key={a.name} value={a.name}>
{a.status === "online" ? "🟢" : "🔴"} {a.name}
</option>
))
)}
</select>
</div>
<Separator />
{/* View navigation */}
<nav className="flex-1 p-2 space-y-1">
{viewItems.map((item) => (
<Button
<button
type="button"
key={item.key}
variant={view === item.key ? "secondary" : "ghost"}
size="sm"
className={cn(
"w-full justify-start gap-2 transition-colors duration-200",
view === item.key
? "text-foreground border-l-2 border-primary rounded-l-none"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => {
if (client) navigate(`/${client}/${item.key}`);
onClick={() => onViewChange(item.key)}
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
style={{
background: view === item.key ? "var(--color-accent-dim)" : "transparent",
color: view === item.key ? "#fff" : "var(--color-text-muted)",
}}
>
<item.icon className="h-4 w-4" />
{item.label}
</Button>
{item.icon} {item.label}
</button>
))}
</nav>
<Separator />
<div className="p-2 space-y-1">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
onClick={onToggleTheme}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{theme === "dark" ? "Light mode" : "Dark mode"}
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
<div className="p-2 border-t" style={{ borderColor: "var(--color-border)" }}>
<button
type="button"
onClick={onLogout}
className="w-full text-left px-3 py-2 rounded text-xs transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
<LogOut className="h-4 w-4" />
Logout
</Button>
🚪 Logout
</button>
</div>
</aside>
);
@@ -1,7 +1,5 @@
import { Loader2, Play, Wifi, WifiOff } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { getClientHealth } from "../api.ts";
import { Button } from "./ui/button.tsx";
type HealthStatus = "connected" | "disconnected" | "reconnecting";
@@ -10,29 +8,14 @@ type Props = {
onRun: () => void;
};
function StatusIndicator({ status }: { status: HealthStatus }) {
function statusLabel(status: HealthStatus): { text: string; color: string } {
if (status === "connected") {
return (
<span className="flex items-center gap-1.5 text-xs text-success transition-colors duration-200">
<Wifi className="h-3.5 w-3.5" />
Connected
</span>
);
return { text: "● Connected", color: "var(--color-success)" };
}
if (status === "reconnecting") {
return (
<span className="flex items-center gap-1.5 text-xs text-warning transition-colors duration-200">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Reconnecting
</span>
);
return { text: "● Reconnecting...", color: "var(--color-warning, #f59e0b)" };
}
return (
<span className="flex items-center gap-1.5 text-xs text-destructive transition-colors duration-200">
<WifiOff className="h-3.5 w-3.5" />
Offline
</span>
);
return { text: "● Offline", color: "var(--color-error)" };
}
export function StatusBar({ client, onRun }: Props) {
@@ -65,24 +48,32 @@ export function StatusBar({ client, onRun }: Props) {
return () => clearInterval(interval);
}, [checkHealth]);
const label = statusLabel(status);
return (
<div className="flex items-center justify-between px-6 py-2 text-xs border-b border-border bg-card/80 backdrop-blur-sm">
<div
className="flex items-center justify-between px-6 py-2 text-xs border-b"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="flex items-center gap-4">
<span className="text-muted-foreground">
<span style={{ color: "var(--color-text-muted)" }}>
{client ? `Client: ${client}` : "No client selected"}
</span>
<Button
variant="default"
size="sm"
disabled={!client}
<button
type="button"
onClick={onRun}
className="h-7 gap-1.5 transition-all duration-200"
disabled={!client}
className="px-3 py-1 rounded text-xs font-medium"
style={{
background: client ? "var(--color-accent)" : "var(--color-border)",
color: "#fff",
opacity: client ? 1 : 0.5,
}}
>
<Play className="h-3.5 w-3.5" />
Run Thread
</Button>
Run Thread
</button>
</div>
<StatusIndicator status={status} />
<span style={{ color: label.color }}>{label.text}</span>
</div>
);
}
@@ -1,6 +1,4 @@
import { AlertCircle, ArrowLeft, Layers, Loader2, Pause, Play, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import {
getThread,
getWorkflowDescriptor,
@@ -13,12 +11,14 @@ import {
import { useFetch } from "../hooks.ts";
import { useSSE } from "../use-sse.ts";
import { RecordCard } from "./record-card.tsx";
import { Badge } from "./ui/badge.tsx";
import { Button } from "./ui/button.tsx";
import { Card } from "./ui/card.tsx";
import { ResizablePanel } from "./ui/resizable-panel.tsx";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
type Props = {
client: string;
threadId: string;
onBack: () => void;
};
function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
for (const r of records) {
if (r.type === "thread-start") return r.workflow;
@@ -53,11 +53,36 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
return states;
}
export function ThreadDetail() {
const params = useParams();
const navigate = useNavigate();
const client = params.client as string;
const threadId = params.threadId as string;
function isClickableGraphNode(nodeStates: Map<string, NodeState>, nodeId: string): boolean {
const state = nodeStates.get(nodeId);
return state !== undefined && state !== "default";
}
function scrollToFirstRecord(): void {
const firstCard = document.querySelector('[data-record-index="0"]');
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
}
function scrollToRoleOccurrence(
nodeId: string,
indicesByRole: Map<string, number[]>,
clickCycleRef: { current: Map<string, number> },
onHighlight: (role: string) => void,
): void {
const indices = indicesByRole.get(nodeId);
if (indices === undefined || indices.length === 0) return;
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
const idx = indices[cycle % indices.length];
clickCycleRef.current.set(nodeId, cycle + 1);
const el = document.querySelector(`[data-record-index="${idx}"]`);
if (el === null) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
onHighlight(nodeId);
}
export function ThreadDetail({ client, threadId, onBack }: Props) {
const sse = useSSE(client, threadId);
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
const [actionStatus, setActionStatus] = useState<string | null>(null);
@@ -97,42 +122,32 @@ export function ThreadDetail() {
return m;
}, [records]);
// Track which occurrence to jump to next per role (cycling)
const clickCycleRef = useRef<Map<string, number>>(new Map());
const highlightRole = useCallback((role: string) => {
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(role);
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null);
highlightTimerRef.current = null;
}, 1500);
}, []);
const handleGraphNodeClick = useCallback(
(nodeId: string) => {
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
if (!isClickableGraphNode(nodeStates, nodeId)) return;
if (nodeId === "__start__") {
const firstCard = document.querySelector('[data-record-index="0"]');
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
scrollToFirstRecord();
return;
}
if (nodeId === "__end__") {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
return;
}
const indices = indicesByRole.get(nodeId);
if (indices === undefined || indices.length === 0) return;
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
const idx = indices[cycle % indices.length];
clickCycleRef.current.set(nodeId, cycle + 1);
const el = document.querySelector(`[data-record-index="${idx}"]`);
if (el !== null) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(nodeId);
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null);
highlightTimerRef.current = null;
}, 1500);
}
scrollToRoleOccurrence(nodeId, indicesByRole, clickCycleRef, highlightRole);
},
[nodeStates, indicesByRole],
[nodeStates, indicesByRole, highlightRole],
);
useEffect(() => {
@@ -151,7 +166,7 @@ export function ThreadDetail() {
try {
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
await fn(client, threadId);
setActionStatus(null);
setActionStatus(`${action} sent ✓`);
} catch (e) {
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
}
@@ -160,84 +175,88 @@ export function ThreadDetail() {
return (
<div>
<div className="flex items-center justify-between mb-4">
<Button
variant="ghost"
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
onClick={() => navigate(`/${client}/threads`)}
<button
type="button"
onClick={onBack}
className="text-sm hover:underline"
style={{ color: "var(--color-accent)" }}
>
<ArrowLeft className="h-4 w-4" />
Back to threads
</Button>
<div className="flex gap-1 rounded-lg border border-border bg-muted/30 p-1">
<Button
variant="outline"
size="sm"
className="transition-colors duration-200"
Back to threads
</button>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleAction("pause")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
>
<Pause className="h-3.5 w-3.5 text-warning" />
Pause
</Button>
<Button
variant="outline"
size="sm"
className="transition-colors duration-200"
Pause
</button>
<button
type="button"
onClick={() => handleAction("resume")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
>
<Play className="h-3.5 w-3.5 text-success" />
Resume
</Button>
<Button
variant="outline"
size="sm"
className="transition-colors duration-200"
Resume
</button>
<button
type="button"
onClick={() => handleAction("kill")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
>
<X className="h-3.5 w-3.5 text-destructive" />
Kill
</Button>
Kill
</button>
</div>
</div>
<h2 className="text-xl font-semibold mb-2 font-mono tracking-tight flex items-center gap-2 flex-wrap">
<h2 className="text-xl font-semibold mb-2 font-mono flex items-center gap-2 flex-wrap">
<span>{threadId}</span>
{sse.connected && !sse.completed && (
<Badge variant="success" className="animate-pulse flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-success-foreground" />
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
>
Live
</Badge>
</span>
)}
</h2>
{actionStatus && (
<Badge variant="secondary" className="mb-4 text-xs font-normal">
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
{actionStatus}
</Badge>
</p>
)}
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
{descriptor !== null && descriptor.graph.edges.length > 0 && (
<ResizablePanel
defaultWidth={360}
minWidth={240}
maxWidth={560}
className={null}
<div
className="shrink-0"
style={{
width: 280,
position: "sticky",
top: 16,
height: "calc(100vh - 120px)",
alignSelf: "flex-start",
}}
>
<Card className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 text-xs text-muted-foreground bg-muted/50 border-b border-border">
<span className="font-mono flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
<div
className="rounded-lg border h-full flex flex-col overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<span className="font-mono">
Workflow graph
{workflowName !== null && (
<span className="ml-2 text-foreground">{workflowName}</span>
<span className="ml-2" style={{ color: "var(--color-text)" }}>
{workflowName}
</span>
)}
</span>
<span className="tabular-nums">
<span>
{descriptor.graph.edges.length} edge
{descriptor.graph.edges.length === 1 ? "" : "s"}
</span>
@@ -250,25 +269,19 @@ export function ThreadDetail() {
onNodeClick={handleGraphNodeClick}
/>
</div>
</Card>
</ResizablePanel>
</div>
</div>
)}
<div className="flex-1 min-w-0">
{status === "loading" && !liveActive && records.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-3">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-sm">Loading thread...</span>
</div>
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
)}
{status === "error" && !liveActive && (
<div className="flex items-center gap-2 py-8 justify-center text-destructive">
<AlertCircle className="h-5 w-5" />
<span className="text-sm">Error: {error}</span>
</div>
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
)}
{(status === "ok" || liveActive || records.length > 0) && (
<div className="border-l-2 border-border ml-2 pl-4 space-y-3">
<div className="space-y-3">
{records.map((r, i) => {
const key = `${threadId}-${i}`;
if (r.type === "role") {
@@ -279,21 +292,18 @@ export function ThreadDetail() {
<div
key={key}
data-record-index={i}
className="relative"
ref={(el) => {
if (!isFirstForRole) return;
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
else firstCardByRoleRef.current.delete(r.role);
}}
>
<div className="absolute -left-[1.3rem] top-4 h-2.5 w-2.5 rounded-full border-2 border-border bg-background" />
<RecordCard record={r} highlighted={flash} />
</div>
);
}
return (
<div key={key} data-record-index={i} className="relative">
<div className="absolute -left-[1.3rem] top-4 h-2.5 w-2.5 rounded-full border-2 border-border bg-background" />
<div key={key} data-record-index={i}>
<RecordCard record={r} highlighted={false} />
</div>
);
@@ -1,37 +1,17 @@
import { AlertCircle, Clock, Loader2, Workflow, Zap } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { listThreads } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { Badge } from "./ui/badge.tsx";
import { Card } from "./ui/card.tsx";
function statusVariant(status: string): "success" | "destructive" | "secondary" {
if (status === "completed") return "success";
if (status === "failed") return "destructive";
return "secondary";
}
type Props = {
client: string;
onSelect: (id: string) => void;
};
export function ThreadList() {
const params = useParams();
const navigate = useNavigate();
const client = params.client as string;
export function ThreadList({ client, onSelect }: Props) {
const { status, data, error } = useFetch(() => listThreads(client), [client]);
if (status === "loading")
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading threads...</p>
</div>
);
if (status === "error")
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">Error: {error}</p>
</div>
);
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
const threads = [...data.threads].sort((a, b) => {
if (!a.startedAt && !b.startedAt) return 0;
@@ -42,44 +22,51 @@ export function ThreadList() {
return (
<div>
<h2 className="text-xl font-semibold tracking-tight mb-4">Threads</h2>
<h2 className="text-xl font-semibold mb-4">Threads</h2>
{threads.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Zap className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm font-medium">No threads</p>
<p className="text-xs text-muted-foreground">
Run a workflow to create your first thread.
</p>
</div>
<p style={{ color: "var(--color-text-muted)" }}>No threads found.</p>
) : (
<div className="space-y-2">
{threads.map((t) => (
<Card
<button
type="button"
key={t.threadId}
className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
onClick={() => navigate(`/${client}/threads/${t.threadId}`)}
onClick={() => onSelect(t.threadId)}
className="w-full text-left p-4 rounded-lg border transition-colors hover:border-[var(--color-accent-dim)]"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center justify-between">
<code className="font-mono text-sm text-foreground">{t.threadId}</code>
<code className="text-sm font-mono" style={{ color: "var(--color-accent)" }}>
{t.threadId}
</code>
{t.status && (
<Badge variant={statusVariant(t.status)} className="text-xs">
<span
className="text-xs px-2 py-0.5 rounded"
style={{
background:
t.status === "completed"
? "var(--color-success)"
: t.status === "failed"
? "var(--color-error)"
: "var(--color-accent)",
color: "#000",
}}
>
{t.status}
</Badge>
</span>
)}
</div>
{t.workflow && (
<p className="text-sm mt-1 font-medium text-foreground flex items-center gap-1.5">
<Workflow className="h-3.5 w-3.5 text-muted-foreground" />
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
{t.workflow}
</p>
)}
{t.startedAt && (
<p className="text-xs mt-1 text-muted-foreground flex items-center gap-1.5">
<Clock className="h-3 w-3" />
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
{t.startedAt}
</p>
)}
</Card>
</button>
))}
</div>
)}
@@ -1,30 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority";
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
outline: "text-foreground",
success: "border-transparent bg-success text-success-foreground shadow",
warning: "border-transparent bg-warning text-warning-foreground shadow",
},
},
defaultVariants: {
variant: "default",
},
},
);
export type BadgeProps = HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>;
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
@@ -1,45 +0,0 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type { ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
success: "border border-success text-success hover:bg-success/10",
warning: "border border-warning text-warning hover:bg-warning/10",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
@@ -1,36 +0,0 @@
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
}
function CardTitle({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
}
function CardDescription({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
}
function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
}
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
@@ -1,7 +0,0 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
@@ -1,104 +0,0 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import type { ComponentPropsWithoutRef, HTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
function DialogOverlay({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -1,17 +0,0 @@
import type { InputHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Input({ className, type, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Input };
@@ -1,73 +0,0 @@
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useCallback,
useRef,
useState,
} from "react";
import { cn } from "../../lib/utils.ts";
type Props = {
defaultWidth: number;
minWidth: number;
maxWidth: number;
className: string | null;
style: CSSProperties | null;
children: React.ReactNode;
};
export function ResizablePanel({
defaultWidth,
minWidth,
maxWidth,
className,
style,
children,
}: Props) {
const [width, setWidth] = useState(defaultWidth);
const dragging = useRef(false);
const startX = useRef(0);
const startW = useRef(0);
const onPointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
dragging.current = true;
startX.current = e.clientX;
startW.current = width;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[width],
);
const onPointerMove = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
const delta = e.clientX - startX.current;
const next = Math.min(maxWidth, Math.max(minWidth, startW.current + delta));
setWidth(next);
},
[minWidth, maxWidth],
);
const onPointerUp = useCallback(() => {
dragging.current = false;
}, []);
return (
<div
className={cn("relative shrink-0", className)}
style={{ ...style, width }}
>
{children}
<div
className="absolute top-0 -right-1 w-2 h-full cursor-col-resize z-10 group"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div className="absolute inset-y-0 left-1/2 w-px bg-border opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
</div>
</div>
);
}
@@ -1,42 +0,0 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
function ScrollArea({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
@@ -1,148 +0,0 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
function SelectTrigger({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectScrollUpButton({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -1,25 +0,0 @@
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
);
}
export { Separator };
@@ -1,69 +0,0 @@
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>) {
return (
<div className="relative w-full overflow-auto">
<table className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
}
function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<tfoot
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={cn(
"border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
return (
<td
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: HTMLAttributes<HTMLTableCaptionElement>) {
return <caption className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />;
}
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow };
@@ -1,16 +0,0 @@
import type { TextareaHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Textarea };
@@ -1,28 +0,0 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
function TooltipContent({
className,
sideOffset = 4,
...props
}: ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
@@ -1,50 +1,22 @@
import {
AlertCircle,
ArrowLeft,
ChevronDown,
GitBranch,
Hash,
Layers,
Loader2,
User,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts";
import { getWorkflowDetail } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { cn } from "../lib/utils.ts";
import { Markdown } from "./markdown.tsx";
import { Button } from "./ui/button.tsx";
import { Card } from "./ui/card.tsx";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible.tsx";
import { ResizablePanel } from "./ui/resizable-panel.tsx";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table.tsx";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
const ROLE_BORDER_COLORS = [
"border-l-blue-400/60",
"border-l-emerald-400/60",
"border-l-amber-400/60",
"border-l-violet-400/60",
"border-l-rose-400/60",
"border-l-cyan-400/60",
"border-l-orange-400/60",
"border-l-teal-400/60",
];
function roleBorderColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) | 0;
}
return ROLE_BORDER_COLORS[Math.abs(hash) % ROLE_BORDER_COLORS.length];
}
type Props = {
client: string;
workflowName: string;
onBack: () => void;
};
function versionCount(detail: WorkflowDetailData): number {
return detail.history.length + 1;
}
// ── Schema rendering helpers ────────────────────────────────────────
type SchemaRow = {
key: string;
name: string;
@@ -67,66 +39,119 @@ function resolveType(prop: Record<string, unknown>): string {
return String(prop.type ?? "unknown");
}
function variantLabel(
variantProps: Record<string, Record<string, unknown>>,
variantIndex: number,
): string {
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) return `${pName}: ${String(pDef.const)}`;
}
return `Variant ${variantIndex + 1}`;
}
function childPrefixForDepth(depth: number, parentPrefix: string): string {
return depth > 0 ? `${parentPrefix} ` : " ";
}
function flattenOneOfVariants(
oneOf: Array<Record<string, unknown>>,
depth: number,
parentPrefix: string,
keyPrefix: string,
): SchemaRow[] {
const rows: SchemaRow[] = [];
for (let vi = 0; vi < oneOf.length; vi++) {
const variant = oneOf[vi];
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
const isLast = vi === oneOf.length - 1;
const connector = isLast ? "└" : "├";
rows.push({
key: `${keyPrefix}variant-${vi}`,
name: `${parentPrefix}${connector} ${variantLabel(variantProps, vi)}`,
type: "",
description: "",
depth,
prefix: parentPrefix,
isVariantHeader: true,
});
const variantChildPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
const variantRequired = new Set<string>(
Array.isArray(variant.required) ? (variant.required as string[]) : [],
);
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) continue;
rows.push(
...flattenProperty(
pName,
pDef,
depth + 1,
variantChildPrefix,
`${keyPrefix}v${vi}-`,
variantRequired,
),
);
}
}
return rows;
}
function flattenSchemaProperties(
schema: Record<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
): SchemaRow[] {
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>(
Array.isArray(schema.required) ? (schema.required as string[]) : [],
);
const rows: SchemaRow[] = [];
for (const [name, prop] of Object.entries(props)) {
rows.push(...flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required));
}
return rows;
}
function flattenSchema(
schema: Record<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
): SchemaRow[] {
const rows: SchemaRow[] = [];
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
if (Array.isArray(oneOf) && oneOf.length > 0) {
for (let vi = 0; vi < oneOf.length; vi++) {
const variant = oneOf[vi];
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
let variantLabel = `Variant ${vi + 1}`;
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) {
variantLabel = `${pName}: ${String(pDef.const)}`;
break;
}
}
const isLast = vi === oneOf.length - 1;
const connector = isLast ? "└" : "├";
rows.push({
key: `${keyPrefix}variant-${vi}`,
name: `${parentPrefix}${connector} ${variantLabel}`,
type: "",
description: "",
depth,
prefix: parentPrefix,
isVariantHeader: true,
});
const childPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
const variantRequired = new Set<string>(
Array.isArray(variant.required) ? (variant.required as string[]) : [],
);
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) continue;
const subRows = flattenProperty(
pName,
pDef,
depth + 1,
childPrefix,
`${keyPrefix}v${vi}-`,
variantRequired,
);
rows.push(...subRows);
}
}
return rows;
return flattenOneOfVariants(oneOf, depth, parentPrefix, keyPrefix);
}
return flattenSchemaProperties(schema, depth, parentPrefix, keyPrefix);
}
function flattenNestedPropertyRows(
name: string,
prop: Record<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
hasOneOf: boolean,
): SchemaRow[] {
const childPrefix = childPrefixForDepth(depth, parentPrefix);
const nestedKeyPrefix = `${keyPrefix}${name}-`;
if (prop.type === "object" && prop.properties !== undefined) {
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
}
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>(
Array.isArray(schema.required) ? (schema.required as string[]) : [],
);
for (const [name, prop] of Object.entries(props)) {
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
rows.push(...subRows);
if (prop.type === "array") {
const items = prop.items as Record<string, unknown> | undefined;
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
return flattenSchema(items, depth + 1, childPrefix, nestedKeyPrefix);
}
}
return rows;
if (hasOneOf) {
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
}
return [];
}
function flattenProperty(
@@ -137,145 +162,139 @@ function flattenProperty(
keyPrefix: string,
required: Set<string>,
): SchemaRow[] {
const rows: SchemaRow[] = [];
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
if (!required.has(name)) type += "?";
const description = String(prop.description ?? "");
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
rows.push({
key: `${keyPrefix}${name}`,
name: displayName,
type,
description,
depth,
prefix: parentPrefix,
isVariantHeader: false,
});
if (prop.type === "object" && prop.properties !== undefined) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(
...flattenSchema(
prop as Record<string, unknown>,
depth + 1,
childPrefix,
`${keyPrefix}${name}-`,
),
);
}
if (prop.type === "array") {
const items = prop.items as Record<string, unknown> | undefined;
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`));
}
}
if (hasOneOf) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(
...flattenSchema(
prop as Record<string, unknown>,
depth + 1,
childPrefix,
`${keyPrefix}${name}-`,
),
);
}
const rows: SchemaRow[] = [
{
key: `${keyPrefix}${name}`,
name: depth > 0 ? `${parentPrefix}└─ ${name}` : name,
type,
description: String(prop.description ?? ""),
depth,
prefix: parentPrefix,
isVariantHeader: false,
},
];
rows.push(...flattenNestedPropertyRows(name, prop, depth, parentPrefix, keyPrefix, hasOneOf));
return rows;
}
// ── Components ──────────────────────────────────────────────────────
function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDescriptor }) {
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`);
const [promptOpen, setPromptOpen] = useState(false);
return (
<Card id={`role-${roleName}`} className={cn("p-4 border-l-4", roleBorderColor(roleName))}>
<h4 className="text-sm font-semibold font-mono mb-1 text-foreground flex items-center gap-1.5">
<User className="h-3.5 w-3.5 text-muted-foreground" />
<div
id={`role-${roleName}`}
className="rounded-lg border p-4"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<h4 className="text-sm font-semibold font-mono mb-1" style={{ color: "var(--color-text)" }}>
{roleName}
</h4>
{role.description !== "" && (
<p className="text-xs mb-3 text-muted-foreground">{role.description}</p>
<p className="text-xs mb-3" style={{ color: "var(--color-text-muted)" }}>
{role.description}
</p>
)}
{role.systemPrompt !== "" && (
<Collapsible open={promptOpen} onOpenChange={setPromptOpen} className="mb-3">
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1 h-7 px-2 text-[10px] uppercase tracking-wider text-muted-foreground bg-muted/50 rounded-md transition-all duration-200"
>
<ChevronDown
className={cn("h-3 w-3 transition-transform", promptOpen && "rotate-180")}
/>
System Prompt
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-1 p-2 rounded-md overflow-y-auto text-xs bg-background border border-border max-h-[300px]">
<Markdown content={role.systemPrompt} />
</div>
</CollapsibleContent>
</Collapsible>
<details className="mb-3">
<summary
className="text-[10px] uppercase tracking-wider font-medium cursor-pointer select-none"
style={{ color: "var(--color-text-muted)" }}
>
System Prompt
</summary>
<div
className="mt-1 p-2 rounded overflow-y-auto text-xs"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
maxHeight: "300px",
}}
>
<Markdown content={role.systemPrompt} />
</div>
</details>
)}
{rows.length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wider mb-1 font-medium text-muted-foreground">
<p
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
style={{ color: "var(--color-text-muted)" }}
>
Meta Schema
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs">Field</TableHead>
<TableHead className="text-xs">Type</TableHead>
<TableHead className="text-xs">Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow
key={r.key}
className={cn(r.isVariantHeader ? "border-b-0" : "", "even:bg-muted/30")}
<table className="w-full text-xs" style={{ borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
<th
className="text-left py-1 pr-3 font-medium"
style={{ color: "var(--color-text-muted)" }}
>
<TableCell
className={cn(
"font-mono whitespace-pre text-xs",
r.isVariantHeader ? "italic text-muted-foreground" : "text-foreground",
)}
Field
</th>
<th
className="text-left py-1 pr-3 font-medium"
style={{ color: "var(--color-text-muted)" }}
>
Type
</th>
<th
className="text-left py-1 font-medium"
style={{ color: "var(--color-text-muted)" }}
>
Description
</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
key={r.key}
style={{
borderBottom: r.isVariantHeader ? "none" : "1px solid var(--color-border)",
}}
>
<td
className="py-1 pr-3 font-mono whitespace-pre"
style={{
color: r.isVariantHeader ? "var(--color-text-muted)" : "var(--color-accent)",
fontStyle: r.isVariantHeader ? "italic" : "normal",
}}
>
{r.name}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
</td>
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>
{r.type}
</TableCell>
<TableCell className="text-xs">
</td>
<td className="py-1" style={{ color: "var(--color-text)" }}>
{r.description || (r.isVariantHeader ? "" : "—")}
</TableCell>
</TableRow>
</td>
</tr>
))}
</TableBody>
</Table>
</tbody>
</table>
</div>
)}
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
<pre className="text-[10px] font-mono p-2 rounded-md overflow-x-auto bg-background text-muted-foreground">
<pre
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
>
{JSON.stringify(role.schema, null, 2)}
</pre>
)}
</Card>
</div>
);
}
export function WorkflowDetail() {
const params = useParams();
const navigate = useNavigate();
const client = params.client as string;
const workflowName = params.workflowName as string;
// ── Main component ──────────────────────────────────────────────────
export function WorkflowDetail({ client, workflowName, onBack }: Props) {
const { status, data, error } = useFetch(
() => getWorkflowDetail(client, workflowName),
[client, workflowName],
@@ -315,52 +334,44 @@ export function WorkflowDetail() {
return (
<div>
<div className="flex items-center justify-between mb-4">
<Button
variant="ghost"
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-all duration-200"
onClick={() => navigate(`/${client}/workflows`)}
<button
type="button"
onClick={onBack}
className="text-sm hover:underline"
style={{ color: "var(--color-accent)" }}
>
<ArrowLeft className="h-4 w-4" />
Back to workflows
</Button>
Back to workflows
</button>
</div>
<h2 className="text-xl font-semibold mb-4 font-mono tracking-tight">{workflowName}</h2>
<h2 className="text-xl font-semibold mb-4 font-mono">{workflowName}</h2>
{status === "loading" && (
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading workflow...</span>
</div>
)}
{status === "error" && (
<div className="flex items-center justify-center gap-2 py-12 text-destructive">
<AlertCircle className="h-5 w-5" />
<span>Error: {error}</span>
</div>
)}
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>}
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
{detail !== null && (
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}>
{/* Left: fixed graph sidebar */}
{hasGraph && (
<ResizablePanel
defaultWidth={360}
minWidth={240}
maxWidth={560}
className={null}
<div
className="shrink-0"
style={{
width: 280,
position: "sticky",
top: 16,
height: "calc(100vh - 160px)",
alignSelf: "flex-start",
}}
>
<Card className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 text-xs text-muted-foreground bg-muted/50">
<span className="font-mono flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
Workflow graph
</span>
<div
className="rounded-lg border h-full flex flex-col overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<span className="font-mono">Workflow graph</span>
<span>
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span>
@@ -373,44 +384,52 @@ export function WorkflowDetail() {
onNodeClick={handleGraphNodeClick}
/>
</div>
</Card>
</ResizablePanel>
</div>
</div>
)}
{/* Right: scrollable content */}
<div className="flex-1 min-w-0 space-y-4">
<Card className="p-4">
<div className="rounded-md bg-muted/30 px-3 py-2 mb-3">
<p className="text-sm whitespace-pre-wrap text-foreground">
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: "—"}
</p>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-mono">
<Hash className="h-3 w-3" />
<span className="text-foreground">{detail.hash}</span>
{/* Workflow overview */}
<div
className="rounded-lg border p-4"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<p
className="text-sm whitespace-pre-wrap mb-3"
style={{ color: "var(--color-text)" }}
>
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: "—"}
</p>
<div className="flex gap-4 text-xs" style={{ color: "var(--color-text-muted)" }}>
<span>
Hash:{" "}
<code className="font-mono" style={{ color: "var(--color-accent)" }}>
{detail.hash}
</code>
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
<GitBranch className="h-3 w-3" />
<span>
{versionCount(detail)} version{versionCount(detail) !== 1 ? "s" : ""}
</span>
{roleEntries.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
<User className="h-3 w-3" />
<span>
{roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
</span>
)}
</div>
</Card>
</div>
{/* Role cards */}
{roleEntries.map(([name, role]) => (
<div
key={name}
className={cn(
"rounded-lg transition-shadow duration-300",
highlightedRole === name && "ring-2 ring-ring",
)}
style={{
transition: "box-shadow 0.3s",
boxShadow: highlightedRole === name ? "0 0 0 2px var(--color-accent)" : "none",
borderRadius: 8,
}}
>
<RoleCard roleName={name} role={role} />
</div>
@@ -91,7 +91,7 @@ export function ConditionEdge(props: EdgeProps) {
defaultLabelY = result[2];
}
const stroke = "hsl(var(--ring))";
const stroke = "var(--color-accent)";
const label = isFallback ? "" : (edgeData?.condition ?? "");
// Use pre-computed label position if available, otherwise fall back to default
@@ -107,9 +107,9 @@ export function ConditionEdge(props: EdgeProps) {
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
color: "hsl(var(--foreground))",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text)",
whiteSpace: "nowrap",
zIndex: 10,
}}
@@ -1,23 +1,29 @@
import { Handle, type NodeProps, Position } from "@xyflow/react";
import { Check, Circle } from "lucide-react";
import type { RoleNodeData } from "./types.ts";
function borderColor(state: RoleNodeData["state"]): string {
switch (state) {
case "completed":
return "hsl(var(--success))";
return "var(--color-success)";
case "active":
return "hsl(var(--ring))";
return "var(--color-accent)";
default:
return "hsl(var(--border))";
return "var(--color-border)";
}
}
function stateIcon(state: RoleNodeData["state"]): string | null {
if (state === "completed") return "✓";
if (state === "active") return "●";
return null;
}
export function RoleNode(props: NodeProps) {
const data = props.data as RoleNodeData;
const icon = stateIcon(data.state);
const isActive = data.state === "active";
const handleStyle = {
background: "hsl(var(--muted-foreground))",
background: "var(--color-text-muted)",
width: 6,
height: 6,
border: "none",
@@ -29,9 +35,9 @@ export function RoleNode(props: NodeProps) {
style={{
width: 180,
height: 60,
background: "hsl(var(--card))",
background: "var(--color-surface)",
borderColor: borderColor(data.state),
color: "hsl(var(--foreground))",
color: "var(--color-text)",
display: "flex",
flexDirection: "column",
justifyContent: "center",
@@ -75,15 +81,19 @@ export function RoleNode(props: NodeProps) {
isConnectable={false}
/>
<div className="flex items-center gap-1.5 font-mono">
{data.state === "completed" && <Check className="h-3 w-3 text-success" />}
{data.state === "active" && <Circle className="h-3 w-3 fill-current text-ring" />}
{icon !== null && (
<span
style={{
color: data.state === "active" ? "var(--color-accent)" : "var(--color-success)",
}}
>
{icon}
</span>
)}
<span className="truncate">{data.label}</span>
</div>
{data.description !== "" && (
<div
className="text-[10px] truncate mt-0.5"
style={{ color: "hsl(var(--muted-foreground))" }}
>
<div className="text-[10px] truncate mt-0.5" style={{ color: "var(--color-text-muted)" }}>
{data.description}
</div>
)}
@@ -1,22 +1,21 @@
import { Handle, type NodeProps, Position } from "@xyflow/react";
import { Play, Square } from "lucide-react";
import type { TerminalNodeData } from "./types.ts";
function borderColor(state: TerminalNodeData["state"]): string {
switch (state) {
case "completed":
return "hsl(var(--success))";
return "var(--color-success)";
case "active":
return "hsl(var(--ring))";
return "var(--color-accent)";
default:
return "hsl(var(--border))";
return "var(--color-border)";
}
}
function bgColor(state: TerminalNodeData["state"]): string {
if (state === "completed") return "hsl(var(--success))";
if (state === "active") return "hsl(var(--ring))";
return "hsl(var(--card))";
if (state === "completed") return "var(--color-success)";
if (state === "active") return "var(--color-accent)";
return "var(--color-surface)";
}
export function TerminalNode(props: NodeProps) {
@@ -24,7 +23,7 @@ export function TerminalNode(props: NodeProps) {
const isStart = data.kind === "start";
const isActive = data.state === "active";
const handleStyle = {
background: "hsl(var(--muted-foreground))",
background: "var(--color-text-muted)",
width: 6,
height: 6,
border: "none",
@@ -32,16 +31,13 @@ export function TerminalNode(props: NodeProps) {
return (
<div
className={`rounded-full border-2 flex items-center justify-center ${isActive ? "wf-node-pulse" : ""} ${data.state !== "default" ? "cursor-pointer" : ""}`}
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""} ${data.state !== "default" ? "cursor-pointer" : ""}`}
style={{
width: 40,
height: 40,
background: bgColor(data.state),
borderColor: borderColor(data.state),
color:
data.state === "default"
? "hsl(var(--muted-foreground))"
: "hsl(var(--primary-foreground))",
color: data.state === "default" ? "var(--color-text-muted)" : "var(--color-bg)",
}}
title={isStart ? "Start" : "End"}
>
@@ -78,7 +74,7 @@ export function TerminalNode(props: NodeProps) {
/>
</>
)}
{isStart ? <Play className="h-3 w-3" /> : <Square className="h-3 w-3" />}
{isStart ? "▶" : "■"}
</div>
);
}
@@ -3,14 +3,13 @@ import {
type EdgeTypes,
MarkerType,
type Node,
type NodeMouseHandler,
type NodeTypes,
type OnNodeClick,
ReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useMemo } from "react";
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
import { useTheme } from "../../hooks/use-theme.tsx";
import { ConditionEdge } from "./condition-edge.tsx";
import { RoleNode } from "./role-node.tsx";
import { TerminalNode } from "./terminal-node.tsx";
@@ -40,12 +39,9 @@ function handleNodeClick(onNodeClick: (nodeId: string) => void, node: Node): voi
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
const { theme } = useTheme();
const onNodeClickHandler: NodeMouseHandler | undefined =
onNodeClick !== null
? (_e: React.MouseEvent, node: Node) => handleNodeClick(onNodeClick, node)
: undefined;
const onNodeClickHandler: OnNodeClick | undefined =
onNodeClick !== null ? (_e, node) => handleNodeClick(onNodeClick, node) : undefined;
const styledEdges = useMemo(
() =>
@@ -55,7 +51,7 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
type: MarkerType.ArrowClosed,
width: 14,
height: 14,
color: "hsl(var(--foreground))",
color: "var(--color-text)",
},
})),
[layout.edges],
@@ -76,10 +72,10 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
colorMode={theme}
style={{ background: "hsl(var(--background))" }}
colorMode="dark"
style={{ background: "var(--color-bg)" }}
>
<Background color="hsl(var(--border))" gap={20} size={1} />
<Background color="var(--color-border)" gap={20} size={1} />
</ReactFlow>
);
}
@@ -1,65 +1,54 @@
import { AlertCircle, Clock, Hash, Loader2, Package } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { Card } from "./ui/card.tsx";
export function WorkflowList() {
const params = useParams();
const navigate = useNavigate();
const client = params.client as string;
type Props = {
client: string;
onSelect: (name: string) => void;
};
export function WorkflowList({ client, onSelect }: Props) {
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
if (status === "loading")
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading workflows...</p>
</div>
);
if (status === "error")
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">Error: {error}</p>
</div>
);
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
const workflows = data.workflows;
return (
<div>
<h2 className="text-xl font-semibold tracking-tight mb-4">Workflows</h2>
<h2 className="text-xl font-semibold mb-4">Workflows</h2>
{workflows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Package className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm font-medium">No workflows</p>
<p className="text-xs text-muted-foreground">Register a workflow to get started.</p>
</div>
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
) : (
<div className="space-y-2">
{workflows.map((w) => (
<Card
<button
key={w.name}
className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
onClick={() => navigate(`/${client}/workflows/${w.name}`)}
type="button"
onClick={() => onSelect(w.name)}
className="w-full text-left p-4 rounded-lg border hover:opacity-90"
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
>
<span className="text-sm font-medium text-foreground flex items-center gap-1.5">
<Package className="h-3.5 w-3.5 text-muted-foreground" />
{w.name}
</span>
<code className="text-xs mt-1 font-mono text-muted-foreground flex items-center gap-1.5">
<Hash className="h-3 w-3" />
<div className="flex items-center gap-2">
<span className="font-medium">{w.name}</span>
</div>
<code
className="text-xs mt-1 block font-mono truncate"
style={{ color: "var(--color-accent)" }}
>
{w.hash !== null ? w.hash : "—"}
</code>
{w.timestamp !== null ? (
<span className="text-xs mt-1 text-muted-foreground flex items-center gap-1.5">
<Clock className="h-3 w-3" />
<span className="text-xs mt-1 block" style={{ color: "var(--color-text-muted)" }}>
Updated {new Date(w.timestamp).toLocaleString()}
</span>
) : null}
</Card>
</button>
))}
</div>
)}
@@ -1,74 +0,0 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
export type Theme = "light" | "dark";
type ThemeContextValue = {
theme: Theme;
setTheme: (t: Theme) => void;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
function getStoredTheme(): Theme | null {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") return stored;
return null;
}
function getSystemTheme(): Theme {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme: Theme): void {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => getStoredTheme() ?? getSystemTheme());
useEffect(() => {
applyTheme(theme);
}, [theme]);
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
function handler() {
if (getStoredTheme() === null) {
const sys = getSystemTheme();
setThemeState(sys);
applyTheme(sys);
}
}
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const setTheme = useCallback((t: Theme) => {
localStorage.setItem("theme", t);
setThemeState(t);
}, []);
const toggleTheme = useCallback(() => {
setThemeState((prev) => {
const next = prev === "dark" ? "light" : "dark";
localStorage.setItem("theme", next);
applyTheme(next);
return next;
});
}, []);
return <ThemeContext value={{ theme, setTheme, toggleTheme }}>{children}</ThemeContext>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (ctx === null) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return ctx;
}
+17 -92
View File
@@ -1,107 +1,32 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius: 0.625rem;
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-warning: hsl(var(--warning));
--color-warning-foreground: hsl(var(--warning-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-sidebar: hsl(var(--sidebar));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
}
:root {
--radius: 0.625rem;
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--success: 160 60% 40%;
--success-foreground: 0 0% 98%;
--warning: 38 92% 50%;
--warning-foreground: 0 0% 0%;
--sidebar: 0 0% 98%;
--sidebar-foreground: 240 3.8% 46.1%;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 6% 6.5%;
--card-foreground: 0 0% 98%;
--popover: 240 6% 6.5%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--success: 160 60% 45%;
--success-foreground: 0 0% 98%;
--warning: 38 92% 50%;
--warning-foreground: 0 0% 0%;
--sidebar: 240 6% 6.5%;
--sidebar-foreground: 240 5% 64.9%;
--color-bg: #0a0a0f;
--color-surface: #12121a;
--color-border: #1e1e2e;
--color-text: #e4e4ef;
--color-text-muted: #6b6b8a;
--color-accent: #7c6df0;
--color-accent-dim: #5a4db8;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
}
body {
margin: 0;
background: hsl(var(--background));
color: hsl(var(--foreground));
background: var(--color-bg);
color: var(--color-text);
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
@keyframes wf-node-pulse {
0%,
100% {
box-shadow: 0 0 0 0 hsl(var(--ring) / 0.55);
box-shadow: 0 0 0 0 rgba(124, 109, 240, 0.55);
}
50% {
box-shadow: 0 0 0 6px hsl(var(--ring) / 0);
box-shadow: 0 0 0 6px rgba(124, 109, 240, 0);
}
}
@@ -111,13 +36,13 @@ body {
@keyframes wf-record-card-highlight {
0% {
border-color: hsl(var(--ring));
border-color: var(--color-accent);
}
35% {
border-color: hsl(var(--ring));
border-color: var(--color-accent);
}
100% {
border-color: hsl(var(--border));
border-color: var(--color-border);
}
}
@@ -1,6 +0,0 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+2 -6
View File
@@ -1,17 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router";
import { ThemeProvider } from "./hooks/use-theme.tsx";
import "./index.css";
import { router } from "./router.tsx";
import { App } from "./app.tsx";
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
<App />
</StrictMode>,
);
}
@@ -1,45 +0,0 @@
import { createHashRouter, redirect } from "react-router";
import { Layout } from "./app.tsx";
import { ClientRedirect } from "./components/client-redirect.tsx";
import { LoginPage } from "./components/login.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { WorkflowDetail } from "./components/workflow-detail.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
export const router = createHashRouter([
{
path: "/login",
Component: LoginPage,
},
{
path: "/",
Component: Layout,
children: [
{
index: true,
Component: ClientRedirect,
},
{
path: ":client/threads",
Component: ThreadList,
},
{
path: ":client/threads/:threadId",
Component: ThreadDetail,
},
{
path: ":client/workflows",
Component: WorkflowList,
},
{
path: ":client/workflows/:workflowName",
Component: WorkflowDetail,
},
{
path: ":client",
loader: ({ params }) => redirect(`/${params.client}/threads`),
},
],
},
]);
@@ -0,0 +1,110 @@
import { useCallback, useEffect, useState } from "react";
type View = "threads" | "workflows";
type HashRoute = {
view: View;
client: string | null;
threadId: string | null;
workflowName: string | null;
};
function parseHash(hash: string): HashRoute {
const raw = hash.replace(/^#\/?/, "");
// Format: #client/threads/id or #client/workflows or #threads or #workflows
const parts = raw.split("/");
// Check if first part is a known view
if (parts[0] === "threads" || parts[0] === "workflows") {
return {
view: parts[0] as View,
client: null,
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
workflowName: parts[0] === "workflows" && parts.length > 1 ? parts.slice(1).join("/") : null,
};
}
// First part is client name
const client = parts[0] || null;
const viewPart = parts[1] ?? "threads";
const view: View = viewPart === "workflows" ? "workflows" : "threads";
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
const workflowName = view === "workflows" && parts.length > 2 ? parts.slice(2).join("/") : null;
return { view, client, threadId, workflowName };
}
function buildHash(route: HashRoute): string {
const prefix = route.client ? `${route.client}/` : "";
if (route.view === "workflows") {
if (route.workflowName !== null) {
return `#${prefix}workflows/${route.workflowName}`;
}
return `#${prefix}workflows`;
}
if (route.threadId !== null) {
return `#${prefix}threads/${route.threadId}`;
}
return `#${prefix}threads`;
}
export function useHashRoute(): {
view: View;
client: string | null;
threadId: string | null;
workflowName: string | null;
setView: (v: View) => void;
setClient: (a: string | null) => void;
setThreadId: (id: string | null) => void;
setWorkflowName: (name: string | null) => void;
} {
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
useEffect(() => {
function onHashChange(): void {
setRoute(parseHash(window.location.hash));
}
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
const navigate = useCallback((next: HashRoute) => {
const hash = buildHash(next);
window.location.hash = hash;
setRoute(next);
}, []);
const setView = useCallback(
(v: View) => navigate({ view: v, client: route.client, threadId: null, workflowName: null }),
[navigate, route.client],
);
const setClient = useCallback(
(a: string | null) =>
navigate({ view: route.view, client: a, threadId: null, workflowName: null }),
[navigate, route.view],
);
const setThreadId = useCallback(
(id: string | null) =>
navigate({ view: "threads", client: route.client, threadId: id, workflowName: null }),
[navigate, route.client],
);
const setWorkflowName = useCallback(
(name: string | null) =>
navigate({ view: "workflows", client: route.client, threadId: null, workflowName: name }),
[navigate, route.client],
);
return {
view: route.view,
client: route.client,
threadId: route.threadId,
workflowName: route.workflowName,
setView,
setClient,
setThreadId,
setWorkflowName,
};
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
+1 -3
View File
@@ -4,9 +4,7 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"strict": true,
"types": ["vite/client"],
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
@@ -15,5 +13,5 @@
"isolatedModules": true,
"noEmit": true
},
"include": ["src", "plugins"]
"include": ["src"]
}
+1 -6
View File
@@ -1,15 +1,10 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteLimitLinePlugin } from "./plugins/vite-limit-line-plugin.js";
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
export default defineConfig({
plugins: [
react(),
tailwindcss(),
...viteLimitLinePlugin({ maxReactFCLines: 300, maxFileLines: 600 }),
],
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
@@ -0,0 +1,100 @@
/**
* greet workflow — smoke test entry
* Single role: greeter takes a prompt and returns a structured greeting.
* 小橘 🍊
*/
import type {
AdapterFn,
ModeratorTable,
RoleFn,
RoleResult,
ThreadContext,
WorkflowDefinition,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import { createWorkflow, END, START } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
type GreetMeta = {
greeter: { greeting: string; language: string };
};
const greeterSchema = z.object({
greeting: z.string().describe("A friendly greeting message"),
language: z.string().describe("The language of the greeting"),
});
const roles: WorkflowDefinition<GreetMeta>["roles"] = {
greeter: {
description: "Generates a friendly greeting",
systemPrompt:
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
schema: greeterSchema,
},
};
const table: ModeratorTable<GreetMeta> = {
[START]: [{ condition: "FALLBACK", role: "greeter" }],
greeter: [{ condition: "FALLBACK", role: END }],
};
export const descriptor = {
name: "greet",
description: "A simple greeting workflow for smoke testing",
graph: { [START]: ["greeter"], greeter: [END] },
roles: { greeter: { description: "Generates a friendly greeting" } },
};
function createLazyAdapter(): AdapterFn {
let cached: { baseUrl: string; apiKey: string; model: string } | null = null;
function getProvider() {
if (cached !== null) return cached;
const apiKey = process.env.DASHSCOPE_API_KEY;
if (!apiKey) throw new Error("missing env: DASHSCOPE_API_KEY");
cached = {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey,
model: process.env.WORKFLOW_MODEL ?? "qwen-plus",
};
return cached;
}
return (<T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const provider = getProvider();
const response = await fetch(`${provider.baseUrl}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
messages: [
{ role: "system", content: prompt },
{
role: "user",
content: `${ctx.start.content}\n\nRespond with JSON: ${JSON.stringify(z.toJSONSchema(schema))}`,
},
],
response_format: { type: "json_object" },
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM error ${response.status}: ${body.slice(0, 500)}`);
}
const data = (await response.json()) as { choices: Array<{ message: { content: string } }> };
const text = data.choices[0]?.message?.content;
if (!text) throw new Error("Empty LLM response");
const parsed = schema.parse(JSON.parse(text));
return { meta: parsed, childThread: null };
};
}) as AdapterFn;
}
export const run = createWorkflow<GreetMeta>(
{ roles, table },
{ adapter: createLazyAdapter(), overrides: null },
);
@@ -1,87 +0,0 @@
import { describe, expect, test } from "bun:test";
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
import { END, type ModeratorContext, type RoleStep, START } from "@uncaged/workflow-runtime";
import { buildDocumentDescriptor } from "../src/descriptor.js";
import { documentTable } from "../src/moderator.js";
import type { DifferMeta, WriterMeta } from "../src/roles/index.js";
import type { DocumentMeta } from "../src/roles.js";
const documentModerator = tableToModerator(documentTable);
function makeCtx(
steps: ModeratorContext<DocumentMeta>["steps"],
): ModeratorContext<DocumentMeta> {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
bundleHash: "TESTHASH00001",
start: { role: START, content: "", meta: {}, timestamp: 0, parentState: null },
steps,
};
}
function writerGenerateStep(): RoleStep<DocumentMeta> {
return {
role: "writer",
contentHash: "STUBHASHWRITER001",
meta: { mode: "generate", outputDocx: "/out/output.docx", sourceDocx: null } satisfies WriterMeta,
refs: [],
timestamp: 1,
};
}
function writerEditStep(): RoleStep<DocumentMeta> {
return {
role: "writer",
contentHash: "STUBHASHWRITER002",
meta: { mode: "edit", outputDocx: "/out/modified.docx", sourceDocx: "/out/original.docx" } satisfies WriterMeta,
refs: [],
timestamp: 1,
};
}
function differStep(): RoleStep<DocumentMeta> {
return {
role: "differ",
contentHash: "STUBHASHDIFF001",
meta: {
sourceDocx: "/out/original.docx",
modifiedDocx: "/out/modified.docx",
diffDocx: "/out/diff.docx",
} satisfies DifferMeta,
refs: [],
timestamp: 2,
};
}
describe("documentTable", () => {
test("START → writer", () => {
expect(documentModerator(makeCtx([]))).toBe("writer");
});
test("writer (generate) → END", () => {
expect(documentModerator(makeCtx([writerGenerateStep()]))).toBe(END);
});
test("writer (edit) → differ", () => {
expect(documentModerator(makeCtx([writerEditStep()]))).toBe("differ");
});
test("differ → END", () => {
expect(documentModerator(makeCtx([writerEditStep(), differStep()]))).toBe(END);
});
});
describe("buildDocumentDescriptor", () => {
test("descriptor passes validation", () => {
const descriptor = buildDocumentDescriptor();
expect(() => validateWorkflowDescriptor(descriptor)).not.toThrow();
});
test("descriptor has writer and differ roles", () => {
const descriptor = buildDocumentDescriptor();
expect(Object.keys(descriptor.roles)).toContain("writer");
expect(Object.keys(descriptor.roles)).toContain("differ");
});
});
@@ -1,28 +0,0 @@
{
"name": "@uncaged/workflow-template-document",
"version": "0.1.0",
"files": ["src", "dist", "package.json"],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"zod": "^4.0.0"
},
"devDependencies": {
"@uncaged/workflow-protocol": "workspace:^"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,11 +0,0 @@
import { buildDescriptor } from "@uncaged/workflow-register";
import { documentTable } from "./moderator.js";
import { DOCUMENT_WORKFLOW_DESCRIPTION, documentRoles } from "./roles.js";
export function buildDocumentDescriptor() {
return buildDescriptor({
description: DOCUMENT_WORKFLOW_DESCRIPTION,
roles: documentRoles,
table: documentTable,
});
}
@@ -1,27 +0,0 @@
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
import { documentTable } from "./moderator.js";
import { DOCUMENT_WORKFLOW_DESCRIPTION, type DocumentMeta, documentRoles } from "./roles.js";
export { buildDocumentDescriptor } from "./descriptor.js";
export { documentTable } from "./moderator.js";
export {
type DifferMeta,
differMetaSchema,
differRole,
type WriterMeta,
writerMetaSchema,
writerRole,
} from "./roles/index.js";
export {
DOCUMENT_WORKFLOW_DESCRIPTION,
type DocumentMeta,
type DocumentRoles,
documentRoles,
} from "./roles.js";
export type { DocumentStartInput } from "./types.js";
export const documentWorkflowDefinition: WorkflowDefinition<DocumentMeta> = {
description: DOCUMENT_WORKFLOW_DESCRIPTION,
roles: documentRoles,
table: documentTable,
};
@@ -1,27 +0,0 @@
import {
END,
type ModeratorCondition,
type ModeratorTable,
START,
} from "@uncaged/workflow-runtime";
import type { WriterMeta } from "./roles/writer.js";
import type { DocumentMeta } from "./roles.js";
const writerIsEditMode: ModeratorCondition<DocumentMeta> = {
name: "writerIsEditMode",
description: "Writer ran in edit mode and produced a modified document",
check: (ctx) => {
const writerStep = ctx.steps.find((s) => s.role === "writer");
if (writerStep === undefined) return false;
return (writerStep.meta as WriterMeta).mode === "edit";
},
};
export const documentTable: ModeratorTable<DocumentMeta> = {
[START]: [{ condition: "FALLBACK", role: "writer" }],
writer: [
{ condition: writerIsEditMode, role: "differ" },
{ condition: "FALLBACK", role: END },
],
differ: [{ condition: "FALLBACK", role: END }],
};
@@ -1,20 +0,0 @@
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import { type DifferMeta, differRole } from "./roles/differ.js";
import { type WriterMeta, writerRole } from "./roles/writer.js";
export const DOCUMENT_WORKFLOW_DESCRIPTION =
"Generates a new Word document from a prompt, or edits an existing one and produces a diff report.";
export type DocumentMeta = {
writer: WriterMeta;
differ: DifferMeta;
};
export type DocumentRoles = {
[K in keyof DocumentMeta]: RoleDefinition<DocumentMeta[K]>;
};
export const documentRoles: DocumentRoles = {
writer: writerRole,
differ: differRole,
};
@@ -1,16 +0,0 @@
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const differMetaSchema = z.object({
sourceDocx: z.string(),
modifiedDocx: z.string(),
diffDocx: z.string(),
});
export type DifferMeta = z.infer<typeof differMetaSchema>;
export const differRole: RoleDefinition<DifferMeta> = {
description: "Produces a Word-format diff report of the writer's changes (edit mode only).",
systemPrompt: "",
schema: differMetaSchema,
};
@@ -1,4 +0,0 @@
export type { DifferMeta } from "./differ.js";
export { differMetaSchema, differRole } from "./differ.js";
export type { WriterMeta } from "./writer.js";
export { writerMetaSchema, writerRole } from "./writer.js";
@@ -1,23 +0,0 @@
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const writerMetaSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("generate"),
outputDocx: z.string(),
sourceDocx: z.null(),
}),
z.object({
mode: z.literal("edit"),
outputDocx: z.string(),
sourceDocx: z.string(),
}),
]);
export type WriterMeta = z.infer<typeof writerMetaSchema>;
export const writerRole: RoleDefinition<WriterMeta> = {
description: "Generates or modifies a Word document via an external agent.",
systemPrompt: "",
schema: writerMetaSchema,
};
@@ -1,4 +0,0 @@
export type DocumentStartInput = {
prompt: string;
inputDocx: string | null;
};
@@ -1,14 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-register" }
]
}
-937
View File
@@ -1,937 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@biomejs/biome':
specifier: ^2.4.14
version: 2.4.15
'@changesets/cli':
specifier: ^2.31.0
version: 2.31.0(@types/node@25.8.0)
'@types/node':
specifier: ^25.7.0
version: 25.8.0
'@types/xxhashjs':
specifier: ^0.2.4
version: 0.2.4
bun-types:
specifier: ^1.3.13
version: 1.3.14
packages:
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
'@biomejs/biome@2.4.15':
resolution: {integrity: sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.4.15':
resolution: {integrity: sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.4.15':
resolution: {integrity: sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.4.15':
resolution: {integrity: sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-arm64@2.4.15':
resolution: {integrity: sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.4.15':
resolution: {integrity: sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@2.4.15':
resolution: {integrity: sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@2.4.15':
resolution: {integrity: sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.4.15':
resolution: {integrity: sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
'@changesets/apply-release-plan@7.1.1':
resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==}
'@changesets/assemble-release-plan@6.0.10':
resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==}
'@changesets/changelog-git@0.2.1':
resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==}
'@changesets/cli@2.31.0':
resolution: {integrity: sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==}
hasBin: true
'@changesets/config@3.1.4':
resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==}
'@changesets/errors@0.2.0':
resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==}
'@changesets/get-dependents-graph@2.1.4':
resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==}
'@changesets/get-release-plan@4.0.16':
resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==}
'@changesets/get-version-range-type@0.4.0':
resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==}
'@changesets/git@3.0.4':
resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==}
'@changesets/logger@0.1.1':
resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==}
'@changesets/parse@0.4.3':
resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==}
'@changesets/pre@2.0.2':
resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==}
'@changesets/read@0.6.7':
resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==}
'@changesets/should-skip-package@0.1.2':
resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==}
'@changesets/types@4.1.0':
resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==}
'@changesets/types@6.1.0':
resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==}
'@changesets/write@0.4.0':
resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==}
'@inquirer/external-editor@1.0.3':
resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@manypkg/find-root@1.1.0':
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
'@manypkg/get-packages@1.1.3':
resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
'@nodelib/fs.stat@2.0.5':
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
engines: {node: '>= 8'}
'@nodelib/fs.walk@1.2.8':
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
'@types/node@25.8.0':
resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==}
'@types/xxhashjs@0.2.4':
resolution: {integrity: sha512-E2+ZoJY2JjmVPN0iQM5gJvZkk98O2PYXSi6HrciEk3EKF34+mauEk/HgwTeCz+2r8HXHMKpucrwy4qTT12OPaQ==}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
array-union@2.1.0:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'}
better-path-resolve@1.0.0:
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
engines: {node: '>=4'}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
bun-types@1.3.14:
resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==}
chardet@2.1.1:
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
detect-indent@6.1.0:
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
engines: {node: '>=8'}
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
enquirer@2.4.1:
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
engines: {node: '>=8.6'}
esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
hasBin: true
extendable-error@0.1.7:
resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
fs-extra@7.0.1:
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
engines: {node: '>=6 <7 || >=8'}
fs-extra@8.1.0:
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
engines: {node: '>=6 <7 || >=8'}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
globby@11.1.0:
resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
engines: {node: '>=10'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
human-id@4.1.3:
resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==}
hasBin: true
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
is-subdir@1.2.0:
resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==}
engines: {node: '>=4'}
is-windows@1.0.2:
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
engines: {node: '>=0.10.0'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
js-yaml@3.14.2:
resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
hasBin: true
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash.startcase@4.4.0:
resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
outdent@0.5.0:
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
p-filter@2.1.0:
resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==}
engines: {node: '>=8'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-map@2.1.0:
resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==}
engines: {node: '>=6'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-manager-detector@0.2.11:
resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
pify@4.0.1:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
prettier@2.8.8:
resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==}
engines: {node: '>=10.13.0'}
hasBin: true
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
read-yaml-file@1.1.0:
resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==}
engines: {node: '>=6'}
resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
semver@7.8.0:
resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
engines: {node: '>=10'}
hasBin: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
spawndamnit@3.0.1:
resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
term-size@2.2.1:
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
engines: {node: '>=8'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
universalify@0.1.2:
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
engines: {node: '>= 4.0.0'}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
snapshots:
'@babel/runtime@7.29.2': {}
'@biomejs/biome@2.4.15':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.4.15
'@biomejs/cli-darwin-x64': 2.4.15
'@biomejs/cli-linux-arm64': 2.4.15
'@biomejs/cli-linux-arm64-musl': 2.4.15
'@biomejs/cli-linux-x64': 2.4.15
'@biomejs/cli-linux-x64-musl': 2.4.15
'@biomejs/cli-win32-arm64': 2.4.15
'@biomejs/cli-win32-x64': 2.4.15
'@biomejs/cli-darwin-arm64@2.4.15':
optional: true
'@biomejs/cli-darwin-x64@2.4.15':
optional: true
'@biomejs/cli-linux-arm64-musl@2.4.15':
optional: true
'@biomejs/cli-linux-arm64@2.4.15':
optional: true
'@biomejs/cli-linux-x64-musl@2.4.15':
optional: true
'@biomejs/cli-linux-x64@2.4.15':
optional: true
'@biomejs/cli-win32-arm64@2.4.15':
optional: true
'@biomejs/cli-win32-x64@2.4.15':
optional: true
'@changesets/apply-release-plan@7.1.1':
dependencies:
'@changesets/config': 3.1.4
'@changesets/get-version-range-type': 0.4.0
'@changesets/git': 3.0.4
'@changesets/should-skip-package': 0.1.2
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
detect-indent: 6.1.0
fs-extra: 7.0.1
lodash.startcase: 4.4.0
outdent: 0.5.0
prettier: 2.8.8
resolve-from: 5.0.0
semver: 7.8.0
'@changesets/assemble-release-plan@6.0.10':
dependencies:
'@changesets/errors': 0.2.0
'@changesets/get-dependents-graph': 2.1.4
'@changesets/should-skip-package': 0.1.2
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
semver: 7.8.0
'@changesets/changelog-git@0.2.1':
dependencies:
'@changesets/types': 6.1.0
'@changesets/cli@2.31.0(@types/node@25.8.0)':
dependencies:
'@changesets/apply-release-plan': 7.1.1
'@changesets/assemble-release-plan': 6.0.10
'@changesets/changelog-git': 0.2.1
'@changesets/config': 3.1.4
'@changesets/errors': 0.2.0
'@changesets/get-dependents-graph': 2.1.4
'@changesets/get-release-plan': 4.0.16
'@changesets/git': 3.0.4
'@changesets/logger': 0.1.1
'@changesets/pre': 2.0.2
'@changesets/read': 0.6.7
'@changesets/should-skip-package': 0.1.2
'@changesets/types': 6.1.0
'@changesets/write': 0.4.0
'@inquirer/external-editor': 1.0.3(@types/node@25.8.0)
'@manypkg/get-packages': 1.1.3
ansi-colors: 4.1.3
enquirer: 2.4.1
fs-extra: 7.0.1
mri: 1.2.0
package-manager-detector: 0.2.11
picocolors: 1.1.1
resolve-from: 5.0.0
semver: 7.8.0
spawndamnit: 3.0.1
term-size: 2.2.1
transitivePeerDependencies:
- '@types/node'
'@changesets/config@3.1.4':
dependencies:
'@changesets/errors': 0.2.0
'@changesets/get-dependents-graph': 2.1.4
'@changesets/logger': 0.1.1
'@changesets/should-skip-package': 0.1.2
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
fs-extra: 7.0.1
micromatch: 4.0.8
'@changesets/errors@0.2.0':
dependencies:
extendable-error: 0.1.7
'@changesets/get-dependents-graph@2.1.4':
dependencies:
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
picocolors: 1.1.1
semver: 7.8.0
'@changesets/get-release-plan@4.0.16':
dependencies:
'@changesets/assemble-release-plan': 6.0.10
'@changesets/config': 3.1.4
'@changesets/pre': 2.0.2
'@changesets/read': 0.6.7
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
'@changesets/get-version-range-type@0.4.0': {}
'@changesets/git@3.0.4':
dependencies:
'@changesets/errors': 0.2.0
'@manypkg/get-packages': 1.1.3
is-subdir: 1.2.0
micromatch: 4.0.8
spawndamnit: 3.0.1
'@changesets/logger@0.1.1':
dependencies:
picocolors: 1.1.1
'@changesets/parse@0.4.3':
dependencies:
'@changesets/types': 6.1.0
js-yaml: 4.1.1
'@changesets/pre@2.0.2':
dependencies:
'@changesets/errors': 0.2.0
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
fs-extra: 7.0.1
'@changesets/read@0.6.7':
dependencies:
'@changesets/git': 3.0.4
'@changesets/logger': 0.1.1
'@changesets/parse': 0.4.3
'@changesets/types': 6.1.0
fs-extra: 7.0.1
p-filter: 2.1.0
picocolors: 1.1.1
'@changesets/should-skip-package@0.1.2':
dependencies:
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
'@changesets/types@4.1.0': {}
'@changesets/types@6.1.0': {}
'@changesets/write@0.4.0':
dependencies:
'@changesets/types': 6.1.0
fs-extra: 7.0.1
human-id: 4.1.3
prettier: 2.8.8
'@inquirer/external-editor@1.0.3(@types/node@25.8.0)':
dependencies:
chardet: 2.1.1
iconv-lite: 0.7.2
optionalDependencies:
'@types/node': 25.8.0
'@manypkg/find-root@1.1.0':
dependencies:
'@babel/runtime': 7.29.2
'@types/node': 12.20.55
find-up: 4.1.0
fs-extra: 8.1.0
'@manypkg/get-packages@1.1.3':
dependencies:
'@babel/runtime': 7.29.2
'@changesets/types': 4.1.0
'@manypkg/find-root': 1.1.0
fs-extra: 8.1.0
globby: 11.1.0
read-yaml-file: 1.1.0
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
run-parallel: 1.2.0
'@nodelib/fs.stat@2.0.5': {}
'@nodelib/fs.walk@1.2.8':
dependencies:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@types/node@12.20.55': {}
'@types/node@25.8.0':
dependencies:
undici-types: 7.24.6
'@types/xxhashjs@0.2.4':
dependencies:
'@types/node': 25.8.0
ansi-colors@4.1.3: {}
ansi-regex@5.0.1: {}
argparse@1.0.10:
dependencies:
sprintf-js: 1.0.3
argparse@2.0.1: {}
array-union@2.1.0: {}
better-path-resolve@1.0.0:
dependencies:
is-windows: 1.0.2
braces@3.0.3:
dependencies:
fill-range: 7.1.1
bun-types@1.3.14:
dependencies:
'@types/node': 25.8.0
chardet@2.1.1: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
detect-indent@6.1.0: {}
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
enquirer@2.4.1:
dependencies:
ansi-colors: 4.1.3
strip-ansi: 6.0.1
esprima@4.0.1: {}
extendable-error@0.1.7: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
'@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
micromatch: 4.0.8
fastq@1.20.1:
dependencies:
reusify: 1.1.0
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
fs-extra@7.0.1:
dependencies:
graceful-fs: 4.2.11
jsonfile: 4.0.0
universalify: 0.1.2
fs-extra@8.1.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 4.0.0
universalify: 0.1.2
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
globby@11.1.0:
dependencies:
array-union: 2.1.0
dir-glob: 3.0.1
fast-glob: 3.3.3
ignore: 5.3.2
merge2: 1.4.1
slash: 3.0.0
graceful-fs@4.2.11: {}
human-id@4.1.3: {}
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
ignore@5.3.2: {}
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-number@7.0.0: {}
is-subdir@1.2.0:
dependencies:
better-path-resolve: 1.0.0
is-windows@1.0.2: {}
isexe@2.0.0: {}
js-yaml@3.14.2:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
jsonfile@4.0.0:
optionalDependencies:
graceful-fs: 4.2.11
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
lodash.startcase@4.4.0: {}
merge2@1.4.1: {}
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.2
mri@1.2.0: {}
outdent@0.5.0: {}
p-filter@2.1.0:
dependencies:
p-map: 2.1.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-map@2.1.0: {}
p-try@2.2.0: {}
package-manager-detector@0.2.11:
dependencies:
quansync: 0.2.11
path-exists@4.0.0: {}
path-key@3.1.1: {}
path-type@4.0.0: {}
picocolors@1.1.1: {}
picomatch@2.3.2: {}
pify@4.0.1: {}
prettier@2.8.8: {}
quansync@0.2.11: {}
queue-microtask@1.2.3: {}
read-yaml-file@1.1.0:
dependencies:
graceful-fs: 4.2.11
js-yaml: 3.14.2
pify: 4.0.1
strip-bom: 3.0.0
resolve-from@5.0.0: {}
reusify@1.1.0: {}
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
safer-buffer@2.1.2: {}
semver@7.8.0: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
signal-exit@4.1.0: {}
slash@3.0.0: {}
spawndamnit@3.0.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
sprintf-js@1.0.3: {}
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-bom@3.0.0: {}
term-size@2.2.1: {}
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
undici-types@7.24.6: {}
universalify@0.1.2: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
+1 -17
View File
@@ -1,27 +1,11 @@
#!/usr/bin/env bun
// Mock agent for smoke testing
import { bootstrap, type JSONSchema, putSchema } from "@uncaged/json-cas";
import { createAgent } from "../packages/uwf-agent-kit/src/index.js";
const MOCK_RAW_OUTPUT_SCHEMA: JSONSchema = {
title: "mock-raw-output",
type: "object",
required: ["text"],
properties: {
text: { type: "string" },
},
additionalProperties: false,
};
const agent = createAgent({
name: "mock",
run: async (ctx) => {
const output = `Mock output for role ${ctx.role}: task was "${ctx.prompt}"`;
const { store } = ctx;
await bootstrap(store);
const schemaHash = await putSchema(store, MOCK_RAW_OUTPUT_SCHEMA);
const detailHash = await store.put(schemaHash, { text: output });
return { output, detailHash };
return `Mock output for role ${ctx.role}: task was "${ctx.prompt}"`;
},
});

Some files were not shown because too many files have changed in this diff Show More