Merge Ironclaw changes onto upstream Openclaw 2026.2.22
Replays all Ironclaw-specific changes (176 commits) onto the latest upstream Openclaw release (2026.2.22). Conflicts auto-resolved in favor of Ironclaw to guarantee zero change loss. Merge base: cbc3de6c9 (2026-02-16) Upstream: a37e12eab (upstream/main, 2026.2.22) Ironclaw: 3009566c9 (origin/main, 2026.2.15-1.9) Backup: ironclaw-backup-pre-sync Conflict resolutions: - 6 GitHub workflow files: deleted (Ironclaw intentionally stripped) - src/sessions/session-key-utils.test.ts: kept (Ironclaw modified) - Duplicate imports from merge: deduplicated - Unused imports from upstream code Ironclaw overrode: removed - Broken test indentation from merge: fixed Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
6594de6186
102
.cursor/plans/cli-only-streaming-hardening_8cba61e1.plan.md
Normal file
102
.cursor/plans/cli-only-streaming-hardening_8cba61e1.plan.md
Normal file
@ -0,0 +1,102 @@
|
||||
---
|
||||
name: cli-only-streaming-hardening
|
||||
overview: Harden the CLI-only web streaming refactor by fixing protocol-level flaws first, then replacing web WS consumers with managed CLI subscribe processes and adding guardrails for dedupe, lifecycle, and long-wait stability.
|
||||
todos:
|
||||
- id: fix-subscribe-cli-semantics
|
||||
content: Make `agent --stream-json --subscribe-session-key` long-lived and session-filtered, with tests.
|
||||
status: completed
|
||||
- id: add-subscribe-spawner
|
||||
content: Add `spawnAgentSubscribeProcess` helper in `apps/web/lib/agent-runner.ts` with profile/workspace env wiring.
|
||||
status: completed
|
||||
- id: parent-wait-cli-subscribe
|
||||
content: Refactor `apps/web/lib/active-runs.ts` waiting flow to use managed subscribe child + globalSeq dedupe.
|
||||
status: completed
|
||||
- id: subagent-cli-subscribe
|
||||
content: Refactor `apps/web/lib/subagent-runs.ts` fallback/rehydration to managed subscribe child + globalSeq dedupe.
|
||||
status: completed
|
||||
- id: remove-web-ws-client
|
||||
content: Remove `apps/web/lib/gateway-events.ts` usages and delete file after typecheck passes.
|
||||
status: completed
|
||||
- id: sse-keepalive
|
||||
content: Add keepalive behavior for long idle waiting streams.
|
||||
status: completed
|
||||
- id: verify-regressions
|
||||
content: Run targeted tests/smoke checks for handoff, refresh, replay, and duplicate/cross-session safety.
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# CLI-Only Streaming Plan (Flaw-Hardened)
|
||||
|
||||
## Critical flaws to fix before WS removal
|
||||
|
||||
- The current subscribe CLI path in [src/commands/agent-via-gateway.ts](/Users/kumareth/Documents/projects/openclaw/src/commands/agent-via-gateway.ts) calls `callGateway(... expectFinal: false)` and exits after `agent.subscribe` response; it does not remain attached for live events.
|
||||
- `agent.subscribe` clients still receive global `agent` broadcasts unless filtered client-side; without filtering, per-session subscribe children can ingest unrelated events and cause cross-session noise/duplication.
|
||||
- Handoff/replay can duplicate already-buffered events unless consumers gate by `globalSeq` (`<= lastSeen` ignore).
|
||||
- Long “waiting for subagents” SSE windows in [apps/web/app/api/chat/stream/route.ts](/Users/kumareth/Documents/projects/openclaw/apps/web/app/api/chat/stream/route.ts) have no keepalive signal, increasing disconnect risk during quiet periods.
|
||||
|
||||
## Revised implementation sequence
|
||||
|
||||
1. **Stabilize subscribe transport semantics first**
|
||||
|
||||
- Rework subscribe mode in [src/commands/agent-via-gateway.ts](/Users/kumareth/Documents/projects/openclaw/src/commands/agent-via-gateway.ts) to use a long-lived gateway client session (not one-shot `callGateway`) that:
|
||||
- connects,
|
||||
- sends `agent.subscribe { sessionKey, afterSeq }`,
|
||||
- streams events until SIGTERM/SIGINT,
|
||||
- emits only matching `sessionKey` events,
|
||||
- exits cleanly with `aborted` on signal.
|
||||
- Add targeted tests for subscribe staying alive and session-key filtering.
|
||||
|
||||
2. **Add reusable CLI subscribe spawner**
|
||||
|
||||
- In [apps/web/lib/agent-runner.ts](/Users/kumareth/Documents/projects/openclaw/apps/web/lib/agent-runner.ts), add `spawnAgentSubscribeProcess(sessionKey, afterSeq)` using:
|
||||
- `node <scriptPath> agent --stream-json --subscribe-session-key <key> --after-seq <n>`
|
||||
- same profile/workspace env wiring as `spawnAgentProcess`.
|
||||
|
||||
3. **Replace parent waiting flow with subscribe child process**
|
||||
|
||||
- In [apps/web/lib/active-runs.ts](/Users/kumareth/Documents/projects/openclaw/apps/web/lib/active-runs.ts):
|
||||
- replace `subscribeToSessionKey(...)` usage with a managed subscribe child,
|
||||
- parse NDJSON from subscribe child and route through existing parent event processor,
|
||||
- dedupe using `globalSeq` (drop stale/replayed duplicates),
|
||||
- store/cleanup process handle across finalize/abort/cleanup.
|
||||
|
||||
4. **Replace subagent fallback/rehydration with subscribe child process**
|
||||
|
||||
- In [apps/web/lib/subagent-runs.ts](/Users/kumareth/Documents/projects/openclaw/apps/web/lib/subagent-runs.ts):
|
||||
- swap `subscribeToSessionKey(...)` for one managed subscribe child per running subagent session,
|
||||
- feed NDJSON into existing `routeRawEvent`/transform path,
|
||||
- use `lastGlobalSeq` dedupe and robust teardown on completion/error/cleanup.
|
||||
|
||||
5. **Retire direct web WS client**
|
||||
|
||||
- Remove [apps/web/lib/gateway-events.ts](/Users/kumareth/Documents/projects/openclaw/apps/web/lib/gateway-events.ts) imports/usages from web runtime.
|
||||
- Delete file only after all references are gone and typecheck passes.
|
||||
|
||||
6. **Long-wait stream resilience**
|
||||
|
||||
- Add lightweight SSE keepalive comments/events while run status is `waiting-for-subagents` in [apps/web/app/api/chat/stream/route.ts](/Users/kumareth/Documents/projects/openclaw/apps/web/app/api/chat/stream/route.ts) or run subscription layer, so idle waits don’t silently time out.
|
||||
|
||||
7. **Verification gates**
|
||||
|
||||
- Run targeted checks for:
|
||||
- parent run -> subagent spawn -> parent wait -> announcement turn -> finalize,
|
||||
- page refresh during parent wait,
|
||||
- page refresh during subagent live stream,
|
||||
- no cross-session event bleed,
|
||||
- no duplicate tool/lifecycle events after replay handoff.
|
||||
|
||||
## Flow target
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
webRun[WebRunManager] --> parentCli[agent --stream-json main run]
|
||||
parentCli --> ndjsonParent[Parent NDJSON events]
|
||||
parentCli -->|parent exits while subagents running| waitState[waitingForSubagents]
|
||||
waitState --> subscribeCliParent[agent --stream-json subscribe parentSessionKey]
|
||||
subscribeCliParent --> ndjsonReplayParent[ReplayedPlusLive NDJSON]
|
||||
subagentMgr[SubagentRunManager] --> subscribeCliSub[agent --stream-json subscribe subagentSessionKey]
|
||||
subscribeCliSub --> ndjsonSub[Subagent NDJSON]
|
||||
ndjsonReplayParent --> sse[API chat stream SSE]
|
||||
ndjsonSub --> sse
|
||||
```
|
||||
665
.cursor/plans/dench_filesystem_crm_integration.plan.md
Normal file
665
.cursor/plans/dench_filesystem_crm_integration.plan.md
Normal file
@ -0,0 +1,665 @@
|
||||
---
|
||||
name: Dench Filesystem CRM Integration
|
||||
overview: Replace Dench's slow tool-by-tool CRM agent with a filesystem-first architecture where OpenClaw manages a DuckDB database and markdown documents in a dedicated workspace folder, synced via S3, and surfaced in Dench's sidebar.
|
||||
todos:
|
||||
- id: inject-skill-infra
|
||||
content: "Phase 1: Add `inject: true` skill metadata support to OpenClaw (types.ts, workspace.ts, system-prompt.ts, run.ts)"
|
||||
status: pending
|
||||
- id: dench-skill
|
||||
content: "Phase 2: Create skills/dench/SKILL.md with DuckDB schema, SQL patterns, CRM patterns, document management instructions"
|
||||
status: pending
|
||||
- id: s3-sync-script
|
||||
content: "Phase 3: Create S3 sync script and sandbox startup hook for dench workspace persistence"
|
||||
status: pending
|
||||
- id: dench-sidebar
|
||||
content: "Phase 4 (DEFERRED): Add workspace data source to Dench sidebar (tRPC endpoints + buildKnowledgeTree merge)"
|
||||
status: pending
|
||||
- id: lambda-sync
|
||||
content: "Phase 4b (DEFERRED): S3-to-PostgreSQL Lambda sync for fast sidebar queries (or simpler: direct DuckDB read from S3)"
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Dench Filesystem-First CRM via OpenClaw
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The current Dench CRM agent uses 15+ individual tRPC-backed tools (createObjectTool, createFieldTool, createEntryTool, searchEntriesTool, etc.) that each make a Prisma call. This is slow -- the agent often needs 10+ tool calls for a single user request.
|
||||
|
||||
The new architecture gives OpenClaw a `dench/` workspace folder with a DuckDB database and markdown files. The agent generates SQL directly via `exec` (duckdb CLI) and writes documents as `.md` files. S3 syncs this data between sandbox sessions, and Dench's sidebar reads from S3/PostgreSQL.
|
||||
|
||||
**Why DuckDB over SQLite:**
|
||||
|
||||
- Native PIVOT/UNPIVOT -- essential for the EAV (Entity-Attribute-Value) pattern used by custom fields; every "show me entries as a table" query needs pivot
|
||||
- PostgreSQL-compatible SQL dialect -- matches Dench's Supabase/Postgres, so generated SQL is portable
|
||||
- Native JSON type -- clean handling of `enum_values`, `enum_colors`, field mappings (no JSON1 extension needed)
|
||||
- Built-in CSV/Parquet import/export with auto-detection -- bulk CRM operations ("import 500 leads from CSV")
|
||||
- FTS extension -- full-text search across entry fields
|
||||
- `generate_series` + macros -- nanoid 32 generation in pure SQL
|
||||
- ~50-100ms startup overhead is acceptable given agent tool-call overhead is already 200-500ms
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph sandbox [OpenClaw Sandbox]
|
||||
agent[OpenClaw Agent]
|
||||
skill[Dench Skill - always in context]
|
||||
ctx[dench/workspace_context.yaml]
|
||||
db[dench/workspace.duckdb]
|
||||
docs[dench/documents/*.md]
|
||||
agent --> skill
|
||||
agent -->|"read-only context"| ctx
|
||||
agent -->|"exec: duckdb"| db
|
||||
agent -->|"write/edit"| docs
|
||||
end
|
||||
|
||||
subgraph s3layer [Persistence Layer]
|
||||
s3["S3: dench/{orgId}/"]
|
||||
end
|
||||
|
||||
subgraph denchApp [Dench Web App]
|
||||
sidebar[App Sidebar]
|
||||
api[tRPC API]
|
||||
pg[PostgreSQL - fs_files sync]
|
||||
end
|
||||
|
||||
db -->|sync on save| s3
|
||||
docs -->|sync on save| s3
|
||||
s3 -->|Lambda trigger| pg
|
||||
pg --> api --> sidebar
|
||||
s3 -->|download on sandbox start| db
|
||||
s3 -->|download on sandbox start| docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Always-In-Context Skill Infrastructure (OpenClaw)
|
||||
|
||||
Currently, all skills are lazy-loaded: only name + description appear in the system prompt, and the agent must `read` the SKILL.md to get full instructions. For the Dench CRM skill, we need the full content injected automatically.
|
||||
|
||||
**Approach:** Add an `inject: true` flag to skill metadata. When set, the skill's full content is included in the system prompt alongside bootstrap files (in the "Project Context" section), not in the lazy-loaded skills list.
|
||||
|
||||
**Files to modify:**
|
||||
|
||||
- [src/agents/skills/types.ts](src/agents/skills/types.ts) -- Add `inject?: boolean` to `OpenClawSkillMetadata`
|
||||
- [src/agents/skills/workspace.ts](src/agents/skills/workspace.ts) -- In `buildWorkspaceSkillSnapshot()`, separate injected skills from lazy-loaded skills. Return injected skill contents alongside the prompt.
|
||||
- [src/agents/system-prompt.ts](src/agents/system-prompt.ts) -- Accept injected skill content and include it in the "Project Context" / "Workspace Files" section (similar to how bootstrap files like AGENTS.md are included via `contextFiles`)
|
||||
- [src/agents/pi-embedded-runner/run.ts](src/agents/pi-embedded-runner/run.ts) -- Pass injected skill content through to the system prompt builder
|
||||
|
||||
**Key change in `buildWorkspaceSkillSnapshot`:**
|
||||
|
||||
```typescript
|
||||
// New return type addition
|
||||
export type SkillSnapshot = {
|
||||
prompt: string; // lazy-loaded skills prompt (XML)
|
||||
injectedContent?: string; // always-in-context skill content (concatenated)
|
||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Dench CRM Skill (OpenClaw)
|
||||
|
||||
Create `skills/dench/SKILL.md` with:
|
||||
|
||||
- Full DuckDB schema reference
|
||||
- nanoid 32 macro for ID generation (matching Dench's Supabase nanoid IDs)
|
||||
- SQL patterns for all CRUD operations including PIVOT for table views
|
||||
- Document management instructions
|
||||
- Workspace structure documentation
|
||||
- CRM patterns (contact, lead, deal, etc.) ported from the [Dench CRM agent prompt](file:///Users/kumareth/Documents/projects/dench/src/lib/agents/crm-agent.ts)
|
||||
|
||||
**New file:** `skills/dench/SKILL.md`
|
||||
|
||||
**Skill frontmatter:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: dench
|
||||
description: Manage Dench CRM workspace - create objects, fields, entries via DuckDB and documents as markdown files
|
||||
metadata:
|
||||
openclaw:
|
||||
inject: true
|
||||
emoji: "📊"
|
||||
---
|
||||
```
|
||||
|
||||
**Workspace directory structure managed by the skill:**
|
||||
|
||||
```
|
||||
~/.openclaw/workspace/dench/
|
||||
workspace_context.yaml # READ-ONLY context (org, members, integrations, defaults)
|
||||
workspace.duckdb # DuckDB database (CRM data)
|
||||
documents/ # Markdown documents (nested by path)
|
||||
getting-started.md
|
||||
projects/
|
||||
project-alpha.md
|
||||
exports/ # Generated CSV/Excel exports
|
||||
WORKSPACE.md # Auto-generated schema documentation
|
||||
```
|
||||
|
||||
**workspace_context.yaml** -- read-only context the agent consumes on startup (written by Dench, never by the agent):
|
||||
|
||||
```yaml
|
||||
# Dench Workspace Context (READ-ONLY)
|
||||
# This file is generated by Dench and synced via S3.
|
||||
# The agent reads this for organizational context but MUST NOT modify it.
|
||||
# Changes flow from Dench UI -> S3 -> this file (on sandbox init).
|
||||
|
||||
workspace:
|
||||
version: 1
|
||||
|
||||
# Organization identity (synced from Dench on sandbox init)
|
||||
organization:
|
||||
id: "org_abc123"
|
||||
name: "Acme Corp"
|
||||
slug: "acme-corp"
|
||||
business:
|
||||
name: "Acme Corporation"
|
||||
type: "saas" # saas, agency, ecommerce, services, etc.
|
||||
industry: "Technology"
|
||||
website: "https://acme.com"
|
||||
|
||||
# Team members -- needed for "user" type fields (e.g. "Assigned To")
|
||||
# Agent uses these IDs when creating entries with user-type fields.
|
||||
members:
|
||||
- id: "usr_abc123"
|
||||
name: "John Doe"
|
||||
email: "john@acme.com"
|
||||
role: owner
|
||||
- id: "usr_def456"
|
||||
name: "Jane Smith"
|
||||
email: "jane@acme.com"
|
||||
role: admin
|
||||
- id: "usr_ghi789"
|
||||
name: "Bob Wilson"
|
||||
email: "bob@acme.com"
|
||||
role: member
|
||||
|
||||
# Protected objects -- cannot be deleted/renamed by agent
|
||||
# Mirrors Dench's base-objects.ts immutable list
|
||||
protected_objects:
|
||||
- name: "people"
|
||||
description: "Contact records"
|
||||
icon: "users"
|
||||
- name: "companies"
|
||||
description: "Company records"
|
||||
icon: "building-2"
|
||||
|
||||
# Connected integrations -- agent reads this for sync context
|
||||
# Populated by Dench when sandbox initializes from S3
|
||||
integrations:
|
||||
connections: []
|
||||
# Example when connected:
|
||||
# - app_key: "salesforce"
|
||||
# app_name: "Salesforce"
|
||||
# connection_id: "conn_xyz"
|
||||
# synced_objects:
|
||||
# - external_resource: "Lead"
|
||||
# local_object: "lead"
|
||||
# sync_direction: bidirectional # import, export, bidirectional
|
||||
# sync_frequency: hourly # realtime, hourly, daily, manual
|
||||
# field_mappings:
|
||||
# "FirstName": "Full Name"
|
||||
# "Email": "Email Address"
|
||||
|
||||
# Enrichment configuration
|
||||
enrichment:
|
||||
enabled: false
|
||||
provider: "aviato" # aviato, apollo
|
||||
auto_enrich: false # Auto-enrich new entries on creation
|
||||
|
||||
# CRM defaults
|
||||
defaults:
|
||||
default_view: table # table, kanban
|
||||
date_format: "YYYY-MM-DD"
|
||||
naming_convention: singular_lowercase # Object names: "lead" not "Leads"
|
||||
|
||||
# S3 persistence
|
||||
sync:
|
||||
s3_bucket: "dench-workspaces"
|
||||
s3_prefix: "" # Set to org_id on init
|
||||
frequency: on_write # on_write, manual
|
||||
last_synced_at: null
|
||||
|
||||
# Credit account (for enrichment, AI operations)
|
||||
credits:
|
||||
allowance_balance: 0
|
||||
topup_balance: 0
|
||||
```
|
||||
|
||||
**Why YAML for context (not DuckDB):** This is read-only context the agent consumes once at startup -- never writes. The agent can `cat workspace_context.yaml` to understand the full org context instantly. Members list means no separate query to resolve user-type field assignments. Integrations give awareness of sync relationships without querying external APIs. This follows the Fintool pattern where user state (preferences, watchlists) lives as YAML in S3 while dense queryable data lives in the database. The data flow is one-way: Dench UI -> S3 -> workspace_context.yaml (on sandbox init). The agent never writes back to this file.
|
||||
|
||||
**DuckDB schema** (initialized by agent on first use via `duckdb dench/workspace.duckdb`):
|
||||
|
||||
```sql
|
||||
-- nanoid 32 macro: generates 32-char IDs matching Dench's Supabase nanoid format
|
||||
CREATE OR REPLACE MACRO nanoid32() AS (
|
||||
SELECT string_agg(
|
||||
substr('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-',
|
||||
(floor(random() * 64) + 1)::int, 1), '')
|
||||
FROM generate_series(1, 32)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS objects (
|
||||
id VARCHAR PRIMARY KEY DEFAULT (nanoid32()),
|
||||
name VARCHAR NOT NULL,
|
||||
description VARCHAR,
|
||||
icon VARCHAR,
|
||||
default_view VARCHAR DEFAULT 'table', -- 'table' or 'kanban'
|
||||
parent_document_id VARCHAR,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
source_app VARCHAR,
|
||||
immutable BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fields (
|
||||
id VARCHAR PRIMARY KEY DEFAULT (nanoid32()),
|
||||
object_id VARCHAR NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
|
||||
name VARCHAR NOT NULL,
|
||||
description VARCHAR,
|
||||
type VARCHAR NOT NULL, -- text, number, email, phone, boolean, date, richtext, user, relation, enum
|
||||
required BOOLEAN DEFAULT false,
|
||||
default_value VARCHAR,
|
||||
related_object_id VARCHAR REFERENCES objects(id) ON DELETE SET NULL,
|
||||
relationship_type VARCHAR, -- one_to_one, one_to_many, many_to_one, many_to_many
|
||||
enum_values JSON, -- ["New","In Progress","Done"]
|
||||
enum_colors JSON, -- ["#ef4444","#f59e0b","#22c55e"]
|
||||
enum_multiple BOOLEAN DEFAULT false,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(object_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id VARCHAR PRIMARY KEY DEFAULT (nanoid32()),
|
||||
object_id VARCHAR NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_fields (
|
||||
id VARCHAR PRIMARY KEY DEFAULT (nanoid32()),
|
||||
entry_id VARCHAR NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
field_id VARCHAR NOT NULL REFERENCES fields(id) ON DELETE CASCADE,
|
||||
value VARCHAR,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(entry_id, field_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS statuses (
|
||||
id VARCHAR PRIMARY KEY DEFAULT (nanoid32()),
|
||||
object_id VARCHAR NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
|
||||
name VARCHAR NOT NULL,
|
||||
color VARCHAR DEFAULT '#94a3b8',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_default BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(object_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id VARCHAR PRIMARY KEY DEFAULT (nanoid32()),
|
||||
title VARCHAR DEFAULT 'Untitled',
|
||||
icon VARCHAR,
|
||||
cover_image VARCHAR,
|
||||
file_path VARCHAR NOT NULL UNIQUE, -- relative path in documents/ dir
|
||||
parent_id VARCHAR REFERENCES documents(id) ON DELETE CASCADE,
|
||||
parent_object_id VARCHAR REFERENCES objects(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_published BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Full-text search index (DuckDB FTS extension)
|
||||
INSTALL fts; LOAD fts;
|
||||
```
|
||||
|
||||
**Auto-generated views** -- after every object/field mutation, the agent regenerates a PIVOT view for each object. These are stored queries (zero data duplication) that make the EAV pattern invisible:
|
||||
|
||||
```sql
|
||||
-- Auto-generated after creating the "leads" object and its fields
|
||||
CREATE OR REPLACE VIEW v_leads AS
|
||||
PIVOT (
|
||||
SELECT e.id as entry_id, e.created_at, e.updated_at,
|
||||
f.name as field_name, ef.value
|
||||
FROM entries e
|
||||
JOIN entry_fields ef ON ef.entry_id = e.id
|
||||
JOIN fields f ON f.id = ef.field_id
|
||||
WHERE e.object_id = (SELECT id FROM objects WHERE name = 'leads')
|
||||
) ON field_name USING first(value);
|
||||
|
||||
-- Now query like a normal table:
|
||||
SELECT * FROM v_leads WHERE "Status" = 'New' ORDER BY "Full Name" LIMIT 50;
|
||||
SELECT "Status", COUNT(*) FROM v_leads GROUP BY "Status";
|
||||
SELECT * FROM v_leads WHERE "Email Address" LIKE '%@gmail.com';
|
||||
```
|
||||
|
||||
Views are regenerated (not data, just the query definition) whenever fields are added/removed/renamed. Naming convention: `v_{object_name}` (e.g., `v_leads`, `v_companies`, `v_people`).
|
||||
|
||||
**Filesystem directory structure** -- auto-projected from DuckDB after schema mutations. Represents the sidebar's nested knowledge tree. NO entry data in the filesystem (DuckDB is sole source of truth for entries):
|
||||
|
||||
```
|
||||
dench/
|
||||
workspace.duckdb # SOLE source of truth for all structured data + views
|
||||
workspace_context.yaml # Read-only org context
|
||||
knowledge/ # Root of knowledge tree (= sidebar root)
|
||||
people/ # Object "people" (directory = object node in sidebar)
|
||||
.object.yaml # Lightweight metadata projection (id, icon, view, field list)
|
||||
onboarding-guide.md # Document nested UNDER the people object
|
||||
companies/ # Object "companies"
|
||||
.object.yaml
|
||||
projects/ # Document "Projects" (directory with children)
|
||||
projects.md # Document content
|
||||
tasks/ # Object nested UNDER the projects document
|
||||
.object.yaml
|
||||
roadmap.md # Sibling document
|
||||
sales/
|
||||
sales.md
|
||||
leads/ # Object nested under sales document
|
||||
.object.yaml
|
||||
follow-up-playbook.md # Document nested under leads object
|
||||
exports/ # CSV/Parquet exports (on-demand, not auto-generated)
|
||||
WORKSPACE.md # Auto-generated schema summary
|
||||
```
|
||||
|
||||
**Source of truth rules:**
|
||||
|
||||
- **Entries (rows)**: DuckDB ONLY (queried via `v_{object}` views). Never duplicated to filesystem.
|
||||
- **Fields (columns)**: DuckDB. Summary projected to `.object.yaml` (read-only).
|
||||
- **Objects (tables)**: DuckDB. Projected as directories + `.object.yaml` (read-only). Queryable via auto-generated views.
|
||||
- **Document metadata**: DuckDB. Projected as directory structure (read-only).
|
||||
- **Document content**: Filesystem (`.md` files). DuckDB stores `file_path` reference only.
|
||||
- **Nesting/ordering**: DuckDB. Projected as directory hierarchy (read-only).
|
||||
|
||||
**Key DuckDB advantages leveraged:**
|
||||
|
||||
- **PIVOT views**: Auto-generated `v_{object}` views make EAV invisible -- query like normal tables
|
||||
- **Native JSON**: `enum_values` and `enum_colors` are native JSON columns, no string parsing needed
|
||||
- **CSV import**: `COPY v_leads TO 'exports/leads.csv';` or import with `COPY ... FROM 'import.csv' (AUTO_DETECT true);`
|
||||
- **PostgreSQL dialect**: Generated SQL is directly portable to Dench's Supabase/Postgres
|
||||
|
||||
**Skill content structure** -- the full SKILL.md incorporates and adapts every section from Dench's existing CRM agent prompt ([src/lib/agents/crm-agent.ts](file:///Users/kumareth/Documents/projects/dench/src/lib/agents/crm-agent.ts)), rewritten for DuckDB/filesystem execution instead of tool calls:
|
||||
|
||||
### Section 1: Role and Workspace Startup
|
||||
|
||||
- Role: Dench CRM Management Agent operating via DuckDB and filesystem
|
||||
- On every conversation: read `dench/workspace_context.yaml` (READ-ONLY) for org context, members, integrations, protected objects
|
||||
- Initialize DuckDB if not exists: `duckdb dench/workspace.duckdb < schema.sql`
|
||||
- Database path: `dench/workspace.duckdb`, documents path: `dench/documents/`
|
||||
|
||||
### Section 2: Primary Responsibilities (adapted from `<primary_responsibilities>`)
|
||||
|
||||
- **Request analysis**: Same as original -- extract intent, identify entities/objects/fields/relationships, transform vague requests into structured SQL
|
||||
- **Object creation**: Instead of `createObjectTool`, generate `INSERT INTO objects` SQL. Naming convention: singular, lowercase (e.g., "lead", "customer"). Check existing with `SELECT` first. For kanban: auto-create Status (enum) and Assigned To (user) fields in same transaction
|
||||
- **Field management**: Instead of `createFieldTool`, generate `INSERT INTO fields` SQL. Field types: text, number, email, phone, boolean, date, richtext, user, relation, enum. Use `INSERT ... ON CONFLICT (object_id, name) DO UPDATE` for idempotency
|
||||
- **Entry creation**: Instead of `createEntryTool`, generate `INSERT INTO entries` + `INSERT INTO entry_fields` in a transaction. Resolve field names to field IDs via `SELECT id FROM fields WHERE object_id = ? AND name = ?`
|
||||
- **Entry search**: Instead of `searchEntriesTool`, generate PIVOT queries with WHERE/LIKE/ORDER BY. Use DuckDB FTS for full-text search. Operators: `=` (equals), `LIKE '%...%'` (contains), `LIKE '...%'` (startsWith), `LIKE '%...'` (endsWith), `IS NULL` (isEmpty), `IS NOT NULL` (isNotEmpty)
|
||||
|
||||
### Section 3: SQL Operation Guide (replaces `<tool_usage_guide>`)
|
||||
|
||||
Each former tool maps to SQL patterns:
|
||||
|
||||
- **createObjectTool -> INSERT object**: `INSERT INTO objects (name, description, icon, default_view) VALUES (...) ON CONFLICT (name) DO NOTHING RETURNING *;`
|
||||
- **createFieldTool -> INSERT field**: `INSERT INTO fields (object_id, name, type, required, enum_values, enum_colors, related_object_id, relationship_type, sort_order) VALUES (...) ON CONFLICT (object_id, name) DO UPDATE SET ...;`
|
||||
- **createEntryTool -> INSERT entry + entry_fields**: Transaction with `INSERT INTO entries` then `INSERT INTO entry_fields` for each field value
|
||||
- **getObjectTool -> SELECT object + fields**: `SELECT o.*, json_group_array(json_object('id', f.id, 'name', f.name, 'type', f.type)) as fields FROM objects o LEFT JOIN fields f ON f.object_id = o.id WHERE o.id = ? GROUP BY o.id;`
|
||||
- **getObjectsTool -> SELECT all objects**: `SELECT o.*, COUNT(e.id) as entry_count FROM objects o LEFT JOIN entries e ON e.object_id = o.id GROUP BY o.id ORDER BY o.sort_order;`
|
||||
- **getOrganizationMembersTool -> READ workspace_context.yaml**: Members list is in `workspace_context.yaml` under `members:`. Read with `cat dench/workspace_context.yaml` and extract the members section. User fields store member IDs like `usr_abc123`.
|
||||
- **searchEntriesTool -> query the auto-generated view**:
|
||||
```sql
|
||||
-- Simple: query the pre-built PIVOT view like a normal table
|
||||
SELECT * FROM v_leads WHERE "Status" = 'New' ORDER BY created_at DESC LIMIT 50;
|
||||
SELECT * FROM v_leads WHERE "Email Address" LIKE '%@gmail.com';
|
||||
SELECT * FROM v_leads WHERE "Full Name" ILIKE '%john%';
|
||||
SELECT "Status", COUNT(*) FROM v_leads GROUP BY "Status";
|
||||
```
|
||||
Views are auto-generated per object (`v_{object_name}`) so the agent never writes raw PIVOT queries for reads.
|
||||
- **updateObjectTool -> UPDATE object**: `UPDATE objects SET name = ?, description = ?, updated_at = now() WHERE id = ?;`
|
||||
- **updateFieldTool -> UPDATE field**: `UPDATE fields SET ... WHERE id = ?;`
|
||||
- **updateEntryTool -> UPSERT entry_fields**: `INSERT INTO entry_fields (entry_id, field_id, value) VALUES (?, ?, ?) ON CONFLICT (entry_id, field_id) DO UPDATE SET value = excluded.value, updated_at = now();`
|
||||
- **deleteObjectTool -> DELETE cascade**: `DELETE FROM objects WHERE id = ? AND immutable = false;` (cascades to fields, entries, entry_fields via FK)
|
||||
- **deleteFieldTool -> DELETE field**: `DELETE FROM fields WHERE id = ?;` (cascades to entry_fields)
|
||||
- **deleteEntryTool -> DELETE entry**: `DELETE FROM entries WHERE id = ?;` (cascades to entry_fields)
|
||||
- **createManyEntriesTool -> Batch INSERT**: Wrap multiple entry+entry_fields inserts in `BEGIN TRANSACTION; ... COMMIT;`
|
||||
- **Bulk import from CSV**: `COPY ... FROM 'import.csv' (AUTO_DETECT true);` with field mapping
|
||||
|
||||
### Section 4: Execution Workflows (adapted from `<execution_guidelines>`)
|
||||
|
||||
Same workflow selection principle: choose the minimal workflow needed based on user intent.
|
||||
|
||||
- **Create New CRM Structure**: `SELECT` to check existence -> `INSERT objects` -> `INSERT fields` (all in one `exec` call with multi-statement SQL in a transaction)
|
||||
- **Search and Display**: Generate PIVOT query with appropriate WHERE/ORDER BY/LIMIT
|
||||
- **Add New Entries**: `SELECT` object+fields -> `INSERT entries` + `INSERT entry_fields` in transaction
|
||||
- **Update Existing Data**: PIVOT query to find -> `UPDATE` entry_fields
|
||||
- **Quick Information**: `SELECT` with aggregate counts
|
||||
- **Bulk Operations**: Multi-row INSERT in transaction, report counts
|
||||
- **Data Cleanup**: PIVOT query with filters -> `DELETE` matching entries
|
||||
|
||||
Key differences from original:
|
||||
|
||||
1. **All steps happen in a single `exec` call** with multi-statement SQL, not 10+ separate tool calls
|
||||
2. **After schema mutations**: regenerate the `v_{object}` view and project the filesystem directory structure
|
||||
3. **Reads use views**: `SELECT * FROM v_leads` instead of raw PIVOT queries
|
||||
|
||||
Example of creating a full CRM structure in one shot:
|
||||
|
||||
```sql
|
||||
BEGIN TRANSACTION;
|
||||
INSERT INTO objects (name, description, icon, default_view) VALUES ('lead', 'Sales leads', 'user-plus', 'table') ON CONFLICT (name) DO NOTHING;
|
||||
INSERT INTO fields (object_id, name, type, required, sort_order) VALUES
|
||||
((SELECT id FROM objects WHERE name = 'lead'), 'Full Name', 'text', true, 0),
|
||||
((SELECT id FROM objects WHERE name = 'lead'), 'Email Address', 'email', true, 1),
|
||||
((SELECT id FROM objects WHERE name = 'lead'), 'Phone Number', 'phone', false, 2)
|
||||
ON CONFLICT (object_id, name) DO NOTHING;
|
||||
INSERT INTO fields (object_id, name, type, enum_values, enum_colors, sort_order) VALUES
|
||||
((SELECT id FROM objects WHERE name = 'lead'), 'Status', 'enum',
|
||||
'["New","Contacted","Qualified","Converted"]'::JSON,
|
||||
'["#94a3b8","#3b82f6","#f59e0b","#22c55e"]'::JSON, 3)
|
||||
ON CONFLICT (object_id, name) DO NOTHING;
|
||||
|
||||
-- Auto-generate the PIVOT view for this object
|
||||
CREATE OR REPLACE VIEW v_lead AS
|
||||
PIVOT (
|
||||
SELECT e.id as entry_id, e.created_at, e.updated_at,
|
||||
f.name as field_name, ef.value
|
||||
FROM entries e
|
||||
JOIN entry_fields ef ON ef.entry_id = e.id
|
||||
JOIN fields f ON f.id = ef.field_id
|
||||
WHERE e.object_id = (SELECT id FROM objects WHERE name = 'lead')
|
||||
) ON field_name USING first(value);
|
||||
|
||||
COMMIT;
|
||||
-- Then: project filesystem structure (mkdir knowledge/lead/, write .object.yaml)
|
||||
```
|
||||
|
||||
### Section 5: CRM Patterns (adapted from `<crm_patterns>`)
|
||||
|
||||
Identical patterns, but with SQL examples instead of tool call examples:
|
||||
|
||||
- **Contact/Customer**: Full Name (text, required), Email Address (email, required), Phone Number (phone), Company (relation), Notes (richtext)
|
||||
- **Lead/Prospect**: Full Name, Email, Phone, Status (enum: New/Contacted/Qualified/Converted), Source (enum), Score (number), Assigned To (user), Notes (richtext)
|
||||
- **Company/Organization**: Company Name (text, required), Industry (enum), Website (text), Type (enum), Notes (richtext)
|
||||
- **Deal/Opportunity**: Deal Name, Amount (number), Stage (enum: Discovery/Proposal/Negotiation/Closed Won/Closed Lost), Close Date (date), Probability (number), Primary Contact (relation), Assigned To (user), Notes (richtext)
|
||||
- **Case/Project**: Case Number, Title, Client (relation), Status (enum), Priority (enum), Due Date (date), Assigned To (user), Notes (richtext)
|
||||
- **Property/Asset**: Address, Property Type (enum), Price (number), Status (enum), Square Footage (number), Bedrooms (number), Notes (richtext)
|
||||
- **Task/Activity** (kanban): Title, Description, Assigned To (user), Due Date, Status (enum: In Queue/In Progress/Done), Priority (enum), Notes (richtext). Use `default_view = 'kanban'` and auto-create Status + Assigned To fields.
|
||||
|
||||
### Section 6: Field Type Reference (adapted from `<field_type_selection>`)
|
||||
|
||||
- **text**: General text data, names, descriptions, addresses. Stored as VARCHAR.
|
||||
- **email**: Email addresses. Stored as VARCHAR. Agent validates format.
|
||||
- **phone**: Phone numbers. Stored as VARCHAR. Agent normalizes format.
|
||||
- **number**: Numeric values (prices, quantities, scores). Stored as VARCHAR in entry_fields (EAV), cast with `::NUMERIC` in queries.
|
||||
- **boolean**: Yes/no flags. Stored as "true"/"false" strings.
|
||||
- **date**: Dates. Stored as ISO 8601 strings. Cast with `::DATE` in queries.
|
||||
- **richtext**: Rich text for Notes fields. Content stored as entry_field value (plain text / markdown). Displayed in Notion-style editor in Dench UI.
|
||||
- **user**: Person/assignee fields. Stores member ID (e.g., "usr_abc123") from `workspace_context.yaml` members list. ALWAYS resolve member name -> ID before inserting.
|
||||
- **enum**: Dropdown/select fields. Field definition stores `enum_values` as JSON array. Entry stores the selected value string. `enum_colors` parallel array for styling. `enum_multiple = true` for multi-select (value stored as JSON array string).
|
||||
- **relation**: Links to entries in another object. Field stores `related_object_id` and `relationship_type`. Entry stores the related entry ID(s). `many_to_one` for single select, `many_to_many` for multi-select (stored as JSON array).
|
||||
|
||||
### Section 7: Naming Conventions and Data Handling (adapted from `<field_naming_conventions>` and `<data_handling_best_practices>`)
|
||||
|
||||
- Object names: singular, lowercase, one word ("lead" not "Leads")
|
||||
- Field names: human-readable, proper capitalization ("Email Address" not "email")
|
||||
- Be descriptive: "Phone Number" not "Phone"
|
||||
- Be consistent within an object
|
||||
- Validate email formats, normalize phone numbers, use ISO 8601 dates
|
||||
- Trim whitespace from all values
|
||||
- Check for duplicates before creating entries (SELECT before INSERT)
|
||||
|
||||
### Section 8: Error Handling (adapted from `<error_handling>`)
|
||||
|
||||
- `UNIQUE constraint` on INSERT -> item already exists, treat as SUCCESS (use `ON CONFLICT DO NOTHING` or `DO UPDATE`)
|
||||
- Protected object deletion -> check `immutable` column and `protected_objects` in workspace_context.yaml
|
||||
- Field type mismatch -> warn user before changing type on field with existing data
|
||||
- Missing required fields -> validate before INSERT, report which fields are missing
|
||||
|
||||
### Section 9: Document Management (new -- not in original CRM agent)
|
||||
|
||||
- Create document: `write` tool to create `dench/documents/<path>.md` + `INSERT INTO documents` with metadata
|
||||
- Document content is the .md file; DuckDB `documents` table tracks metadata (title, icon, nesting, order)
|
||||
- Cross-nesting: documents under objects (`parent_object_id`), objects under documents (`parent_document_id`)
|
||||
- Document tree mirrors filesystem: `dench/documents/projects/alpha.md` -> `file_path = 'projects/alpha.md'`
|
||||
|
||||
### Section 10: Protected Objects and Read-Only Context (new)
|
||||
|
||||
- Read `protected_objects` from `workspace_context.yaml` on startup
|
||||
- NEVER delete, rename, or modify immutable objects (People, Companies)
|
||||
- NEVER modify `workspace_context.yaml` -- it is read-only context from Dench
|
||||
- Members list is authoritative for user-type field resolution
|
||||
|
||||
### Section 11: Post-Mutation Checklist (MANDATORY -- revised after agent testing)
|
||||
|
||||
**Problem identified:** In testing, the agent correctly executed SQL (object + fields + entries) but skipped the filesystem projection (.object.yaml) and sometimes the PIVOT view. Root cause: the original skill mentioned these as afterthoughts ("Then project the filesystem...") with no concrete template or examples. The agent follows examples literally -- if examples only show SQL, it only does SQL.
|
||||
|
||||
**Fix:** Every workflow example now uses an explicit 3-step structure. The post-mutation section is now a checklist, not a description.
|
||||
|
||||
After creating/modifying an OBJECT or FIELDS:
|
||||
|
||||
- `CREATE OR REPLACE VIEW v_{object_name}` -- regenerate PIVOT view
|
||||
- `mkdir -p dench/knowledge/{object_name}/` -- create directory
|
||||
- Write `.object.yaml` with id, name, description, icon, default_view, entry_count, and full field list
|
||||
- Update WORKSPACE.md
|
||||
|
||||
After adding ENTRIES:
|
||||
|
||||
- Update `entry_count` in `.object.yaml`
|
||||
- Verify: `SELECT * FROM v_{object} LIMIT 5`
|
||||
|
||||
After deleting an OBJECT:
|
||||
|
||||
- `DROP VIEW IF EXISTS v_{object_name}`
|
||||
- `rm -rf dench/knowledge/{object_name}/`
|
||||
|
||||
The skill now includes:
|
||||
|
||||
- A concrete `.object.yaml` template with example content (previously missing entirely)
|
||||
- Full bash commands for generating `.object.yaml` from DuckDB queries
|
||||
- "Step 1 / Step 2 / Step 3" structure in every workflow example (SQL, Filesystem, Verify)
|
||||
- Critical Reminders section leads with "NEVER SKIP FILESYSTEM PROJECTION" and "THREE STEPS, EVERY TIME"
|
||||
|
||||
### Section 12: Critical Reminders (adapted from `<critical_reminders>`)
|
||||
|
||||
- Handle the ENTIRE CRM operation from analysis to SQL execution **to filesystem projection** to summary
|
||||
- **NEVER SKIP FILESYSTEM PROJECTION**: After any object mutation, create/update `.object.yaml` AND the `v_{object}` view. If missing, the object is invisible in the sidebar.
|
||||
- **THREE STEPS, EVERY TIME**: (1) SQL transaction, (2) filesystem projection, (3) verify
|
||||
- Always check existing data before creating (SELECT before INSERT, or ON CONFLICT)
|
||||
- Search proactively to provide better UX (PIVOT with filters)
|
||||
- Never assume field names -- always verify with `SELECT * FROM fields WHERE object_id = ?`
|
||||
- Extract ALL data from user messages
|
||||
- NOTES FIELDS: type "richtext", displayed in Notion editor
|
||||
- USER FIELDS: Resolve member name to ID from workspace_context.yaml BEFORE inserting
|
||||
- ENUM FIELDS: type "enum" with `enum_values` JSON array
|
||||
- RELATION FIELDS: type "relation" with `related_object_id`
|
||||
- KANBAN BOARDS: `default_view = 'kanban'`, auto-create Status and Assigned To fields
|
||||
- PROTECTED OBJECTS: Never delete objects listed in `workspace_context.yaml` `protected_objects`
|
||||
- ONE EXEC CALL: Batch related SQL in a single transaction whenever possible -- this is the entire point of the filesystem-first approach
|
||||
- ENTRY COUNT: After adding entries, update `entry_count` in `.object.yaml`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: S3 Sync Layer
|
||||
|
||||
**Two sync directions:**
|
||||
|
||||
1. **Sandbox -> S3:** After every database write or document save, sync changed files to S3
|
||||
2. **S3 -> Sandbox:** On sandbox startup, download the latest dench/ folder from S3
|
||||
|
||||
**Option A (simpler): Script-based sync**
|
||||
|
||||
- Add a `dench/sync.sh` script that the agent calls after mutations
|
||||
- Uses AWS CLI: `aws s3 sync dench/ s3://dench-workspaces/{orgId}/`
|
||||
- Credentials injected via ABAC (short-lived, scoped to org prefix)
|
||||
- Skill instructs the agent to run sync after writes
|
||||
|
||||
**Option B (more robust): inotifywait + background sync**
|
||||
|
||||
- Background process watches dench/ for changes
|
||||
- Debounced sync to S3 (e.g., 5s after last change)
|
||||
- More reliable but more infrastructure
|
||||
|
||||
**Recommendation:** Start with Option A. The agent is already making exec calls; one more `aws s3 sync` is trivial.
|
||||
|
||||
**New file:** `skills/dench/sync.sh` (bundled with the skill, referenced by SKILL.md)
|
||||
|
||||
**Sandbox startup hook:**
|
||||
|
||||
- Add to OpenClaw's sandbox initialization: download dench workspace from S3 if it exists
|
||||
- Could be a `BOOTSTRAP.md` instruction or a sandbox pre-warm step
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Dench Sidebar Integration
|
||||
|
||||
The Dench sidebar currently reads from Prisma (`getAll` objects + `getAll` documents). It needs a new data source for workspace-managed data.
|
||||
|
||||
**Approach:** Add a new tRPC endpoint that reads from S3 (or the synced PostgreSQL `fs_files` table) and returns the same tree structure the sidebar expects.
|
||||
|
||||
**Files to modify in Dench:**
|
||||
|
||||
- [src/lib/trpc/routers/objects.ts](file:///Users/kumareth/Documents/projects/dench/src/lib/trpc/routers/objects.ts) -- Add `getWorkspaceObjects` endpoint that queries the synced SQLite data (either by reading dench.db from S3, or from a PostgreSQL materialized view)
|
||||
- [src/lib/trpc/routers/documents.ts](file:///Users/kumareth/Documents/projects/dench/src/lib/trpc/routers/documents.ts) -- Add `getWorkspaceDocuments` endpoint
|
||||
- [src/components/app-sidebar.tsx](file:///Users/kumareth/Documents/projects/dench/src/components/app-sidebar.tsx) -- Merge workspace data into the `buildKnowledgeTree` function
|
||||
|
||||
**S3 -> PostgreSQL sync (Lambda):**
|
||||
|
||||
- When workspace.duckdb is uploaded to S3, a Lambda function:
|
||||
1. Downloads the DuckDB file
|
||||
2. Reads objects, fields, entries, documents tables
|
||||
3. Upserts into PostgreSQL tables (or a dedicated `workspace_objects`, `workspace_documents` table)
|
||||
- This mirrors the Fintool pattern: S3 source of truth, Lambda sync, PG for fast queries
|
||||
|
||||
**Alternative (simpler for v1):** Dench backend downloads workspace.duckdb from S3 on demand and queries it directly using `duckdb` Node.js bindings (`@duckdb/node-api`). No Lambda needed initially.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Real-Time Updates (Future)
|
||||
|
||||
- WebSocket notifications when S3 objects change (via S3 Event Notifications -> SNS -> WebSocket)
|
||||
- Dench sidebar auto-refreshes when workspace data changes
|
||||
- Bidirectional: Dench UI edits write back to S3, agent picks up changes on next sandbox start
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **Three-layer storage** (following Fintool pattern):
|
||||
- **YAML** (`workspace_context.yaml`): Read-only workspace identity, team members, integrations, defaults. Written by Dench, consumed by agent. Never modified by the agent -- data flows one-way from Dench UI -> S3 -> this file.
|
||||
- **DuckDB** (`workspace.duckdb`): Dense, relational CRM data (objects, fields, entries, statuses). Queryable via SQL with PIVOT, JOIN, FTS.
|
||||
- **Markdown** (`documents/*.md`): Rich document content. Agent uses `write`/`edit` tools directly. DuckDB tracks metadata (title, icon, nesting) while the file system holds the content.
|
||||
- **DuckDB over SQLite**: Native PIVOT/UNPIVOT is essential for the EAV data model (rendering custom-field entries as tables). PostgreSQL-compatible SQL dialect means generated SQL is portable to Dench's Supabase/Postgres. Native JSON type eliminates string parsing for enum_values/colors. Built-in CSV/Parquet import with auto-detection enables bulk CRM operations. FTS extension provides full-text search. ~50-100ms startup overhead is acceptable.
|
||||
- **nanoid 32 IDs**: Matches Dench's Supabase PostgreSQL nanoid format. Generated via a DuckDB macro using `generate_series` + `random()` over the standard nanoid alphabet (`0-9A-Za-z_-`). 32 chars provides 192 bits of entropy.
|
||||
- **Members in YAML not DuckDB**: The agent needs member IDs for "user" type fields (like "Assigned To"). Putting members in workspace_context.yaml means the agent reads them once on startup without a separate SQL query. The list changes infrequently (team changes, not per-request). Agent reads only, never writes.
|
||||
- **Integrations in YAML not DuckDB**: The agent needs to know what apps are connected and how fields map, but doesn't need to query this relationally. YAML gives the agent instant context about sync relationships. Agent reads only, never writes.
|
||||
- **Skill inject vs bootstrap file**: Using skill metadata `inject: true` is cleaner than adding a new bootstrap file type. It keeps the Dench instructions modular and version-controlled in the skills directory.
|
||||
- **One transaction per operation**: The skill instructs the agent to wrap multi-step operations in `BEGIN ... COMMIT` for atomicity. DuckDB supports full ACID transactions.
|
||||
138
.cursor/plans/file_chat_sidebar_368973cb.plan.md
Normal file
138
.cursor/plans/file_chat_sidebar_368973cb.plan.md
Normal file
@ -0,0 +1,138 @@
|
||||
---
|
||||
name: File chat sidebar
|
||||
overview: Add a collapsible chat sidebar to the workspace file view that lets users chat with the agent about the currently open file, with file-scoped sessions stored separately from the main chat list.
|
||||
todos:
|
||||
- id: extract-chat-panel
|
||||
content: Extract ChatPanel component from page.tsx with fileContext prop support
|
||||
status: in_progress
|
||||
- id: simplify-home-page
|
||||
content: Simplify page.tsx to render Sidebar + ChatPanel
|
||||
status: pending
|
||||
- id: tag-file-sessions
|
||||
content: Add filePath field to WebSessionMeta and filtering to GET /api/web-sessions
|
||||
status: pending
|
||||
- id: workspace-chat-sidebar
|
||||
content: Add collapsible ChatPanel sidebar to workspace page with file context
|
||||
status: pending
|
||||
- id: live-reload
|
||||
content: Re-fetch file content after agent finishes streaming to show edits live
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# File Chat Sidebar for Workspace
|
||||
|
||||
## Architecture
|
||||
|
||||
The workspace page layout changes from `[sidebar | content]` to `[sidebar | content | chat-panel]`. The chat panel reuses the same `useChat` + `ChatMessage` + session persistence logic from the main chat page.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph workspace [Workspace Page Layout]
|
||||
WS[WorkspaceSidebar_260px] --> MC[MainContent_flex1]
|
||||
MC --> CP[ChatPanel_380px]
|
||||
end
|
||||
subgraph storage [Session Storage]
|
||||
idx[index.json] -->|filePath field| fileScoped[File-scoped sessions]
|
||||
idx -->|no filePath| globalSessions[Global sessions]
|
||||
end
|
||||
CP -->|POST /api/chat| agent[Agent Runner]
|
||||
CP -->|file context in message| agent
|
||||
```
|
||||
|
||||
## Step 1: Extract `ChatPanel` from `page.tsx`
|
||||
|
||||
Extract the entire chat UI (messages list, input form, session management, `useChat`, streaming status) from [apps/web/app/page.tsx](apps/web/app/page.tsx) into a new reusable component:
|
||||
|
||||
**New file:** `apps/web/app/components/chat-panel.tsx`
|
||||
|
||||
```typescript
|
||||
type ChatPanelProps = {
|
||||
/** When set, scopes sessions to this file and prepends content as context */
|
||||
fileContext?: { path: string; content: string; filename: string };
|
||||
/** External session list (for sidebar session list) */
|
||||
sessions?: WebSessionMeta[];
|
||||
onSessionsChange?: () => void;
|
||||
/** Compact mode for workspace sidebar (no lobster, smaller empty state) */
|
||||
compact?: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
- Internally uses `useChat` from `@ai-sdk/react` with `DefaultChatTransport`
|
||||
- Manages its own `currentSessionId`, `savedMessageIdsRef`, `input`, etc. (same logic as `page.tsx`)
|
||||
- When `fileContext` is provided:
|
||||
- The first message in each session is prefixed with: `"[Context: file '{path}']\n\n{content}\n\n---\n\nUser question: {userText}"` -- subsequent messages just send `userText` as-is (the agent already has context from the conversation)
|
||||
- Session creation passes `filePath` to the API
|
||||
- Renders: header bar (with session title / status), scrollable message list using `ChatMessage`, error bar, input form
|
||||
|
||||
Then **simplify `page.tsx**` to just:
|
||||
|
||||
```tsx
|
||||
<Sidebar ... />
|
||||
<ChatPanel />
|
||||
```
|
||||
|
||||
## Step 2: Tag file sessions in web-sessions API
|
||||
|
||||
Modify [apps/web/app/api/web-sessions/route.ts](apps/web/app/api/web-sessions/route.ts):
|
||||
|
||||
- Add optional `filePath` to `WebSessionMeta` type
|
||||
- `POST` accepts `filePath` in the body and stores it in the index
|
||||
- `GET` accepts `?filePath=...` query param:
|
||||
- If `filePath` is set: returns only sessions where `meta.filePath === filePath`
|
||||
- If `filePath` is absent: returns only sessions where `meta.filePath` is falsy (excludes file-scoped sessions from the main list)
|
||||
|
||||
This single change means the main chat sidebar automatically stops showing file sessions, with no other code changes needed.
|
||||
|
||||
## Step 3: Add chat sidebar to workspace page
|
||||
|
||||
Modify [apps/web/app/workspace/page.tsx](apps/web/app/workspace/page.tsx):
|
||||
|
||||
- Add a collapsible right panel (380px) that renders `ChatPanel` with `fileContext`
|
||||
- The panel appears when a file or document is selected (content kinds: `"document"`, `"file"`)
|
||||
- Add a toggle button in the breadcrumbs bar to show/hide the chat panel
|
||||
- Pass the file's `content`, `path`, and `filename` as `fileContext`
|
||||
- The panel shows a mini session list at top (file-scoped sessions only) and the chat below
|
||||
|
||||
Layout becomes:
|
||||
|
||||
```
|
||||
+------------------+------------------------+-------------------+
|
||||
| WorkspaceSidebar | Content (flex-1) | ChatPanel (380px) |
|
||||
| (260px) | | [toggle] |
|
||||
+------------------+------------------------+-------------------+
|
||||
```
|
||||
|
||||
## Step 4: File-scoped session list inside ChatPanel
|
||||
|
||||
Inside `ChatPanel`, when `fileContext` is provided:
|
||||
|
||||
- Fetch sessions filtered by `filePath` via `GET /api/web-sessions?filePath=...`
|
||||
- Show a compact session list (just titles, clickable) above the messages area
|
||||
- "New chat" button creates a new file-scoped session
|
||||
- Selecting a session loads its messages (same logic as main page's `handleSessionSelect`)
|
||||
|
||||
## Step 5: Live file reload after agent edits
|
||||
|
||||
When the agent finishes streaming (status goes from `streaming` -> `ready`) and `fileContext` is provided:
|
||||
|
||||
- Re-fetch the file content via `GET /api/workspace/file?path=...`
|
||||
- Call a callback `onFileChanged?.(newContent)` so the workspace page can update `ContentState` without a full reload
|
||||
- This makes edits appear live in the file viewer/document view next to the chat
|
||||
|
||||
## Key files touched
|
||||
|
||||
| File | Change |
|
||||
| ---------------------------------------- | ------------------------------------ |
|
||||
| `apps/web/app/components/chat-panel.tsx` | **New** -- extracted chat UI + logic |
|
||||
| `apps/web/app/page.tsx` | Simplify to use `ChatPanel` |
|
||||
| `apps/web/app/api/web-sessions/route.ts` | Add `filePath` field + filtering |
|
||||
| `apps/web/app/workspace/page.tsx` | Add right chat sidebar with toggle |
|
||||
|
||||
## What stays the same (reused as-is)
|
||||
|
||||
- `ChatMessage` component -- no changes
|
||||
- `ChainOfThought` component -- no changes
|
||||
- `/api/chat` route + `agent-runner.ts` -- no changes (file context goes in the message text)
|
||||
- `/api/web-sessions/[id]` and `/api/web-sessions/[id]/messages` routes -- no changes
|
||||
- `useChat` from `@ai-sdk/react` -- same transport, same everything
|
||||
324
.cursor/plans/full_web_ui_redesign_9ad2e285.plan.md
Normal file
324
.cursor/plans/full_web_ui_redesign_9ad2e285.plan.md
Normal file
@ -0,0 +1,324 @@
|
||||
---
|
||||
name: Full Web UI Redesign
|
||||
overview: "Complete redesign of the OpenClaw web app (apps/web/) to match the Dench design system: switch from dark theme to light, adopt Instrument Serif + Inter fonts, port the Dench color palette and layout patterns, and rewrite every component and page from the ground up."
|
||||
todos:
|
||||
- id: foundation
|
||||
content: "Phase 1: Rewrite globals.css (light theme, HSL tokens, font imports) and layout.tsx (next/font, remove dark mode)"
|
||||
status: pending
|
||||
- id: landing
|
||||
content: "Phase 2: Rewrite app/page.tsx as Dench-style landing page (navbar, hero, demo sections, footer)"
|
||||
status: pending
|
||||
- id: layout-shell
|
||||
content: "Phase 3: Create app-navbar.tsx, rewrite workspace/page.tsx layout with top navbar + sidebar grid"
|
||||
status: pending
|
||||
- id: sidebar
|
||||
content: "Phase 4: Redesign workspace-sidebar.tsx and file-manager-tree.tsx to match Dench sidebar"
|
||||
status: pending
|
||||
- id: data-table
|
||||
content: "Phase 5: Redesign object-table.tsx with Dench-style toolbar, sticky headers, pagination, enum badges"
|
||||
status: pending
|
||||
- id: kanban
|
||||
content: "Phase 6: Redesign object-kanban.tsx with light cards, columns, board header"
|
||||
status: pending
|
||||
- id: entry-detail
|
||||
content: "Phase 7: Redesign entry-detail-modal.tsx as right-panel slide-out with properties list"
|
||||
status: pending
|
||||
- id: dashboard-chat
|
||||
content: "Phase 8a: Build dashboard view with greeting, centered chat input, suggestion chips, and animate-down-to-bottom Framer Motion layoutId transition"
|
||||
status: pending
|
||||
- id: chat
|
||||
content: "Phase 8b: Restyle chat-panel.tsx, chat-message.tsx, chain-of-thought.tsx for light theme + bottom composer"
|
||||
status: pending
|
||||
- id: remaining
|
||||
content: "Phase 9: Restyle all remaining components (breadcrumbs, document-view, file-viewer, database-viewer, empty-state, markdown, context-menu, slash-command, charts, etc.)"
|
||||
status: pending
|
||||
- id: deps
|
||||
content: "Phase 10: Add framer-motion dependency, verify fonts work, test build"
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Full Web UI Redesign — Dench Design System
|
||||
|
||||
## Current State
|
||||
|
||||
The OpenClaw web app is a **dark-themed** Next.js 15 app with:
|
||||
|
||||
- Dark background (`#0a0a0a`), dark surfaces (`#141414`), orange accent (`#e85d3a`)
|
||||
- Inter font only, no serif headings
|
||||
- Minimal homepage (centered text + CTA)
|
||||
- Workspace layout: left sidebar (260px) + content + optional chat panel
|
||||
- Custom table/kanban/viewer components, all dark-styled
|
||||
- Tailwind v4 (CSS-based config), no shadcn/ui
|
||||
|
||||
## Target State (Dench Design)
|
||||
|
||||
Per the screenshots and Dench source:
|
||||
|
||||
- **Light theme** — `bg-neutral-50` layout, white cards, `bg-neutral-100` sidebar/navbar
|
||||
- **Instrument Serif** for headings/titles, **Inter** for body text, **Lora** for branding
|
||||
- **Top navbar** (grid 3-col, with Dashboard/Workflows/Integrations tabs, org logo, user menu)
|
||||
- **Left sidebar** (260px, `bg-neutral-100`, collapsible knowledge tree with item counts)
|
||||
- **Data tables** with: sticky header, column borders, search bar, filter/column controls, enum badges, relation chips, pagination
|
||||
- **Kanban board** with rounded cards, priority badges, assignee avatars
|
||||
- **Entry detail** right-panel slide-out with property list
|
||||
- **Landing page** with hero section, demo sections, clean navigation bar
|
||||
- **Dashboard chat UX** — centered greeting ("Good evening, Kumar?") in Instrument Serif + centered chat input with suggestion chips; on first message, the input animates down to a bottom-docked composer via Framer Motion shared `layoutId` spring transition
|
||||
- HSL-based CSS variables (shadcn pattern), `--radius: 0.5rem`, neutral base color
|
||||
|
||||
## Architecture Decision: Tailwind v4
|
||||
|
||||
The OpenClaw app uses **Tailwind v4** (CSS-based config via `@import "tailwindcss"`), while Dench uses Tailwind v3 (JS config). We will keep Tailwind v4 but port all design tokens into `globals.css` using `@theme` blocks and CSS custom properties. No downgrade needed.
|
||||
|
||||
## Architecture Decision: Light + Dark Theme
|
||||
|
||||
Dench is light-only. We will use Dench's light palette as the `:root` default AND create a custom dark palette under `.dark` (class-based toggle via `<html class="dark">`). All components will use CSS variable references (e.g. `bg-background`, `text-foreground`, `border-border`) so they automatically adapt. No hardcoded hex/rgb in components.
|
||||
|
||||
**Light palette** (from Dench):
|
||||
|
||||
- `--background: 0 0% 96%` (neutral-50 feel)
|
||||
- `--foreground: 0 0% 3.9%`
|
||||
- `--card: 0 0% 100%` / `--card-foreground: 0 0% 3.9%`
|
||||
- `--muted: 0 0% 96.1%` / `--muted-foreground: 0 0% 45.1%`
|
||||
- `--border: 0 0% 89.8%`
|
||||
- `--primary: 0 0% 9%` / `--primary-foreground: 0 0% 98%`
|
||||
- `--accent: 0 0% 96.1%` / `--accent-foreground: 0 0% 9%`
|
||||
- `--destructive: 0 84.2% 60.2%`
|
||||
|
||||
**Dark palette** (custom, designed to complement Dench's light theme):
|
||||
|
||||
- `--background: 0 0% 7%` (#121212 — rich near-black, not pure black)
|
||||
- `--foreground: 0 0% 93%` (#ededed)
|
||||
- `--card: 0 0% 10%` (#1a1a1a) / `--card-foreground: 0 0% 93%`
|
||||
- `--muted: 0 0% 14%` (#242424) / `--muted-foreground: 0 0% 55%` (#8c8c8c)
|
||||
- `--border: 0 0% 18%` (#2e2e2e)
|
||||
- `--primary: 0 0% 93%` / `--primary-foreground: 0 0% 9%`
|
||||
- `--accent: 0 0% 16%` (#292929) / `--accent-foreground: 0 0% 93%`
|
||||
- `--destructive: 0 62% 55%`
|
||||
- Sidebar: `--sidebar-bg: 0 0% 9%` (#171717)
|
||||
- Navbar: similar to sidebar, subtle `border-b` at `--border`
|
||||
|
||||
Sidebar/navbar in dark mode use a slightly elevated surface (`#171717`) rather than pure background, for depth.
|
||||
|
||||
**Theme toggle:** add a sun/moon toggle button in the navbar (right side, near user avatar). Use `next-themes` or a simple `useEffect` + `localStorage` approach to persist preference and apply `.dark` class on `<html>`.
|
||||
|
||||
---
|
||||
|
||||
## Files to Change
|
||||
|
||||
### Phase 1 — Foundation (Theme, Fonts, Layout Shell)
|
||||
|
||||
**[app/globals.css](apps/web/app/globals.css)** — Complete rewrite:
|
||||
|
||||
- `:root` block: Dench's light-theme HSL palette (background, foreground, card, primary, secondary, muted, accent, destructive, border, ring, sidebar, chart-1 through chart-5)
|
||||
- `.dark` block: custom dark palette (see "Architecture Decision: Light + Dark Theme" above) — all same variable names, dark values
|
||||
- Add `@theme` block for Tailwind v4 mapping CSS vars to utility classes (`bg-background`, `text-foreground`, `border-border`, `bg-card`, `text-muted-foreground`, etc.)
|
||||
- Import Instrument Serif from Google Fonts
|
||||
- Add `.font-instrument` utility class
|
||||
- Port scrollbar, prose, editor, and slash-command styles using CSS variables (theme-aware, not hardcoded)
|
||||
- Port workflow state colors (`--workflow-active`, `--workflow-processing`, `--workflow-idle`)
|
||||
|
||||
**[app/layout.tsx](apps/web/app/layout.tsx)** — Rewrite:
|
||||
|
||||
- Import Inter and Lora via `next/font/google`
|
||||
- Set CSS variables `--font-corporate` and `--font-lora`
|
||||
- Default to light: no `className="dark"` on `<html>` (let theme provider handle it)
|
||||
- Apply `font-corporate` to `<body>`
|
||||
- Add `suppressHydrationWarning` on `<html>` for theme flash prevention
|
||||
- Add inline script or `next-themes` `ThemeProvider` for class-based dark mode toggle with `localStorage` persistence
|
||||
- Update metadata title/description to "Dench" branding
|
||||
|
||||
**New: `app/hooks/use-theme.ts**` — Simple theme hook:
|
||||
|
||||
- Read/write `localStorage` key `"theme"` (`"light"` | `"dark"` | `"system"`)
|
||||
- Apply/remove `.dark` class on `document.documentElement`
|
||||
- Expose `theme`, `setTheme`, `resolvedTheme` for components
|
||||
|
||||
### Phase 2 — Landing Page
|
||||
|
||||
**[app/page.tsx](apps/web/app/page.tsx)** — Full rewrite to match Dench landing:
|
||||
|
||||
- Sticky navigation bar (logo "Dench" in `font-lora`, Login button in rounded-full blue pill)
|
||||
- Hero section: "AI CRM" headline in `font-instrument font-bold`, subtext, "Get Started Free" CTA
|
||||
- Full-width CRM demo area (window chrome with traffic-light dots, scaled mock table)
|
||||
- Additional demo sections (workflow, kanban) — simplified versions
|
||||
- Footer with copyright, links
|
||||
|
||||
### Phase 3 — Workspace Layout Shell
|
||||
|
||||
**[app/workspace/page.tsx](apps/web/app/workspace/page.tsx)** — Rewrite layout structure:
|
||||
|
||||
- Add top `AppNavbar` component: `bg-neutral-100 border-b border-border shadow-[0_0_40px_rgba(0,0,0,0.05)]`
|
||||
- Left: org logo + "Powered by Dench" + org name in `font-instrument`
|
||||
- Center: tab navigation (Dashboard, Workflows, Integrations) with active state
|
||||
- Right: credit display, notification bell, sun/moon theme toggle, user avatar dropdown
|
||||
- Main area: `grid lg:grid-cols-[260px_1fr]` under navbar
|
||||
- Full height: `h-[100dvh] flex flex-col bg-neutral-50`
|
||||
- Content area: `overflow-y-auto overflow-x-hidden`
|
||||
- Replace all inline `style={{}}` dark colors with Tailwind classes
|
||||
|
||||
**New component: `app/components/workspace/app-navbar.tsx**` — Top navbar (extracted for reuse)
|
||||
|
||||
### Phase 4 — Sidebar Redesign
|
||||
|
||||
**[app/components/workspace/workspace-sidebar.tsx](apps/web/app/components/workspace/workspace-sidebar.tsx)** — Full rewrite:
|
||||
|
||||
- Background: `bg-sidebar` with `border-r border-border` (light: neutral-100, dark: #171717 via CSS var)
|
||||
- Shadow: theme-aware subtle shadow
|
||||
- Header: "KNOWLEDGE" section label in uppercase `text-[11px] font-medium tracking-wider text-muted-foreground`
|
||||
- Knowledge items: `text-[13px]`, hover `bg-accent`, `rounded-xl`
|
||||
- Item badges showing entry counts in `bg-muted border border-border` pills
|
||||
- Icons per item type (objects get custom icons, documents get doc icon)
|
||||
- Collapsible sections: KNOWLEDGE, CHATS, TELEPHONY
|
||||
- Bottom: "API Keys" link
|
||||
- Remove all inline `style={{}}` dark colors
|
||||
|
||||
**[app/components/workspace/file-manager-tree.tsx](apps/web/app/components/workspace/file-manager-tree.tsx)** — Restyle tree items:
|
||||
|
||||
- Light-theme hover states, active states matching `bg-neutral-200`
|
||||
- `text-[13px]` sizing, proper icon colors
|
||||
- Drag-and-drop visual indicators in light theme
|
||||
|
||||
### Phase 5 — Data Table Redesign
|
||||
|
||||
**[app/components/workspace/object-table.tsx](apps/web/app/components/workspace/object-table.tsx)** — Complete rewrite to match Dench data-table:
|
||||
|
||||
- Toolbar: object name in `font-instrument`, search input (`rounded-full shadow-[0_0_21px_0_rgba(0,0,0,0.07)]`), "Ask AI" button, Table/Board view toggle, refresh/import/filter/columns/+ Add buttons
|
||||
- Table header: `sticky top-0 z-30 bg-card border-b-2 border-border/80`, sortable columns with sort arrows
|
||||
- Table cells: `px-4 border-r border-border/30`, proper text truncation
|
||||
- Enum badges: colored pill style matching Dench (translucent background + border)
|
||||
- Relation chips: link icon + blue text
|
||||
- Row hover: `hover:bg-muted/50`
|
||||
- Pagination bar: "Showing 1 to N of N results", rows-per-page selector, page navigation
|
||||
- "..." action menu per row (right column)
|
||||
|
||||
### Phase 6 — Kanban Board Redesign
|
||||
|
||||
**[app/components/workspace/object-kanban.tsx](apps/web/app/components/workspace/object-kanban.tsx)** — Rewrite:
|
||||
|
||||
- Board header: view toggle (Board/Table), "Ask AI" button, search, "Group by" selector
|
||||
- Columns: `bg-muted/50 rounded-2xl border border-border/60`, column title + count badge
|
||||
- Cards: `bg-card rounded-xl border border-border/80 shadow-sm`
|
||||
- Card content: title, field badges (objective, risk profile), date, assignee avatar
|
||||
- "+ Add Item" at column bottom
|
||||
- "Drop cards here" empty column placeholder
|
||||
|
||||
### Phase 7 — Entry Detail Panel
|
||||
|
||||
**[app/components/workspace/entry-detail-modal.tsx](apps/web/app/components/workspace/entry-detail-modal.tsx)** — Redesign as right-panel slide-out:
|
||||
|
||||
- Takes ~40% of content width, pushes table left
|
||||
- Header: icon + title in large font, "Created Jan 12, 2026 at 12:47 PM" subtitle
|
||||
- "PROPERTIES" section label
|
||||
- Property rows: label (uppercase text-xs text-muted-foreground) + value
|
||||
- Relation fields show colored link chips
|
||||
- Enum fields show colored badges (matching table)
|
||||
- "Add a property" at bottom
|
||||
- Close button (>> icon) top-right
|
||||
|
||||
### Phase 8a — Dashboard Chat UX (Greeting + Animate-to-Bottom Input)
|
||||
|
||||
This is the hero interaction on the workspace "Dashboard" tab — a centered greeting with a chat input that transitions into the bottom-docked composer after the first message.
|
||||
|
||||
**How Dench implements it:**
|
||||
|
||||
- `DashboardHeader`: time-based greeting ("Good morning/afternoon/evening, Name?") with staggered word-by-word Framer Motion entrance (`y:20 → 0`, `blur(8px) → blur(0)`)
|
||||
- `DashboardChatbox`: centered TipTap input with placeholder "Build a workflow to automate your tasks", attach/voice/submit buttons, suggestion chips below (shuffled from a pool of ~27 templates, showing 7 in two rows)
|
||||
- **Layout animation:** both the centered input and the bottom composer share a Framer Motion `layoutId="chat-thread-composer"`. When `showStartComposer` flips to false after the first message, Framer Motion automatically animates the input from center to bottom with `transition={{ type: "spring", stiffness: 260, damping: 30 }}`
|
||||
|
||||
**New components to create:**
|
||||
|
||||
`app/components/workspace/dashboard-view.tsx` — Dashboard home view:
|
||||
|
||||
- Greeting in `font-instrument text-4xl` with time-based message + user name
|
||||
- Word-by-word staggered Framer Motion entrance animation
|
||||
- Centered chat input area below greeting
|
||||
|
||||
`app/components/workspace/dashboard-chatbox.tsx` — Centered input + chips:
|
||||
|
||||
- Rounded white card with subtle shadow, textarea/input with placeholder
|
||||
- Attach (paperclip), voice (mic), submit (arrow) icon buttons
|
||||
- Suggestion chip rows: 3 on first row, 4 on second row, each with icon + label + `rounded-xl` border
|
||||
- Accepts `layoutId` prop for shared layout animation
|
||||
- `mode` prop: `"dashboard"` (centered, with greeting) vs `"thread"` (same input but used within chat thread)
|
||||
- Entry animation: `opacity: 0, y: 20` → `opacity: 1, y: 0`, duration 0.8s
|
||||
|
||||
**Modify [app/workspace/page.tsx](apps/web/app/workspace/page.tsx):**
|
||||
|
||||
- When no content selected (Dashboard tab active), render `DashboardView`
|
||||
- On chat submit: transition to chat thread view
|
||||
- Use `LayoutGroup` from Framer Motion to wrap the dashboard + chat area
|
||||
- Track `showStartComposer` state: when true, show centered `DashboardChatbox`; when false, show messages + bottom `ChatComposer` — both sharing the same `layoutId`
|
||||
|
||||
**Prompt templates** (simplified set for OpenClaw):
|
||||
|
||||
- Follow-up Emails, Calendly Prep, Zoom Recap, Facebook Leads, Calendar Sync, Salesforce Sync, Intercom Chat (matching the Dench screenshot chips)
|
||||
|
||||
### Phase 8b — Chat & Message Restyling
|
||||
|
||||
**[app/components/chat-panel.tsx](apps/web/app/components/chat-panel.tsx)** — Restyle:
|
||||
|
||||
- Theme-aware background (`bg-card`), card-colored input area
|
||||
- Input: rounded border, subtle shadow, consistent with dashboard chatbox style
|
||||
- Bottom-docked composer with `layoutId` for shared animation
|
||||
- Session tabs in light theme
|
||||
- Tool call indicators in light theme
|
||||
- Send button styling (rounded, neutral)
|
||||
|
||||
**[app/components/chat-message.tsx](apps/web/app/components/chat-message.tsx)** — Restyle:
|
||||
|
||||
- Theme-aware message bubbles (user: `bg-muted`, assistant: `bg-card`)
|
||||
- Code blocks with `bg-muted`
|
||||
- Markdown rendering in light theme
|
||||
- Chain-of-thought styling update
|
||||
|
||||
**[app/components/chain-of-thought.tsx](apps/web/app/components/chain-of-thought.tsx)** — Light theme
|
||||
|
||||
### Phase 9 — Remaining Components
|
||||
|
||||
All components below: replace every hardcoded color (`style={{}}`, hex, rgb) with semantic Tailwind utilities (`bg-background`, `text-foreground`, `border-border`, `bg-card`, `text-muted-foreground`, `bg-muted`, etc.) so they work in both light and dark:
|
||||
|
||||
- **[breadcrumbs.tsx](apps/web/app/components/workspace/breadcrumbs.tsx)** — `text-muted-foreground`, `hover:text-foreground`
|
||||
- **[document-view.tsx](apps/web/app/components/workspace/document-view.tsx)** — `bg-card` background, `border-border`
|
||||
- **[file-viewer.tsx](apps/web/app/components/workspace/file-viewer.tsx)** — `bg-muted` code blocks, `text-foreground`
|
||||
- **[database-viewer.tsx](apps/web/app/components/workspace/database-viewer.tsx)** — `bg-card` tables, `bg-muted` query editor
|
||||
- **[empty-state.tsx](apps/web/app/components/workspace/empty-state.tsx)** — `text-muted-foreground` illustration
|
||||
- **[markdown-content.tsx](apps/web/app/components/workspace/markdown-content.tsx)** — Prose styles via CSS vars
|
||||
- **[markdown-editor.tsx](apps/web/app/components/workspace/markdown-editor.tsx)** — `bg-card` editor chrome
|
||||
- **[context-menu.tsx](apps/web/app/components/workspace/context-menu.tsx)** — `bg-card` dropdown, `border-border`
|
||||
- **[slash-command.tsx](apps/web/app/components/workspace/slash-command.tsx)** — `bg-card` command palette
|
||||
- **[inline-rename.tsx](apps/web/app/components/workspace/inline-rename.tsx)** — `bg-card` input, `border-border`
|
||||
- **[knowledge-tree.tsx](apps/web/app/components/workspace/knowledge-tree.tsx)** — Theme-aware tree styles
|
||||
- **[charts/](apps/web/app/components/charts/)** — All chart components: CSS var chart colors, `bg-card` panels
|
||||
- **[sidebar.tsx](apps/web/app/components/sidebar.tsx)** — Theme-aware (if still used)
|
||||
|
||||
### Phase 10 — Package Dependencies
|
||||
|
||||
**[package.json](apps/web/package.json)** — Add if needed:
|
||||
|
||||
- `framer-motion` (for landing page + dashboard chat animations)
|
||||
- `next-themes` (for dark/light toggle with `localStorage` + class-based switching, SSR-safe)
|
||||
- Verify `next/font/google` is available (bundled with Next.js)
|
||||
|
||||
---
|
||||
|
||||
## Key Design Tokens
|
||||
|
||||
- **Radius:** `0.5rem` base
|
||||
- **Primary font:** Inter via `next/font/google`
|
||||
- **Heading font:** Instrument Serif via Google Fonts import
|
||||
- **Brand font:** Lora via `next/font/google`
|
||||
- **Sidebar width:** 260px
|
||||
- **Shadows (light):** `shadow-[0_0_40px_rgba(0,0,0,0.05)]` (sidebar/navbar), `shadow-[0_0_21px_0_rgba(0,0,0,0.07)]` (search)
|
||||
- **Shadows (dark):** `shadow-[0_0_40px_rgba(0,0,0,0.2)]` (sidebar/navbar), `shadow-[0_0_21px_0_rgba(0,0,0,0.15)]` (search)
|
||||
|
||||
## Component Styling Rules (Theme-Safe)
|
||||
|
||||
All components MUST use semantic CSS variable-backed utilities — never hardcoded colors:
|
||||
|
||||
- `bg-background` / `bg-card` / `bg-muted` / `bg-accent` — not `bg-white`, `bg-neutral-50`, `bg-[#1a1a1a]`
|
||||
- `text-foreground` / `text-muted-foreground` / `text-card-foreground` — not `text-black`, `text-gray-500`
|
||||
- `border-border` — not `border-neutral-200`, `border-[#2e2e2e]`
|
||||
- `bg-sidebar` for sidebar/navbar backgrounds
|
||||
- For shadows that differ between themes: use a CSS variable `--shadow-subtle` / `--shadow-elevated` or conditional `dark:shadow-*` utilities
|
||||
- Exceptions: Dench-specific decorative elements (landing page traffic-light dots, brand colors) can use fixed values
|
||||
132
.cursor/plans/interactive_subagent_panel_fb79f5b8.plan.md
Normal file
132
.cursor/plans/interactive_subagent_panel_fb79f5b8.plan.md
Normal file
@ -0,0 +1,132 @@
|
||||
---
|
||||
name: Interactive Subagent Panel
|
||||
overview: Make subagents fully independent of the parent agent's event stream. Each subagent gets its own gateway subscription immediately on registration. Then make the subagent panel interactive with stop, send, queue matching the main chat, using unified API routes.
|
||||
todos:
|
||||
- id: decouple-subagent
|
||||
content: "SubagentRunManager: subscribe immediately on registration, remove routeRawEvent/preRegBuffer/activateGatewayFallback"
|
||||
status: pending
|
||||
- id: remove-parent-routing
|
||||
content: "active-runs.ts: remove subagent event routing from parent NDJSON stream"
|
||||
status: pending
|
||||
- id: srm-methods
|
||||
content: "SubagentRunManager: add persistUserMessage(), reactivateSubagent(), abortSubagent(), spawnSubagentMessage()"
|
||||
status: pending
|
||||
- id: unify-chat-route
|
||||
content: Extend POST /api/chat to dispatch to subagent flow when sessionKey is a subagent key
|
||||
status: pending
|
||||
- id: unify-stop-route
|
||||
content: Extend POST /api/chat/stop to dispatch to SubagentRunManager when sessionKey is a subagent key
|
||||
status: pending
|
||||
- id: unify-stream-route
|
||||
content: Extend GET /api/chat/stream to dispatch to SubagentRunManager when sessionKey is a subagent key
|
||||
status: pending
|
||||
- id: parser-turns
|
||||
content: Extend createStreamParser to handle user-message events as turn boundaries
|
||||
status: pending
|
||||
- id: panel-rewrite
|
||||
content: Rewrite SubagentPanel with ChatEditor, send/stop/queue, multi-turn conversation
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Interactive Subagent Panel
|
||||
|
||||
## Core Problem
|
||||
|
||||
Subagent events piggyback on the parent agent's CLI NDJSON stream. When the parent finishes (spawns subagents then exits), the stream dies and subagent events stop flowing. The `activateGatewayFallback()` partially compensates but loses early events.
|
||||
|
||||
The root cause is architectural: subagents are treated as appendages of the parent. They should be independent sessions.
|
||||
|
||||
## Architecture Change
|
||||
|
||||
A subagent is just an agent session. The only link to the parent is the completion announcement. Each subagent gets its own gateway subscription from the moment it's registered.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph before [Current: Coupled]
|
||||
GW1[Gateway] --> ParentCLI[Parent CLI stdout]
|
||||
ParentCLI --> ARM1[ActiveRunManager]
|
||||
ParentCLI -.->|"routeRawEvent<br/>(filtered by runId, never arrives)"| SRM1[SubagentRunManager]
|
||||
ARM1 -.->|"activateGatewayFallback<br/>(after parent exits, loses early events)"| SRM1
|
||||
end
|
||||
|
||||
subgraph after [New: Independent]
|
||||
GW2[Gateway] --> ParentCLI2[Parent CLI stdout]
|
||||
GW2 --> SubProc[Subscribe Process per subagent]
|
||||
ParentCLI2 --> ARM2[ActiveRunManager]
|
||||
SubProc --> SRM2[SubagentRunManager]
|
||||
end
|
||||
```
|
||||
|
||||
## Phase 1: Decouple Subagents
|
||||
|
||||
### 1. SubagentRunManager ([subagent-runs.ts](apps/web/lib/subagent-runs.ts))
|
||||
|
||||
**In `registerSubagent()` (line 266-270)**: replace the comment with:
|
||||
|
||||
```typescript
|
||||
if (run.status === "running") {
|
||||
startSubagentSubscribeStream(run);
|
||||
}
|
||||
```
|
||||
|
||||
Each subagent immediately gets its own subscribe process (`spawnAgentSubscribeProcess`) that connects to the gateway and streams events for that subagent's sessionKey. No dependency on the parent's stream.
|
||||
|
||||
**Remove dead code:**
|
||||
|
||||
- `routeRawEvent()` (lines 419-448) -- no longer called; events come from per-subagent subscribe processes
|
||||
- `preRegBuffer` from the registry type and `getRegistry()` -- no pre-registration buffering needed; the subscribe process handles everything
|
||||
- `activateGatewayFallback()` (lines 368-375) -- no longer needed; subscription starts at registration time
|
||||
|
||||
### 2. active-runs.ts ([active-runs.ts](apps/web/lib/active-runs.ts))
|
||||
|
||||
**Remove subagent event routing from the parent NDJSON handler**: the block that checks `ev.sessionKey !== parentSessionKey` and calls `routeSubagentEvent()` -- delete it entirely. Parent NDJSON stream now only processes parent events. No imports of `routeRawEvent`, `ensureRegisteredFromDisk`, `hasActiveSubagent` from subagent-runs needed for routing.
|
||||
|
||||
**Remove `activateGatewayFallback()` call** from the parent exit handler.
|
||||
|
||||
**Keep**: the `waiting-for-subagents` state transition and `hasRunningSubagentsForParent()` check -- the parent still needs to know when all subagents finish so it can finalize.
|
||||
|
||||
### 3. No CLI changes needed
|
||||
|
||||
The `runId` filter in `src/commands/agent.ts` is correct -- the parent's NDJSON stream should only contain parent events. Subagent events flow independently through their own subscribe processes.
|
||||
|
||||
## Phase 2: Unified API Routes
|
||||
|
||||
Same primitive, same routes. Dispatch based on session key format (`:subagent:` vs `:web:`).
|
||||
|
||||
### 4. SubagentRunManager: interactive methods
|
||||
|
||||
- `**persistUserMessage(sessionKey, msg)**` -- append `{type: "user-message", text, id}` to event buffer + JSONL
|
||||
- `**reactivateSubagent(sessionKey)**` -- set status to `"running"`, clear `endedAt`, restart subscribe process
|
||||
- `**abortSubagent(sessionKey)**` -- spawn CLI `gateway call chat.abort`, mark `"error"`, signal subscribers
|
||||
- `**spawnSubagentMessage(sessionKey, message)**` -- spawn CLI `gateway call agent --params '{"message":"...", "sessionKey":"...", "lane":"subagent", ...}'`
|
||||
|
||||
### 5. Extend `POST /api/chat` ([route.ts](apps/web/app/api/chat/route.ts))
|
||||
|
||||
If `sessionKey` contains `:subagent:`:
|
||||
|
||||
- Reject if running (409)
|
||||
- `persistUserMessage()` + `reactivateSubagent()` + `spawnSubagentMessage()`
|
||||
- Subscribe via `subscribeToSubagent(sessionKey, ..., { replay: false })` for SSE response
|
||||
|
||||
Otherwise: existing parent flow.
|
||||
|
||||
### 6. Extend `POST /api/chat/stop` ([stop/route.ts](apps/web/app/api/chat/stop/route.ts))
|
||||
|
||||
Accept `sessionKey`. If `:subagent:`: `abortSubagent()`. Otherwise: `abortRun()`.
|
||||
|
||||
### 7. Extend `GET /api/chat/stream` ([stream/route.ts](apps/web/app/api/chat/stream/route.ts))
|
||||
|
||||
Accept `sessionKey`. If `:subagent:`: lazy-register from disk, `ensureSubagentStreamable()`, `subscribeToSubagent()`. Otherwise: existing parent flow.
|
||||
|
||||
Remove `apps/web/app/api/chat/subagent-stream/route.ts` after migration.
|
||||
|
||||
## Phase 3: Frontend
|
||||
|
||||
### 8. Stream parser turn boundaries ([chat-panel.tsx](apps/web/app/components/chat-panel.tsx))
|
||||
|
||||
Add `user-message` to `ParsedPart` and `createStreamParser` for multi-turn subagent conversations.
|
||||
|
||||
### 9. Rewrite SubagentPanel ([subagent-panel.tsx](apps/web/app/components/subagent-panel.tsx))
|
||||
|
||||
Full ChatPanel-like experience: ChatEditor, send/stop/queue buttons, AttachmentStrip, message queue, auto-scroll. Uses the unified routes with `sessionKey`.
|
||||
245
.cursor/plans/mention_search_and_entry_modals_8a47eefc.plan.md
Normal file
245
.cursor/plans/mention_search_and_entry_modals_8a47eefc.plan.md
Normal file
@ -0,0 +1,245 @@
|
||||
---
|
||||
name: Mention Search and Entry Modals
|
||||
overview: Overhaul the tiptap @ mention system to search files AND object entries with fast fuzzy matching, fix the broken link system with canonical internal URIs, and add query-param-based entry detail modals accessible from table rows and @ mention links.
|
||||
todos:
|
||||
- id: search-index-api
|
||||
content: "Phase 1: Build GET /api/workspace/search-index endpoint -- returns flat JSON of all files + all entries from every DuckDB object with display fields"
|
||||
status: completed
|
||||
- id: fuse-search-hook
|
||||
content: "Phase 2: Add fuse.js dep, create useSearchIndex() hook in lib/search-index.ts, build Fuse instance on fetch"
|
||||
status: completed
|
||||
- id: mention-upgrade
|
||||
content: "Phase 2b: Rewrite createFileMention -> createWorkspaceMention in slash-command.tsx, wire up Fuse search, update CommandList for entry results"
|
||||
status: completed
|
||||
- id: link-utilities
|
||||
content: "Phase 3: Create lib/workspace-links.ts with parseWorkspaceLink/buildEntryLink, define @entry/{object}/{id} format"
|
||||
status: completed
|
||||
- id: link-insert-fix
|
||||
content: "Phase 3b: Update mention insert commands to use canonical link format; update markdown-editor.tsx link click handler to parse workspace links"
|
||||
status: completed
|
||||
- id: entry-api
|
||||
content: "Phase 4: Build GET /api/workspace/objects/[name]/entries/[id] endpoint for single entry data"
|
||||
status: completed
|
||||
- id: entry-modal
|
||||
content: "Phase 4b: Build EntryDetailModal component with full field display, relation links, close/escape handling"
|
||||
status: completed
|
||||
- id: query-param-routing
|
||||
content: "Phase 4c: Wire ?entry= query param in workspace/page.tsx -- render modal, sync URL on open/close, handle initial load"
|
||||
status: completed
|
||||
- id: table-row-click
|
||||
content: "Phase 4d: Make object-table.tsx rows clickable with onEntryClick prop, wire to entry param in parent"
|
||||
status: completed
|
||||
- id: fix-nav-bugs
|
||||
content: "Phase 5: Fix path resolution in onNavigate (knowledge/ prefix handling), sync URL bar with activePath via router.replace()"
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# @ Mention Search, Link System, and Entry Detail Modals
|
||||
|
||||
## Current Problems
|
||||
|
||||
1. **@ mention only searches files** -- entries (rows) in objects (tables) are invisible to the search. Uses naive `String.includes()` with no fuzzy matching.
|
||||
2. **File links are broken** -- `buildFileItems` in [slash-command.tsx](apps/web/app/components/workspace/slash-command.tsx) sets `href: node.path` (e.g. `knowledge/leads`), but `onNavigate` in [workspace/page.tsx](apps/web/app/workspace/page.tsx) calls `findNode(tree, path)` which may not resolve correctly depending on whether the path includes `knowledge/` prefix. No canonical link format exists.
|
||||
3. **No entry detail view** -- table rows in [object-table.tsx](apps/web/app/components/workspace/object-table.tsx) are not clickable. There is no modal, route, or UI to view a single entry.
|
||||
4. **No URL-based navigation** -- navigating to content is purely callback-based (state updates). The URL only updates on initial load via `?path=`. Sharing a link to a specific entry is impossible.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph searchIndex [Search Index - API]
|
||||
searchEndpoint["GET /api/workspace/search-index"]
|
||||
duckdb["DuckDB: objects + entries + fields"]
|
||||
tree["Filesystem: tree nodes"]
|
||||
searchEndpoint --> duckdb
|
||||
searchEndpoint --> tree
|
||||
end
|
||||
|
||||
subgraph clientSearch [Client-Side Fuzzy Search]
|
||||
fuseIndex["Fuse.js index - built once on load"]
|
||||
mentionPlugin["@ Mention Plugin"]
|
||||
fuseIndex --> mentionPlugin
|
||||
end
|
||||
|
||||
subgraph routing [Query Param Routing]
|
||||
pathParam["?path=knowledge/leads"]
|
||||
entryParam["&entry=abc123"]
|
||||
pathParam --> workspacePage["Workspace Page"]
|
||||
entryParam --> entryModal["Entry Detail Modal"]
|
||||
end
|
||||
|
||||
searchEndpoint -->|"JSON: files + entries"| fuseIndex
|
||||
mentionPlugin -->|"insert link"| internalLink["dench://entry/leads/abc123"]
|
||||
internalLink -->|"onNavigate resolves"| routing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Search Index API Endpoint
|
||||
|
||||
**New file:** `apps/web/app/api/workspace/search-index/route.ts`
|
||||
|
||||
Returns a flat JSON array of all searchable items -- both files and entry rows from every object. The client fetches this once on workspace load and rebuilds on tree changes (SSE watcher already triggers refreshes).
|
||||
|
||||
```typescript
|
||||
type SearchIndexItem = {
|
||||
// Shared
|
||||
id: string; // unique key (path for files, entryId for entries)
|
||||
label: string; // display text (filename or display-field value)
|
||||
sublabel?: string; // secondary text (path for files, object name for entries)
|
||||
kind: "file" | "object" | "entry";
|
||||
icon?: string;
|
||||
|
||||
// For entries
|
||||
objectName?: string;
|
||||
entryId?: string;
|
||||
fields?: Record<string, string>; // first 3-4 field key-value pairs for preview
|
||||
|
||||
// For files/objects
|
||||
path?: string;
|
||||
nodeType?: "document" | "folder" | "file" | "report" | "database";
|
||||
};
|
||||
```
|
||||
|
||||
**Server implementation:**
|
||||
|
||||
- Reuse existing `buildTree()` from [tree/route.ts](apps/web/app/api/workspace/tree/route.ts) for files
|
||||
- For entries: query every object from DuckDB, resolve display fields, and return flattened entries with their first few field values as searchable text
|
||||
- SQL: `SELECT * FROM v_{objectName} LIMIT 500` per object (capped for perf)
|
||||
- Cache with short TTL or rely on client refetch on SSE tree-change events
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Client-Side Fuzzy Search with Fuse.js
|
||||
|
||||
**New file:** `apps/web/lib/search-index.ts`
|
||||
|
||||
A React hook `useSearchIndex()` that:
|
||||
|
||||
1. Fetches `/api/workspace/search-index` once on mount
|
||||
2. Builds a `Fuse` instance over the items, keyed on `label`, `sublabel`, and `fields` values
|
||||
3. Exposes a `search(query: string): SearchIndexItem[]` function
|
||||
4. Rebuilds when tree changes (listen to same SSE watcher signal)
|
||||
|
||||
**Fuse.js config:**
|
||||
|
||||
- Keys: `["label", "sublabel", "objectName", "fields.*"]` with weighted scoring
|
||||
- Threshold: ~0.4 (tolerant fuzzy)
|
||||
- Max results: 20
|
||||
|
||||
**Update [slash-command.tsx](apps/web/app/components/workspace/slash-command.tsx):**
|
||||
|
||||
Replace `createFileMention(tree)` with `createWorkspaceMention(searchFn)`:
|
||||
|
||||
- `items` callback uses the Fuse search function instead of `flattenTree` + `.includes()`
|
||||
- Results are grouped: show files first, then entries grouped by object
|
||||
- The `CommandList` component gets a minor update to show entry items differently (object badge, field preview)
|
||||
|
||||
**Dep:** Add `fuse.js` to `apps/web/package.json` -- lightweight (~7KB gzipped), runs entirely client-side, no wasm.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Canonical Internal Link Format
|
||||
|
||||
Define a URI scheme for internal workspace links, parsed in a single utility:
|
||||
|
||||
**New file:** `apps/web/lib/workspace-links.ts`
|
||||
|
||||
```typescript
|
||||
// Canonical formats:
|
||||
// Files: "knowledge/path/to/doc.md" (relative path, unchanged)
|
||||
// Objects: "knowledge/leads" (relative path, unchanged)
|
||||
// Entries: "@entry/leads/abc123" (new: @entry/{objectName}/{entryId})
|
||||
|
||||
type WorkspaceLink =
|
||||
| { kind: "file"; path: string }
|
||||
| { kind: "object"; objectName: string; path: string }
|
||||
| { kind: "entry"; objectName: string; entryId: string };
|
||||
|
||||
function parseWorkspaceLink(href: string): WorkspaceLink;
|
||||
function buildEntryLink(objectName: string, entryId: string): string;
|
||||
function buildFileLink(path: string): string;
|
||||
function workspaceLinkToUrl(link: WorkspaceLink): string;
|
||||
// Returns: "/workspace?path=knowledge/leads" or "/workspace?path=knowledge/leads&entry=abc123"
|
||||
```
|
||||
|
||||
**Update mention insert command** in `slash-command.tsx`:
|
||||
|
||||
- File items: keep `href: node.path` (unchanged)
|
||||
- Entry items: use `href: "@entry/{objectName}/{entryId}"`, display text = display field value
|
||||
|
||||
**Update link click handler** in [markdown-editor.tsx](apps/web/app/components/workspace/markdown-editor.tsx) (lines 215-238):
|
||||
|
||||
- Parse the href with `parseWorkspaceLink()`
|
||||
- For file/object links: `onNavigate(link.path)` (as today, but path resolution fixed)
|
||||
- For entry links: `onNavigate("@entry/leads/abc123")` -- parent resolves to modal
|
||||
|
||||
**Update workspace page** `onNavigate` handler:
|
||||
|
||||
- Parse the link, and if it's an `@entry/...` link, set query params to open the entry modal
|
||||
- If it's a file/object link, do `findNode(tree, path)` as before but with proper path normalization
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Entry Detail Modal with Query Param Routing
|
||||
|
||||
**URL pattern:** `/workspace?path=knowledge/leads&entry=abc123`
|
||||
|
||||
When `entry` query param is present, render an `EntryDetailModal` as an overlay on top of the current workspace content.
|
||||
|
||||
**New file:** `apps/web/app/components/workspace/entry-detail-modal.tsx`
|
||||
|
||||
- Full-screen overlay/side panel with entry data
|
||||
- Fetches entry data from a new API endpoint or uses already-loaded object data
|
||||
- Renders all fields with proper type-specific display (reuse `CellValue` from [object-table.tsx](apps/web/app/components/workspace/object-table.tsx))
|
||||
- Relation fields are clickable -- clicking opens the related entry's modal (updates `entry` param)
|
||||
- Close button / Escape / clicking backdrop clears the `entry` param
|
||||
- URL is shareable and bookmarkable
|
||||
|
||||
**New API:** `GET /api/workspace/objects/[name]/entries/[id]`
|
||||
|
||||
- Returns a single entry with all field values, resolved relation labels, and reverse relations
|
||||
- Lightweight endpoint for the modal to fetch data without loading the full object
|
||||
|
||||
**Update [workspace/page.tsx](apps/web/app/workspace/page.tsx):**
|
||||
|
||||
- Read `entry` from `searchParams`
|
||||
- When present, render `<EntryDetailModal>` on top of current content
|
||||
- Update URL using `router.replace()` (shallow) when opening/closing modal -- no full page navigation
|
||||
- On initial load, if both `path` and `entry` are set, load the object AND open the modal
|
||||
|
||||
**Update [object-table.tsx](apps/web/app/components/workspace/object-table.tsx):**
|
||||
|
||||
- Make table rows clickable
|
||||
- `onClick` handler calls a new `onEntryClick(entryId)` prop
|
||||
- Parent sets the `entry` query param, opening the modal
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Fix Existing Link Navigation Bugs
|
||||
|
||||
**Bug 1:** Path resolution mismatch -- `node.path` from tree includes `knowledge/` prefix but link resolution sometimes strips it. Fix: normalize paths in `findNode()` and `onNavigate()` using the new `parseWorkspaceLink()`.
|
||||
|
||||
**Bug 2:** The `onNavigate` callback in `DocumentView` does `findNode(tree, path)` but the href from a markdown link may not exactly match a tree path (e.g., `leads` vs `knowledge/leads`). Fix: add fallback resolution that tries with/without `knowledge/` prefix, and tries matching the last segment against object names.
|
||||
|
||||
**Bug 3:** No URL update when navigating -- selecting a sidebar item or clicking a link updates state but not the URL bar. Fix: use `router.replace()` to keep URL in sync with `activePath` so links are shareable.
|
||||
|
||||
---
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
- [apps/web/app/components/workspace/slash-command.tsx](apps/web/app/components/workspace/slash-command.tsx) -- Replace file-only search with unified fuzzy search over files + entries
|
||||
- [apps/web/app/components/workspace/markdown-editor.tsx](apps/web/app/components/workspace/markdown-editor.tsx) -- Fix link click handler to support entry links
|
||||
- [apps/web/app/workspace/page.tsx](apps/web/app/workspace/page.tsx) -- Add entry modal rendering, fix URL sync, fix `onNavigate` resolution
|
||||
- [apps/web/app/components/workspace/object-table.tsx](apps/web/app/components/workspace/object-table.tsx) -- Make rows clickable with `onEntryClick`
|
||||
|
||||
## New Files
|
||||
|
||||
- `apps/web/app/api/workspace/search-index/route.ts` -- Search index endpoint
|
||||
- `apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts` -- Single entry endpoint
|
||||
- `apps/web/lib/workspace-links.ts` -- Link parsing/building utilities
|
||||
- `apps/web/lib/search-index.ts` -- Client-side Fuse.js hook
|
||||
- `apps/web/app/components/workspace/entry-detail-modal.tsx` -- Entry modal component
|
||||
258
.cursor/plans/reports_analytics_layer_d6cf8500.plan.md
Normal file
258
.cursor/plans/reports_analytics_layer_d6cf8500.plan.md
Normal file
@ -0,0 +1,258 @@
|
||||
---
|
||||
name: Reports Analytics Layer
|
||||
overview: "Add a generative-UI reports feature to the Dench web app: the agent creates JSON report definitions with SQL queries and chart configs, rendered live via Recharts in both the workspace view and inline in chat as artifacts."
|
||||
todos:
|
||||
- id: recharts-dep
|
||||
content: Add recharts dependency to apps/web/package.json
|
||||
status: completed
|
||||
- id: chart-panel
|
||||
content: Create ChartPanel component supporting bar/line/area/pie/donut/radar/scatter/funnel via Recharts with CSS variable theming
|
||||
status: completed
|
||||
- id: filter-bar
|
||||
content: Create FilterBar component with dateRange, select, multiSelect filter types; fetches options via SQL
|
||||
status: completed
|
||||
- id: report-viewer
|
||||
content: "Create ReportViewer component: loads report config, executes panel SQL with filter injection, renders ChartPanel grid"
|
||||
status: completed
|
||||
- id: report-execute-api
|
||||
content: Create POST /api/workspace/reports/execute route for SQL execution with filter clause injection
|
||||
status: completed
|
||||
- id: workspace-report-type
|
||||
content: Add report content type to workspace/page.tsx ContentState + ContentRenderer; detect .report.json in tree API
|
||||
status: completed
|
||||
- id: knowledge-tree-report
|
||||
content: Add report node type + icon to knowledge-tree.tsx and tree/route.ts
|
||||
status: completed
|
||||
- id: chat-artifact
|
||||
content: Modify chat-message.tsx to detect report-json fenced blocks and render inline ReportCard
|
||||
status: completed
|
||||
- id: report-card
|
||||
content: Create ReportCard component for compact inline chart rendering in chat with Pin/Open actions
|
||||
status: completed
|
||||
- id: sidebar-reports
|
||||
content: Add Reports section to sidebar.tsx listing .report.json files from workspace tree
|
||||
status: completed
|
||||
- id: skill-update
|
||||
content: Add Section 13 (Report Generation) to skills/dench/SKILL.md with schema, examples, and agent instructions
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Reports / Analytics Layer for Dench Workspace
|
||||
|
||||
## Architecture
|
||||
|
||||
Reports are **JSON config files** (`.report.json`) that declare SQL queries to run against `workspace.duckdb` and how to visualize the results. The web app executes SQL at render time (live data), renders charts via Recharts, and supports interactive filters.
|
||||
|
||||
Reports surface in **three places**:
|
||||
|
||||
1. **Workspace view** -- full-page report dashboard when clicking a report in the tree
|
||||
2. **Chat** -- inline chart artifact when the agent generates a report in conversation
|
||||
3. **Sidebar** -- reports listed under a new "Reports" section
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph agent [Agent]
|
||||
skill[Dench Skill]
|
||||
skill -->|"write .report.json"| fs[Filesystem]
|
||||
skill -->|"emit report block in text"| chat[Chat Stream]
|
||||
end
|
||||
|
||||
subgraph web [Web App]
|
||||
fs --> treeAPI["/api/workspace/tree"]
|
||||
fs --> reportAPI["/api/workspace/reports"]
|
||||
reportAPI --> execAPI["/api/workspace/query"]
|
||||
execAPI --> duckdb["workspace.duckdb"]
|
||||
|
||||
treeAPI --> sidebar[Sidebar + KnowledgeTree]
|
||||
reportAPI --> workspaceView[ReportViewer in Workspace]
|
||||
chat --> chatUI["ChatMessage with inline ReportCard"]
|
||||
chatUI --> execAPI
|
||||
workspaceView --> recharts[Recharts Charts]
|
||||
chatUI --> recharts
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Report Definition Format
|
||||
|
||||
Stored as `.report.json` files in `dench/reports/` (or nested under any knowledge path). Agent generates these via the `write` tool.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Deals Pipeline",
|
||||
"description": "Revenue breakdown by stage and rep",
|
||||
"panels": [
|
||||
{
|
||||
"id": "deals-by-stage",
|
||||
"title": "Deal Count by Stage",
|
||||
"type": "bar",
|
||||
"sql": "SELECT \"Stage\", COUNT(*) as count FROM v_deal GROUP BY \"Stage\" ORDER BY count DESC",
|
||||
"mapping": { "xAxis": "Stage", "yAxis": ["count"] },
|
||||
"size": "half"
|
||||
},
|
||||
{
|
||||
"id": "revenue-trend",
|
||||
"title": "Revenue Over Time",
|
||||
"type": "area",
|
||||
"sql": "SELECT DATE_TRUNC('month', created_at) as month, SUM(\"Amount\"::NUMERIC) as revenue FROM v_deal GROUP BY month ORDER BY month",
|
||||
"mapping": { "xAxis": "month", "yAxis": ["revenue"] },
|
||||
"size": "half"
|
||||
},
|
||||
{
|
||||
"id": "stage-distribution",
|
||||
"title": "Stage Distribution",
|
||||
"type": "pie",
|
||||
"sql": "SELECT \"Stage\", COUNT(*) as count FROM v_deal GROUP BY \"Stage\"",
|
||||
"mapping": { "nameKey": "Stage", "valueKey": "count" },
|
||||
"size": "third"
|
||||
}
|
||||
],
|
||||
"filters": [
|
||||
{
|
||||
"id": "date-range",
|
||||
"type": "dateRange",
|
||||
"label": "Date Range",
|
||||
"column": "created_at"
|
||||
},
|
||||
{
|
||||
"id": "assigned-to",
|
||||
"type": "select",
|
||||
"label": "Assigned To",
|
||||
"sql": "SELECT DISTINCT \"Assigned To\" as value FROM v_deal WHERE \"Assigned To\" IS NOT NULL",
|
||||
"column": "Assigned To"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Chart types supported:** `bar`, `line`, `area`, `pie`, `donut`, `radar`, `radialBar`, `scatter`, `funnel`.
|
||||
**Panel sizes:** `full`, `half`, `third` (CSS grid layout).
|
||||
**Filter types:** `dateRange`, `select`, `multiSelect`, `number`.
|
||||
|
||||
When filters are active, they inject `WHERE` clauses into each panel's SQL before execution.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Recharts + Report Viewer Component
|
||||
|
||||
**Add Recharts dependency:**
|
||||
|
||||
- [apps/web/package.json](apps/web/package.json) -- add `recharts` to dependencies
|
||||
|
||||
**New components** (all in `apps/web/app/components/charts/`):
|
||||
|
||||
- `**chart-panel.tsx**` -- Wrapper that takes a panel config + data rows, renders the correct Recharts chart (Bar, Line, Area, Pie, etc.). Uses the app's CSS variable palette for theming (`--color-accent`, `--color-text`, `--color-border`). One component, switch on `panel.type`.
|
||||
- `**filter-bar.tsx**` -- Horizontal filter strip. Reads filter configs from the report, fetches options for `select` type filters via SQL, renders date pickers / dropdowns. Emits active filter state upward.
|
||||
- `**report-viewer.tsx**` -- Full report dashboard. Fetches report config (from file or prop), iterates panels, executes each panel's SQL (with filter injection), renders `ChartPanel` components in a CSS grid (`size` controls column span). Includes a header with title/description and the filter bar.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Workspace Integration
|
||||
|
||||
**Extend content types** in [apps/web/app/workspace/page.tsx](apps/web/app/workspace/page.tsx):
|
||||
|
||||
- Add `report` to the `ContentState` union: `{ kind: "report"; reportPath: string; filename: string }`
|
||||
- Add `report` case to `ContentRenderer` that renders `ReportViewer`
|
||||
- In `loadContent`, detect `.report.json` files and load as `report` kind
|
||||
|
||||
**Extend knowledge tree** in [apps/web/app/components/workspace/knowledge-tree.tsx](apps/web/app/components/workspace/knowledge-tree.tsx):
|
||||
|
||||
- Add `"report"` to the `TreeNode.type` union
|
||||
- Add report icon (bar chart icon) to `NodeTypeIcon`
|
||||
|
||||
**Extend tree API** in [apps/web/app/api/workspace/tree/route.ts](apps/web/app/api/workspace/tree/route.ts):
|
||||
|
||||
- Detect `.report.json` files and assign them `type: "report"` in the tree
|
||||
|
||||
**New API route** -- `apps/web/app/api/workspace/reports/execute/route.ts`:
|
||||
|
||||
- POST `{ sql: string, filters?: FilterState }` -- injects filter WHERE clauses into SQL, executes via `duckdbQuery`, returns rows
|
||||
- This is separate from the generic query endpoint because it handles filter injection safely
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Chat Artifact (Inline Reports)
|
||||
|
||||
**Report block convention in agent text:**
|
||||
The agent emits a fenced code block with language `report-json` containing the report JSON. Example in the streamed text:
|
||||
|
||||
````
|
||||
Here's your pipeline analysis:
|
||||
|
||||
```report-json
|
||||
{"version":1,"title":"Deals by Stage","panels":[...]}
|
||||
````
|
||||
|
||||
The data shows most deals are in the Discovery stage.
|
||||
|
||||
`````
|
||||
|
||||
**Modify [apps/web/app/components/chat-message.tsx](apps/web/app/components/chat-message.tsx):**
|
||||
|
||||
- In `groupParts`, detect text segments containing ````report-json ... ```` blocks
|
||||
- Split text around report blocks into `text` and `report-artifact` segments
|
||||
- New segment type: `{ type: "report-artifact"; config: ReportConfig }`
|
||||
|
||||
**New component** -- `apps/web/app/components/charts/report-card.tsx`:
|
||||
|
||||
- Compact inline report card rendered inside chat bubbles
|
||||
- Shows report title + a subset of panels (auto-sized to fit chat width)
|
||||
- "Open in Workspace" button that saves to filesystem + navigates
|
||||
- "Pin" action to persist an ephemeral chat report as a `.report.json` file
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Sidebar Reports Section
|
||||
|
||||
**Modify [apps/web/app/components/sidebar.tsx](apps/web/app/components/sidebar.tsx):**
|
||||
|
||||
- Add "Reports" as a new `SidebarSection` (between Workspace and Memories)
|
||||
- Fetch report list from `/api/workspace/tree` (filter for `type: "report"` nodes)
|
||||
- Each report links to `/workspace?path=reports/{name}.report.json`
|
||||
- Show chart icon + report title
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Dench Skill Updates
|
||||
|
||||
**Modify [skills/dench/SKILL.md](skills/dench/SKILL.md):**
|
||||
|
||||
- Add Section 13: Report Generation
|
||||
- Document the `.report.json` format with full schema reference
|
||||
- Provide example reports for common CRM analytics:
|
||||
- Pipeline funnel (deals by stage)
|
||||
- Revenue trend over time
|
||||
- Lead source breakdown
|
||||
- Activity/task completion rates
|
||||
- Contact growth over time
|
||||
- Instruct the agent to:
|
||||
- Create reports in `reports/` directory
|
||||
- Use the existing `v_{object}` PIVOT views in SQL queries
|
||||
- Include relevant filters (date range, assignee, status)
|
||||
- Emit `report-json` blocks in chat for inline rendering
|
||||
- Choose appropriate chart types for the data shape
|
||||
- Add a "Post-Report Checklist" matching the existing post-mutation pattern
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
|
||||
| Action | File |
|
||||
| ------ | -------------------------------------------------------------------- |
|
||||
| Modify | `apps/web/package.json` (add recharts) |
|
||||
| Create | `apps/web/app/components/charts/chart-panel.tsx` |
|
||||
| Create | `apps/web/app/components/charts/report-viewer.tsx` |
|
||||
| Create | `apps/web/app/components/charts/report-card.tsx` |
|
||||
| Create | `apps/web/app/components/charts/filter-bar.tsx` |
|
||||
| Create | `apps/web/app/api/workspace/reports/execute/route.ts` |
|
||||
| Modify | `apps/web/app/workspace/page.tsx` (add report content type) |
|
||||
| Modify | `apps/web/app/components/chat-message.tsx` (detect report blocks) |
|
||||
| Modify | `apps/web/app/components/sidebar.tsx` (Reports section) |
|
||||
| Modify | `apps/web/app/components/workspace/knowledge-tree.tsx` (report node) |
|
||||
| Modify | `apps/web/app/api/workspace/tree/route.ts` (detect .report.json) |
|
||||
| Modify | `skills/dench/SKILL.md` (report generation instructions) |
|
||||
`````
|
||||
282
.cursor/plans/sidebar_file_manager_02ed8b45.plan.md
Normal file
282
.cursor/plans/sidebar_file_manager_02ed8b45.plan.md
Normal file
@ -0,0 +1,282 @@
|
||||
---
|
||||
name: Sidebar File Manager
|
||||
overview: Transform the workspace file tree sidebar (both on `/` home and `/workspace` pages) into a full-fledged file system manager with context menus, drag-and-drop file moves, create/delete/rename operations, system file locking, and live reactivity -- modeled after macOS Finder.
|
||||
todos:
|
||||
- id: api-system-files
|
||||
content: Add isSystemFile() to lib/workspace.ts, extend safeResolvePath to support non-existent target paths
|
||||
status: pending
|
||||
- id: api-delete
|
||||
content: Add DELETE handler to /api/workspace/file/route.ts with system file protection
|
||||
status: pending
|
||||
- id: api-rename
|
||||
content: Create /api/workspace/rename/route.ts (POST) with validation and system file protection
|
||||
status: pending
|
||||
- id: api-move
|
||||
content: Create /api/workspace/move/route.ts (POST) for drag-and-drop moves
|
||||
status: pending
|
||||
- id: api-mkdir
|
||||
content: Create /api/workspace/mkdir/route.ts (POST) for creating new directories
|
||||
status: pending
|
||||
- id: api-copy
|
||||
content: Create /api/workspace/copy/route.ts (POST) for duplicating files/folders
|
||||
status: pending
|
||||
- id: api-watch
|
||||
content: Create /api/workspace/watch/route.ts SSE endpoint using chokidar for live file events
|
||||
status: pending
|
||||
- id: install-dndkit
|
||||
content: Install @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities in apps/web
|
||||
status: pending
|
||||
- id: context-menu
|
||||
content: "Build context-menu.tsx: portal-based right-click menu with Finder-like options, system file lock states"
|
||||
status: pending
|
||||
- id: inline-rename
|
||||
content: "Build inline-rename.tsx: double-click/F2 to rename in-place with validation"
|
||||
status: pending
|
||||
- id: file-manager-tree
|
||||
content: "Build file-manager-tree.tsx: unified DnD tree wrapping context menu, inline rename, drag-drop, keyboard shortcuts"
|
||||
status: pending
|
||||
- id: sse-hook
|
||||
content: "Build useWorkspaceWatcher hook: SSE connection with debounced tree refetch and auto-reconnect"
|
||||
status: pending
|
||||
- id: integrate-workspace-sidebar
|
||||
content: Replace KnowledgeTree with FileManagerTree in workspace-sidebar.tsx
|
||||
status: pending
|
||||
- id: integrate-home-sidebar
|
||||
content: Replace WorkspaceTreeNode in sidebar.tsx with FileManagerTree (compact mode)
|
||||
status: pending
|
||||
- id: integrate-workspace-page
|
||||
content: Wire workspace/page.tsx to useWorkspaceWatcher for live-reactive tree state
|
||||
status: pending
|
||||
- id: keyboard-shortcuts
|
||||
content: Add keyboard navigation and file operation shortcuts to FileManagerTree
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Full File System Manager Sidebar
|
||||
|
||||
## Current State
|
||||
|
||||
Two sidebar trees render workspace files read-only:
|
||||
|
||||
- **Home sidebar** (`[apps/web/app/components/sidebar.tsx](apps/web/app/components/sidebar.tsx)`): `WorkspaceSection` / `WorkspaceTreeNode` -- compact tree inside collapsible section
|
||||
- **Workspace sidebar** (`[apps/web/app/components/workspace/workspace-sidebar.tsx](apps/web/app/components/workspace/workspace-sidebar.tsx)`): wraps `KnowledgeTree` from `[knowledge-tree.tsx](apps/web/app/components/workspace/knowledge-tree.tsx)`
|
||||
|
||||
Both fetch from `GET /api/workspace/tree` (`[apps/web/app/api/workspace/tree/route.ts](apps/web/app/api/workspace/tree/route.ts)`). File read/write exists at `/api/workspace/file` (`[apps/web/app/api/workspace/file/route.ts](apps/web/app/api/workspace/file/route.ts)`). No delete, rename, move, mkdir, or copy endpoints exist. No context menus, drag-and-drop, or live refresh.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph frontend [Frontend Components]
|
||||
ContextMenu[ContextMenu Component]
|
||||
FileTree[FileTree - Unified Tree with DnD]
|
||||
InlineRename[Inline Rename Input]
|
||||
NewFileDialog[New File/Folder Prompt]
|
||||
end
|
||||
|
||||
subgraph api [API Routes - apps/web/app/api/workspace/]
|
||||
TreeRoute[GET /tree]
|
||||
FileRoute[GET+POST /file]
|
||||
DeleteRoute[DELETE /file]
|
||||
RenameRoute[POST /rename]
|
||||
MoveRoute[POST /move]
|
||||
MkdirRoute[POST /mkdir]
|
||||
CopyRoute[POST /copy]
|
||||
WatchRoute[GET /watch - SSE]
|
||||
end
|
||||
|
||||
subgraph fsLib [lib/workspace.ts]
|
||||
SafeResolve[safeResolvePath]
|
||||
IsSystemFile[isSystemFile]
|
||||
end
|
||||
|
||||
FileTree -->|right-click| ContextMenu
|
||||
FileTree -->|drag-drop| MoveRoute
|
||||
ContextMenu -->|"New File/Folder"| MkdirRoute
|
||||
ContextMenu -->|Delete| DeleteRoute
|
||||
ContextMenu -->|Rename| InlineRename
|
||||
ContextMenu -->|Duplicate| CopyRoute
|
||||
ContextMenu -->|"Move to..."| MoveRoute
|
||||
InlineRename --> RenameRoute
|
||||
NewFileDialog --> MkdirRoute
|
||||
NewFileDialog --> FileRoute
|
||||
|
||||
DeleteRoute --> SafeResolve
|
||||
RenameRoute --> SafeResolve
|
||||
MoveRoute --> SafeResolve
|
||||
CopyRoute --> SafeResolve
|
||||
DeleteRoute --> IsSystemFile
|
||||
RenameRoute --> IsSystemFile
|
||||
MoveRoute --> IsSystemFile
|
||||
|
||||
WatchRoute -->|"SSE events"| FileTree
|
||||
```
|
||||
|
||||
## 1. New Backend API Endpoints
|
||||
|
||||
Add to `[apps/web/app/api/workspace/](apps/web/app/api/workspace/)`:
|
||||
|
||||
`**DELETE /api/workspace/file**` - Delete a file or folder
|
||||
|
||||
- Body: `{ path: string }`
|
||||
- Reject if `isSystemFile(path)` returns true
|
||||
- Use `fs.rmSync(absPath, { recursive: true })` for folders
|
||||
|
||||
`**POST /api/workspace/rename**` - Rename a file or folder (new route file)
|
||||
|
||||
- Body: `{ path: string, newName: string }`
|
||||
- Reject system files; validate newName (no slashes, no `.` prefix for non-system)
|
||||
- Use `fs.renameSync(oldAbs, newAbs)` where newAbs replaces only the basename
|
||||
|
||||
`**POST /api/workspace/move**` - Move a file/folder to a new parent (new route file)
|
||||
|
||||
- Body: `{ sourcePath: string, destinationDir: string }`
|
||||
- Reject system files; validate destination exists and is a directory
|
||||
- Use `fs.renameSync(srcAbs, join(destAbs, basename(srcAbs)))`
|
||||
|
||||
`**POST /api/workspace/mkdir**` - Create a directory (new route file)
|
||||
|
||||
- Body: `{ path: string }`
|
||||
- Use `fs.mkdirSync(absPath, { recursive: true })`
|
||||
|
||||
`**POST /api/workspace/copy**` - Duplicate a file or folder (new route file)
|
||||
|
||||
- Body: `{ path: string, destinationPath?: string }`
|
||||
- If no destination, auto-name as `<name> copy.<ext>`
|
||||
- Use `fs.cpSync` for recursive copy
|
||||
|
||||
`**GET /api/workspace/watch**` - SSE endpoint for live changes (new route file)
|
||||
|
||||
- Uses `chokidar` to watch the dench workspace root
|
||||
- Streams SSE events: `{ type: "add"|"change"|"unlink"|"addDir"|"unlinkDir", path: string }`
|
||||
- Client reconnects on close; debounce events (200ms)
|
||||
|
||||
### System File Protection
|
||||
|
||||
Add to `[apps/web/lib/workspace.ts](apps/web/lib/workspace.ts)`:
|
||||
|
||||
```typescript
|
||||
const SYSTEM_FILE_PATTERNS = [
|
||||
/^\.object\.yaml$/,
|
||||
/^workspace\.duckdb/,
|
||||
/^workspace_context\.yaml$/,
|
||||
/\.wal$/,
|
||||
/\.tmp$/,
|
||||
];
|
||||
|
||||
export function isSystemFile(relativePath: string): boolean {
|
||||
const basename = relativePath.split("/").pop() ?? "";
|
||||
return SYSTEM_FILE_PATTERNS.some((p) => p.test(basename));
|
||||
}
|
||||
```
|
||||
|
||||
Extend `safeResolvePath` to optionally accept a `mustExist: false` flag (currently returns null for non-existent paths, but mkdir/create need paths that don't exist yet).
|
||||
|
||||
## 2. Context Menu Component
|
||||
|
||||
Create `[apps/web/app/components/workspace/context-menu.tsx](apps/web/app/components/workspace/context-menu.tsx)`:
|
||||
|
||||
- Pure CSS + React portal-based context menu (no library, matches the zero-dep approach)
|
||||
- Positioned at cursor coordinates, clamped to viewport
|
||||
- Closes on click-outside, Escape, or scroll
|
||||
- Menu items with icons, keyboard shortcut hints, separators, and disabled states
|
||||
|
||||
**Menu structure** (mirrors Finder):
|
||||
|
||||
| For files | For folders | For empty area |
|
||||
| ---------- | -------------------- | -------------- |
|
||||
| Open | Open | New File |
|
||||
| Rename | New File inside... | New Folder |
|
||||
| Duplicate | New Folder inside... | Paste |
|
||||
| Copy | Rename | --- |
|
||||
| Move to... | Duplicate | --- |
|
||||
| --- | Copy | --- |
|
||||
| Get Info | Move to... | --- |
|
||||
| --- | --- | --- |
|
||||
| Delete | Delete | --- |
|
||||
|
||||
System files (`.object.yaml`, `workspace.duckdb`) show the same menu but all mutating actions are **disabled** with a lock icon and "System file" tooltip.
|
||||
|
||||
## 3. Drag-and-Drop for File Moves
|
||||
|
||||
Install `@dnd-kit/core` + `@dnd-kit/sortable` + `@dnd-kit/utilities` (lightweight, ~15KB gzipped, React 19 compatible).
|
||||
|
||||
Create `[apps/web/app/components/workspace/dnd-file-tree.tsx](apps/web/app/components/workspace/dnd-file-tree.tsx)`:
|
||||
|
||||
- Each tree node is both a **draggable** and a **droppable** (folders accept drops)
|
||||
- Drag overlay shows a ghost of the file/folder name with icon
|
||||
- Drop targets highlight with accent border when hovered
|
||||
- On drop: call `POST /api/workspace/move` with `{ sourcePath, destinationDir }`
|
||||
- System files: draggable is **disabled** (visual lock indicator)
|
||||
- Folder auto-expand on hover during drag (300ms delay)
|
||||
- Drop validation: prevent dropping a folder into itself or its children
|
||||
|
||||
## 4. Inline Rename
|
||||
|
||||
- Double-click or press Enter/F2 on a selected node to enter rename mode
|
||||
- Replace the label `<span>` with a controlled `<input>` pre-filled with the current name
|
||||
- Commit on Enter or blur; cancel on Escape
|
||||
- Call `POST /api/workspace/rename` on commit
|
||||
- Shake animation + red border on validation error (empty name, invalid chars, name collision)
|
||||
|
||||
## 5. Unified FileManagerTree Component
|
||||
|
||||
Refactor `[knowledge-tree.tsx](apps/web/app/components/workspace/knowledge-tree.tsx)` into a new `FileManagerTree` that wraps DnD + context menu + inline rename:
|
||||
|
||||
```
|
||||
FileManagerTree (DndContext provider + SSE watcher)
|
||||
└─ FileManagerNode (draggable + droppable + onContextMenu + double-click rename)
|
||||
├─ ChevronIcon
|
||||
├─ NodeIcon (+ lock badge for system files)
|
||||
├─ InlineRenameInput | Label
|
||||
└─ Children (recursive)
|
||||
```
|
||||
|
||||
Both sidebars (`sidebar.tsx` WorkspaceSection and `workspace-sidebar.tsx`) will use this unified component, passing a `compact` prop to control spacing/features for the home sidebar (e.g., hide "Get Info", simpler context menu).
|
||||
|
||||
## 6. Live Reactivity (SSE)
|
||||
|
||||
Create a `useWorkspaceWatcher` hook in `[apps/web/app/hooks/use-workspace-watcher.ts](apps/web/app/hooks/use-workspace-watcher.ts)`:
|
||||
|
||||
- Opens an `EventSource` to `GET /api/workspace/watch`
|
||||
- On any file event, debounces and refetches the tree from `/api/workspace/tree`
|
||||
- Provides `tree`, `loading`, and `refresh()` to consumers
|
||||
- Auto-reconnects with exponential backoff (1s, 2s, 4s, max 30s)
|
||||
- Falls back to polling every 5s if SSE fails
|
||||
|
||||
Both sidebars and the workspace page will use this hook for shared, reactive tree state.
|
||||
|
||||
## 7. Keyboard Shortcuts
|
||||
|
||||
In the `FileManagerTree`, attach keyboard handlers on the focused tree container:
|
||||
|
||||
- **Delete / Backspace**: Delete selected item (with confirmation dialog)
|
||||
- **Enter / F2**: Start inline rename
|
||||
- **Cmd+C**: Copy path to clipboard
|
||||
- **Cmd+D**: Duplicate
|
||||
- **Cmd+N**: New file in current folder
|
||||
- **Cmd+Shift+N**: New folder in current folder
|
||||
- **Arrow keys**: Navigate tree up/down, expand/collapse with left/right
|
||||
- **Space**: Quick Look / preview toggle (future)
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action |
|
||||
| --------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| `apps/web/package.json` | Add `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` |
|
||||
| `apps/web/lib/workspace.ts` | Add `isSystemFile()`, extend `safeResolvePath` |
|
||||
| `apps/web/app/api/workspace/file/route.ts` | Add `DELETE` handler |
|
||||
| `apps/web/app/api/workspace/rename/route.ts` | New -- rename endpoint |
|
||||
| `apps/web/app/api/workspace/move/route.ts` | New -- move endpoint |
|
||||
| `apps/web/app/api/workspace/mkdir/route.ts` | New -- mkdir endpoint |
|
||||
| `apps/web/app/api/workspace/copy/route.ts` | New -- copy endpoint |
|
||||
| `apps/web/app/api/workspace/watch/route.ts` | New -- SSE file watcher |
|
||||
| `apps/web/app/components/workspace/context-menu.tsx` | New -- right-click menu |
|
||||
| `apps/web/app/components/workspace/file-manager-tree.tsx` | New -- unified DnD + context menu tree |
|
||||
| `apps/web/app/components/workspace/inline-rename.tsx` | New -- inline rename input |
|
||||
| `apps/web/app/hooks/use-workspace-watcher.ts` | New -- SSE watcher hook |
|
||||
| `apps/web/app/components/workspace/knowledge-tree.tsx` | Refactor into file-manager-tree |
|
||||
| `apps/web/app/components/workspace/workspace-sidebar.tsx` | Use new FileManagerTree |
|
||||
| `apps/web/app/components/sidebar.tsx` | Use new FileManagerTree (compact mode) |
|
||||
| `apps/web/app/workspace/page.tsx` | Use `useWorkspaceWatcher` hook |
|
||||
214
.cursor/plans/web_cron_dashboard_d0829ca4.plan.md
Normal file
214
.cursor/plans/web_cron_dashboard_d0829ca4.plan.md
Normal file
@ -0,0 +1,214 @@
|
||||
---
|
||||
name: Web Cron Dashboard
|
||||
overview: Add a "Cron" virtual folder to the web app sidebar that reads `~/.openclaw/cron/` data, showing all cron jobs, their heartbeat/scheduling status, run history, and full session transcripts with thinking for each run.
|
||||
todos:
|
||||
- id: types
|
||||
content: Create shared cron types in apps/web/app/types/cron.ts
|
||||
status: pending
|
||||
- id: api-jobs
|
||||
content: Create GET /api/cron/jobs route (reads jobs.json + heartbeat info)
|
||||
status: pending
|
||||
- id: api-runs
|
||||
content: Create GET /api/cron/jobs/[jobId]/runs route (reads run log JSONL)
|
||||
status: pending
|
||||
- id: api-session
|
||||
content: Create GET /api/cron/runs/[sessionId] route (reads session transcript JSONL with full thinking/tools)
|
||||
status: pending
|
||||
- id: cron-dashboard
|
||||
content: Build CronDashboard component (heartbeat status, job list, timeline)
|
||||
status: pending
|
||||
- id: cron-job-detail
|
||||
content: Build CronJobDetail component (job config, next run countdown, run history)
|
||||
status: pending
|
||||
- id: cron-run-chat
|
||||
content: Build CronRunChat component (full session transcript with thinking/tools, reusing ChainOfThought patterns)
|
||||
status: pending
|
||||
- id: sidebar
|
||||
content: Add ~cron virtual folder to workspace sidebar in page.tsx (fetch + tree + handleNodeSelect)
|
||||
status: pending
|
||||
- id: content-routing
|
||||
content: Extend content state and main area renderer in workspace/page.tsx for cron-dashboard and cron-job kinds
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Web Cron Dashboard
|
||||
|
||||
## Architecture
|
||||
|
||||
The cron system stores its data at:
|
||||
|
||||
- **Job definitions**: `~/.openclaw/cron/jobs.json` (`CronStoreFile` with `CronJob[]`)
|
||||
- **Run logs per job**: `~/.openclaw/cron/runs/{jobId}.jsonl` (`CronRunLogEntry` per line)
|
||||
- **Run session transcripts**: `~/.openclaw/agents/{agentId}/sessions/{sessionId}.jsonl`
|
||||
|
||||
The web app does NOT connect to the gateway directly -- it reads files from disk via Next.js API routes (same pattern as existing `/api/sessions`, `/api/web-sessions`). No gateway WebSocket integration needed.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph disk [Disk Storage]
|
||||
JobsJSON["~/.openclaw/cron/jobs.json"]
|
||||
RunLogs["~/.openclaw/cron/runs/{jobId}.jsonl"]
|
||||
Sessions["~/.openclaw/agents/{agentId}/sessions/{sessionId}.jsonl"]
|
||||
end
|
||||
subgraph api [Next.js API Routes]
|
||||
CronJobsAPI["/api/cron/jobs"]
|
||||
CronRunsAPI["/api/cron/jobs/{jobId}/runs"]
|
||||
CronRunSessionAPI["/api/cron/runs/{sessionId}"]
|
||||
end
|
||||
subgraph ui [UI Components]
|
||||
Sidebar["~cron virtual folder"]
|
||||
Dashboard["CronDashboard"]
|
||||
JobDetail["CronJobDetail"]
|
||||
RunChat["CronRunChat"]
|
||||
end
|
||||
JobsJSON --> CronJobsAPI --> Dashboard
|
||||
RunLogs --> CronRunsAPI --> JobDetail
|
||||
Sessions --> CronRunSessionAPI --> RunChat
|
||||
Dashboard --> Sidebar
|
||||
JobDetail --> Sidebar
|
||||
RunChat --> Sidebar
|
||||
```
|
||||
|
||||
## 1. API Routes
|
||||
|
||||
### `GET /api/cron/jobs` -- [apps/web/app/api/cron/jobs/route.ts](apps/web/app/api/cron/jobs/route.ts)
|
||||
|
||||
Read `~/.openclaw/cron/jobs.json` and return all jobs with computed status fields. Also scan `~/.openclaw/cron/runs/` to get latest run info. Include heartbeat info by reading agent config from `~/.openclaw/config.yaml` (heartbeat interval, next due estimate).
|
||||
|
||||
Returns:
|
||||
|
||||
```typescript
|
||||
{
|
||||
jobs: CronJob[],
|
||||
heartbeat: {
|
||||
intervalMs: number,
|
||||
nextDueEstimateMs: number | null
|
||||
},
|
||||
cronStatus: {
|
||||
enabled: boolean,
|
||||
nextWakeAtMs: number | null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/cron/jobs/[jobId]/runs` -- [apps/web/app/api/cron/jobs/[jobId]/runs/route.ts](apps/web/app/api/cron/jobs/[jobId]/runs/route.ts)
|
||||
|
||||
Read `~/.openclaw/cron/runs/{jobId}.jsonl` using the same logic as `readCronRunLogEntries` in [src/cron/run-log.ts](src/cron/run-log.ts). Return entries with `?limit=N` support.
|
||||
|
||||
Returns:
|
||||
|
||||
```typescript
|
||||
{ entries: CronRunLogEntry[] }
|
||||
```
|
||||
|
||||
### `GET /api/cron/runs/[sessionId]` -- [apps/web/app/api/cron/runs/[sessionId]/route.ts](apps/web/app/api/cron/runs/[sessionId]/route.ts)
|
||||
|
||||
Find and read `~/.openclaw/agents/*/sessions/{sessionId}.jsonl` (same search pattern as existing [apps/web/app/api/sessions/[sessionId]/route.ts](apps/web/app/api/sessions/[sessionId]/route.ts)). Parse JSONL into messages including `thinking` blocks, tool calls, and text -- return the full conversation with all parts preserved (not truncated like the existing session API).
|
||||
|
||||
Returns:
|
||||
|
||||
```typescript
|
||||
{
|
||||
sessionId: string,
|
||||
messages: Array<{
|
||||
id: string,
|
||||
role: "user" | "assistant",
|
||||
parts: Array<
|
||||
| { type: "text", text: string }
|
||||
| { type: "thinking", thinking: string }
|
||||
| { type: "tool-call", toolName: string, args: unknown, output?: string }
|
||||
>,
|
||||
timestamp: string
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Sidebar Integration
|
||||
|
||||
In [apps/web/app/workspace/page.tsx](apps/web/app/workspace/page.tsx), add a `~cron` virtual folder alongside the existing `~chats` folder:
|
||||
|
||||
- Fetch cron jobs from `/api/cron/jobs` on mount (same pattern as `fetchSessions`).
|
||||
- Build a `~cron` `TreeNode` folder with children:
|
||||
- Each job becomes a folder: `~cron/{jobId}` (name = job name, with status icon in the name string)
|
||||
- Clicking `~cron` opens the cron dashboard view
|
||||
- Clicking `~cron/{jobId}` opens the job detail view
|
||||
- Handle `~cron` paths in `handleNodeSelect` (same pattern as `~chats`).
|
||||
|
||||
```typescript
|
||||
const cronFolder: TreeNode = {
|
||||
name: "Cron",
|
||||
path: "~cron",
|
||||
type: "folder",
|
||||
virtual: true,
|
||||
children: cronJobs.map((j) => ({
|
||||
name: `${statusEmoji(j)} ${j.name}`,
|
||||
path: `~cron/${j.id}`,
|
||||
type: "folder",
|
||||
virtual: true,
|
||||
})),
|
||||
};
|
||||
return [...tree, chatsFolder, cronFolder];
|
||||
```
|
||||
|
||||
## 3. UI Components
|
||||
|
||||
### CronDashboard ([apps/web/app/components/cron/cron-dashboard.tsx](apps/web/app/components/cron/cron-dashboard.tsx))
|
||||
|
||||
Shown when user clicks `~cron` in sidebar. Displays:
|
||||
|
||||
- **Heartbeat status card**: interval (e.g. "every 30m"), estimated next heartbeat as a live countdown, explanation of how heartbeat decides which cron jobs to run
|
||||
- **Cron scheduler status**: enabled/disabled, next wake time as countdown
|
||||
- **Jobs table/list**: all jobs with columns:
|
||||
- Name, Schedule (human-readable), Status (running/ok/error/disabled/idle), Next run (countdown), Last run (time-ago + duration), Session target (main/isolated)
|
||||
- Click a job row to navigate to its detail view
|
||||
- **Timeline view**: visual timeline showing upcoming scheduled runs across all jobs (next 24h)
|
||||
|
||||
### CronJobDetail ([apps/web/app/components/cron/cron-job-detail.tsx](apps/web/app/components/cron/cron-job-detail.tsx))
|
||||
|
||||
Shown when user clicks `~cron/{jobId}`. Displays:
|
||||
|
||||
- **Job config header**: name, description, schedule (formatted), enabled status, session target, wake mode, payload summary
|
||||
- **Next run countdown**: large countdown timer to next execution
|
||||
- **Run history list**: each `CronRunLogEntry` as a card with:
|
||||
- Timestamp, status badge (ok/error/skipped), duration, summary text
|
||||
- Click to expand into full run chat (loads session transcript inline or navigates to run view)
|
||||
- **Error details**: if `consecutiveErrors > 0`, show error streak and last error message
|
||||
|
||||
### CronRunChat ([apps/web/app/components/cron/cron-run-chat.tsx](apps/web/app/components/cron/cron-run-chat.tsx))
|
||||
|
||||
Displayed inline when expanding a run in `CronJobDetail`. Renders the full session transcript:
|
||||
|
||||
- Reuse `ChatMessage` and `ChainOfThought` components from [apps/web/app/components/chat-message.tsx](apps/web/app/components/chat-message.tsx) and [apps/web/app/components/chain-of-thought.tsx](apps/web/app/components/chain-of-thought.tsx) (or at minimum their rendering patterns)
|
||||
- Show all thinking/reasoning blocks fully expanded (not collapsed, since these are historical runs)
|
||||
- Show all tool calls with their inputs and outputs
|
||||
- Show the system event / prompt that triggered the run
|
||||
- Display run metadata at the top: model used, tokens consumed, duration
|
||||
|
||||
## 4. Content Routing in Workspace Page
|
||||
|
||||
Extend the `content` state in [apps/web/app/workspace/page.tsx](apps/web/app/workspace/page.tsx) with new kinds:
|
||||
|
||||
```typescript
|
||||
type ContentState =
|
||||
| { kind: "none" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "document"; ... }
|
||||
| { kind: "object"; ... }
|
||||
| { kind: "cron-dashboard"; jobs: CronJob[]; heartbeat: HeartbeatInfo }
|
||||
| { kind: "cron-job"; jobId: string; job: CronJob; runs: CronRunLogEntry[] }
|
||||
```
|
||||
|
||||
In the main content area renderer, add cases for `cron-dashboard` and `cron-job` that render the corresponding components.
|
||||
|
||||
## 5. Data Types (shared)
|
||||
|
||||
Create [apps/web/app/types/cron.ts](apps/web/app/types/cron.ts) with client-side type definitions mirroring `CronJob`, `CronRunLogEntry`, `CronSchedule`, `CronJobState` etc. (copy from [src/cron/types.ts](src/cron/types.ts) and [src/cron/run-log.ts](src/cron/run-log.ts), stripped of server-only imports).
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **Read-only**: The cron UI is read-only (view jobs, runs, transcripts). No add/edit/delete/enable/disable from the web UI -- that stays in the CLI.
|
||||
- **No gateway connection**: All data read directly from disk via API routes, consistent with the existing web app pattern.
|
||||
- **Reuse existing components**: Chat rendering reuses `ChatMessage`/`ChainOfThought` patterns rather than building from scratch.
|
||||
- **Live countdowns**: Use `setInterval` for countdown timers (next heartbeat, next cron run) with periodic refetch of job data to stay current.
|
||||
- **Auto-refresh**: Poll `/api/cron/jobs` every ~30s to catch state changes from the running gateway.
|
||||
124
.cursor/plans/workspace_profile_support_7e8600ec.plan.md
Normal file
124
.cursor/plans/workspace_profile_support_7e8600ec.plan.md
Normal file
@ -0,0 +1,124 @@
|
||||
---
|
||||
name: Workspace profile support
|
||||
overview: Add full workspace profile and custom path support to the Ironclaw web app and the dench SKILL.md, so they respect OPENCLAW_PROFILE, OPENCLAW_HOME, OPENCLAW_STATE_DIR, and per-agent workspace config — matching the CLI's existing resolution logic.
|
||||
todos:
|
||||
- id: centralize-helpers
|
||||
content: Add resolveOpenClawStateDir() to apps/web/lib/workspace.ts and update resolveWorkspaceRoot() with OPENCLAW_PROFILE + OPENCLAW_HOME + OPENCLAW_STATE_DIR support
|
||||
status: pending
|
||||
- id: fix-api-routes
|
||||
content: Replace all hardcoded join(homedir(), '.openclaw', ...) in ~13 web app API routes/lib files with the new shared helpers
|
||||
status: pending
|
||||
- id: fix-empty-state-ui
|
||||
content: Make empty-state.tsx show the resolved workspace path dynamically instead of hardcoded ~/.openclaw/workspace
|
||||
status: pending
|
||||
- id: fix-system-prompt
|
||||
content: Replace hardcoded ~/.openclaw/web-chat/ in system-prompt.ts line 173 with a dynamic path from the state dir context
|
||||
status: pending
|
||||
- id: skill-substitution
|
||||
content: Add workspace path substitution in buildWorkspaceSkillSnapshot() so injected SKILL.md content replaces ~/.openclaw/workspace with the actual resolved workspace dir
|
||||
status: pending
|
||||
- id: tree-api-profile
|
||||
content: Expose active profile name in the tree API response so the UI can show profile-aware workspace labels
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Full Workspace Profile and Custom Path Support
|
||||
|
||||
## Problem
|
||||
|
||||
The CLI core (`src/agents/workspace.ts`, `src/config/paths.ts`) already resolves workspace paths dynamically via `OPENCLAW_PROFILE`, `OPENCLAW_HOME`, `OPENCLAW_STATE_DIR`, and per-agent config — but the web app (`apps/web/`) and the injected dench skill (`skills/dench/SKILL.md`) hardcode `~/.openclaw` and `~/.openclaw/workspace` everywhere, ignoring profiles entirely.
|
||||
|
||||
**35 hardcoded `~/.openclaw` references** in `SKILL.md`, and **~15 hardcoded paths** across the web app API routes and UI.
|
||||
|
||||
## Approach
|
||||
|
||||
### 1. Centralize path resolution in the web app
|
||||
|
||||
Create two new helpers in [apps/web/lib/workspace.ts](apps/web/lib/workspace.ts) and update the existing `resolveWorkspaceRoot()`:
|
||||
|
||||
- `**resolveOpenClawStateDir()**` — mirrors `src/config/paths.ts:resolveStateDir()` logic: checks `OPENCLAW_STATE_DIR` env var, then `OPENCLAW_HOME`, falls back to `~/.openclaw`. Returns the base state directory (e.g. `~/.openclaw`).
|
||||
- **Update `resolveWorkspaceRoot()**`— add`OPENCLAW_PROFILE`awareness between the`OPENCLAW_WORKSPACE` check and the fallback:
|
||||
1. `OPENCLAW_WORKSPACE` env var (existing)
|
||||
2. `OPENCLAW_PROFILE` -> `<stateDir>/workspace-<profile>` (new)
|
||||
3. `<stateDir>/workspace` (existing, but now uses `resolveOpenClawStateDir()` instead of hardcoded `~/.openclaw`)
|
||||
|
||||
```typescript
|
||||
export function resolveOpenClawStateDir(): string {
|
||||
const override = process.env.OPENCLAW_STATE_DIR?.trim();
|
||||
if (override) return override.startsWith("~") ? join(homedir(), override.slice(1)) : override;
|
||||
const home = process.env.OPENCLAW_HOME?.trim() || homedir();
|
||||
return join(home, ".openclaw");
|
||||
}
|
||||
|
||||
export function resolveWorkspaceRoot(): string | null {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const profile = process.env.OPENCLAW_PROFILE?.trim();
|
||||
const candidates = [
|
||||
process.env.OPENCLAW_WORKSPACE,
|
||||
profile && profile.toLowerCase() !== "default" ? join(stateDir, `workspace-${profile}`) : null,
|
||||
join(stateDir, "workspace"),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const dir of candidates) {
|
||||
if (existsSync(dir)) return dir;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Replace all hardcoded paths in web app API routes
|
||||
|
||||
Every file below uses `join(homedir(), ".openclaw", ...)` directly. Replace with calls to `resolveOpenClawStateDir()` or `resolveWorkspaceRoot()`:
|
||||
|
||||
| File | What to change |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `apps/web/app/api/workspace/tree/route.ts` | `join(home, ".openclaw", "skills")` and `join(home, ".openclaw")` -> `resolveOpenClawStateDir()` |
|
||||
| `apps/web/app/api/workspace/virtual-file/route.ts` | All 6 hardcoded paths in `resolveVirtualPath()` and `isSafePath()` -> derive from `resolveWorkspaceRoot()` and `resolveOpenClawStateDir()` |
|
||||
| `apps/web/app/api/skills/route.ts` | `join(openclawDir, "skills")` and `join(openclawDir, "workspace", "skills")` -> use both helpers |
|
||||
| `apps/web/app/api/sessions/route.ts` | `resolveOpenClawDir()` local helper -> use shared `resolveOpenClawStateDir()` |
|
||||
| `apps/web/app/api/memories/route.ts` | `join(homedir(), ".openclaw", "workspace")` -> `resolveWorkspaceRoot()` |
|
||||
| `apps/web/app/api/cron/jobs/route.ts` | Module-level `CRON_DIR` and `agentsDir` -> derive from `resolveOpenClawStateDir()` |
|
||||
| `apps/web/app/api/cron/runs/search-transcript/route.ts` | agents dir -> `resolveOpenClawStateDir()` |
|
||||
| `apps/web/app/api/cron/runs/[sessionId]/route.ts` | agents dir -> `resolveOpenClawStateDir()` |
|
||||
| `apps/web/app/api/cron/jobs/[jobId]/runs/route.ts` | if hardcoded -> `resolveOpenClawStateDir()` |
|
||||
| `apps/web/app/api/web-sessions/route.ts` | `WEB_CHAT_DIR` -> derive from `resolveOpenClawStateDir()` |
|
||||
| `apps/web/app/api/web-sessions/[id]/route.ts` | same |
|
||||
| `apps/web/app/api/web-sessions/[id]/messages/route.ts` | same |
|
||||
| `apps/web/lib/active-runs.ts` | `WEB_CHAT_DIR` -> derive from `resolveOpenClawStateDir()` |
|
||||
|
||||
### 3. Update the UI empty state
|
||||
|
||||
In [apps/web/app/components/workspace/empty-state.tsx](apps/web/app/components/workspace/empty-state.tsx) (line 128): the hardcoded `~/.openclaw/workspace` display string should be dynamic. Two options:
|
||||
|
||||
- **Option A**: Pass the resolved workspace path from the tree API response (it already returns `workspaceRoot`). The empty state can show that or a user-friendly tilde-collapsed version.
|
||||
- **Option B**: Add an API endpoint or server component that returns the expected workspace path.
|
||||
|
||||
Option A is simplest — the tree API already returns `openclawDir` and `workspaceRoot`. Thread the expected path into the empty state component.
|
||||
|
||||
### 4. Fix hardcoded path in system prompt
|
||||
|
||||
In [src/agents/system-prompt.ts](src/agents/system-prompt.ts) line 173: the hardcoded `~/.openclaw/web-chat/` should use the `workspaceDir` parameter (or derive from the state dir that's already available in the prompt builder context). Replace with a template string that references the actual state directory.
|
||||
|
||||
### 5. Add workspace variable substitution for injected SKILL.md content
|
||||
|
||||
The dench `SKILL.md` has **35 instances** of `~/.openclaw/workspace`. Since this content is injected verbatim into the system prompt via `readSkillContent()`, we need a substitution mechanism.
|
||||
|
||||
In [src/agents/skills/workspace.ts](src/agents/skills/workspace.ts) around line 271 where `readSkillContent()` is called for injected skills:
|
||||
|
||||
```typescript
|
||||
// After reading content, substitute workspace path placeholders
|
||||
const content = readSkillContent(entry.skill.filePath);
|
||||
if (content) {
|
||||
const resolved = content.replaceAll("~/.openclaw/workspace", workspaceDir);
|
||||
injectedSkills.push({ name: entry.skill.name, content: resolved });
|
||||
}
|
||||
```
|
||||
|
||||
This requires threading `workspaceDir` into `buildWorkspaceSkillSnapshot()` — which it already receives as its first argument.
|
||||
|
||||
Then update `skills/dench/SKILL.md` to use `~/.openclaw/workspace` as a canonical placeholder (it already does), and the substitution will replace it with the actual resolved path at injection time. No changes needed to the SKILL.md content itself.
|
||||
|
||||
### 6. Expose workspace info in the tree API response
|
||||
|
||||
The tree API ([apps/web/app/api/workspace/tree/route.ts](apps/web/app/api/workspace/tree/route.ts)) already returns `workspaceRoot` and `openclawDir`. Consider also returning `profile` (from `OPENCLAW_PROFILE`) so the UI can display profile-aware context (e.g. "Workspace (staging)" in the sidebar).
|
||||
3
.cursor/worktrees.json
Normal file
3
.cursor/worktrees.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"setup-worktree": ["npm install"]
|
||||
}
|
||||
77
.github/dependabot.yml
vendored
77
.github/dependabot.yml
vendored
@ -47,80 +47,3 @@ updates:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Swift Package Manager - macOS app
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/macos
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Swift Package Manager - shared MoltbotKit
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/shared/MoltbotKit
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Swift Package Manager - Swabble
|
||||
- package-ecosystem: swift
|
||||
directory: /Swabble
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Gradle - Android app
|
||||
- package-ecosystem: gradle
|
||||
directory: /apps/android
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
android-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# Docker base images
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
docker-images:
|
||||
patterns:
|
||||
- "*"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
224
.github/workflows/auto-response.yml
vendored
224
.github/workflows/auto-response.yml
vendored
@ -1,224 +0,0 @@
|
||||
name: Auto response
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, labeled]
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
auto-response:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Handle labeled items
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
// Labels prefixed with "r:" are auto-response triggers.
|
||||
const rules = [
|
||||
{
|
||||
label: "r: skill",
|
||||
close: true,
|
||||
message:
|
||||
"Thanks for the contribution! New skills should be published to [Clawhub](https://clawhub.ai) for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.",
|
||||
},
|
||||
{
|
||||
label: "r: support",
|
||||
close: true,
|
||||
message:
|
||||
"Please use [our support server](https://discord.gg/clawd) and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.openclaw.ai/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
|
||||
},
|
||||
{
|
||||
label: "r: testflight",
|
||||
close: true,
|
||||
message: "Not available, build from source.",
|
||||
},
|
||||
{
|
||||
label: "r: third-party-extension",
|
||||
close: true,
|
||||
message:
|
||||
"Please make this as a third-party plugin that you maintain yourself in your own repo. Docs: https://docs.openclaw.ai/plugin. Feel free to open a PR after to add it to our community plugins page: https://docs.openclaw.ai/plugins/community",
|
||||
},
|
||||
{
|
||||
label: "r: moltbook",
|
||||
close: true,
|
||||
lock: true,
|
||||
lockReason: "off-topic",
|
||||
message:
|
||||
"OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.",
|
||||
},
|
||||
];
|
||||
|
||||
const triggerLabel = "trigger-response";
|
||||
const target = context.payload.issue ?? context.payload.pull_request;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labelSet = new Set(
|
||||
(target.labels ?? [])
|
||||
.map((label) => (typeof label === "string" ? label : label?.name))
|
||||
.filter((name) => typeof name === "string"),
|
||||
);
|
||||
|
||||
const hasTriggerLabel = labelSet.has(triggerLabel);
|
||||
if (hasTriggerLabel) {
|
||||
labelSet.delete(triggerLabel);
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: target.number,
|
||||
name: triggerLabel,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isLabelEvent = context.payload.action === "labeled";
|
||||
if (!hasTriggerLabel && !isLabelEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issue = context.payload.issue;
|
||||
if (issue) {
|
||||
const title = issue.title ?? "";
|
||||
const body = issue.body ?? "";
|
||||
const haystack = `${title}\n${body}`.toLowerCase();
|
||||
const hasMoltbookLabel = labelSet.has("r: moltbook");
|
||||
const hasTestflightLabel = labelSet.has("r: testflight");
|
||||
const hasSecurityLabel = labelSet.has("security");
|
||||
if (title.toLowerCase().includes("security") && !hasSecurityLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["security"],
|
||||
});
|
||||
labelSet.add("security");
|
||||
}
|
||||
if (title.toLowerCase().includes("testflight") && !hasTestflightLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["r: testflight"],
|
||||
});
|
||||
labelSet.add("r: testflight");
|
||||
}
|
||||
if (haystack.includes("moltbook") && !hasMoltbookLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["r: moltbook"],
|
||||
});
|
||||
labelSet.add("r: moltbook");
|
||||
}
|
||||
}
|
||||
|
||||
const invalidLabel = "invalid";
|
||||
const dirtyLabel = "dirty";
|
||||
const noisyPrMessage =
|
||||
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
|
||||
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (pullRequest) {
|
||||
if (labelSet.has(dirtyLabel)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body: noisyPrMessage,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const labelCount = labelSet.size;
|
||||
if (labelCount > 20) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body: noisyPrMessage,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (labelSet.has(invalidLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (issue && labelSet.has(invalidLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rule = rules.find((item) => labelSet.has(item.label));
|
||||
if (!rule) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issueNumber = target.number;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body: rule.message,
|
||||
});
|
||||
|
||||
if (rule.close) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: "closed",
|
||||
});
|
||||
}
|
||||
|
||||
if (rule.lock) {
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
lock_reason: rule.lockReason ?? "resolved",
|
||||
});
|
||||
}
|
||||
720
.github/workflows/ci.yml
vendored
720
.github/workflows/ci.yml
vendored
@ -10,186 +10,30 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
|
||||
# Lint and format always run. Fail-safe: if detection fails, run everything.
|
||||
docs-scope:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
docs_changed: ${{ steps.check.outputs.docs_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: false
|
||||
# check:
|
||||
# name: "check"
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@v4
|
||||
# with:
|
||||
# submodules: false
|
||||
#
|
||||
# - name: Setup Node environment
|
||||
# uses: ./.github/actions/setup-node-env
|
||||
#
|
||||
# - name: Check types and lint and oxfmt
|
||||
# run: pnpm check
|
||||
|
||||
- name: Detect docs-only changes
|
||||
id: check
|
||||
uses: ./.github/actions/detect-docs-changes
|
||||
|
||||
# Detect which heavy areas are touched so PRs can skip unrelated expensive jobs.
|
||||
# Push to main keeps broad coverage.
|
||||
changed-scope:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
run_node: ${{ steps.scope.outputs.run_node }}
|
||||
run_macos: ${{ steps.scope.outputs.run_macos }}
|
||||
run_android: ${{ steps.scope.outputs.run_android }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: false
|
||||
|
||||
- name: Detect changed scopes
|
||||
id: scope
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
else
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")"
|
||||
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
|
||||
# Fail-safe: run broad checks if detection fails.
|
||||
echo "run_node=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_macos=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_android=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_node=false
|
||||
run_macos=false
|
||||
run_android=false
|
||||
has_non_docs=false
|
||||
has_non_native_non_docs=false
|
||||
|
||||
while IFS= read -r path; do
|
||||
[ -z "$path" ] && continue
|
||||
case "$path" in
|
||||
docs/*|*.md|*.mdx)
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
has_non_docs=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
# Generated protocol models are already covered by protocol:check and
|
||||
# should not force the full native macOS lane.
|
||||
apps/macos/Sources/OpenClawProtocol/*|apps/shared/OpenClawKit/Sources/OpenClawProtocol/*)
|
||||
;;
|
||||
apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*)
|
||||
run_macos=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
apps/android/*|apps/shared/*)
|
||||
run_android=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
src/*|test/*|extensions/*|packages/*|scripts/*|ui/*|.github/*|openclaw.mjs|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig*.json|vitest*.ts|tsdown.config.ts|.oxlintrc.json|.oxfmtrc.jsonc)
|
||||
run_node=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
apps/android/*|apps/ios/*|apps/macos/*|apps/shared/*|Swabble/*|appcast.xml)
|
||||
;;
|
||||
*)
|
||||
has_non_native_non_docs=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$CHANGED"
|
||||
|
||||
# If there are non-doc files outside native app trees, keep Node checks enabled.
|
||||
if [ "$run_node" = false ] && [ "$has_non_docs" = true ] && [ "$has_non_native_non_docs" = true ]; then
|
||||
run_node=true
|
||||
fi
|
||||
|
||||
echo "run_node=${run_node}" >> "$GITHUB_OUTPUT"
|
||||
echo "run_macos=${run_macos}" >> "$GITHUB_OUTPUT"
|
||||
echo "run_android=${run_android}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
build-artifacts:
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Build dist
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Validate npm pack contents after build (only on push to main, not PRs).
|
||||
release-check:
|
||||
needs: [docs-scope, build-artifacts]
|
||||
if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Check release contents
|
||||
run: pnpm release:check
|
||||
|
||||
checks:
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
|
||||
steps:
|
||||
- name: Skip bun lane on push
|
||||
@ -208,537 +52,11 @@ jobs:
|
||||
with:
|
||||
install-bun: "${{ matrix.runtime == 'bun' }}"
|
||||
|
||||
- name: Configure vitest JSON reports
|
||||
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure Node test resources
|
||||
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
if: matrix.runtime == 'node'
|
||||
run: |
|
||||
# `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes.
|
||||
# Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
|
||||
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=8192" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
if: matrix.runtime != 'bun' || github.event_name != 'push'
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Summarize slowest tests
|
||||
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: |
|
||||
node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null
|
||||
echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md"
|
||||
|
||||
- name: Upload vitest reports
|
||||
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
|
||||
path: |
|
||||
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
|
||||
${{ runner.temp }}/vitest-slowest.md
|
||||
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
name: "check"
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Check types and lint and oxfmt
|
||||
run: pnpm check
|
||||
|
||||
# Report-only dead-code scans. Runs after scope detection and stores machine-readable
|
||||
# results as artifacts for later triage before we enable hard gates.
|
||||
# Temporarily disabled in CI while we process initial findings.
|
||||
deadcode:
|
||||
name: dead-code report
|
||||
needs: [docs-scope, changed-scope]
|
||||
# if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
if: false
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- tool: knip
|
||||
command: pnpm deadcode:report:ci:knip
|
||||
- tool: ts-prune
|
||||
command: pnpm deadcode:report:ci:ts-prune
|
||||
- tool: ts-unused-exports
|
||||
command: pnpm deadcode:report:ci:ts-unused
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Run ${{ matrix.tool }} dead-code scan
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Upload dead-code results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dead-code-${{ matrix.tool }}-${{ github.run_id }}
|
||||
path: .artifacts/deadcode
|
||||
|
||||
# Validate docs (format, lint, broken links) only when docs files changed.
|
||||
check-docs:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_changed == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Check docs
|
||||
run: pnpm check:docs
|
||||
|
||||
secrets:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install detect-secrets
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install detect-secrets==1.5.0
|
||||
|
||||
- name: Detect secrets
|
||||
run: |
|
||||
if ! detect-secrets scan --baseline .secrets.baseline; then
|
||||
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checks-windows:
|
||||
needs: [docs-scope, changed-scope, build-artifacts, check]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-windows-2025
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
# Keep total concurrency predictable on the 16 vCPU runner:
|
||||
# `scripts/test-parallel.mjs` runs some vitest suites in parallel processes.
|
||||
OPENCLAW_TEST_WORKERS: 2
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: lint
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Try to exclude workspace from Windows Defender (best-effort)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
|
||||
if (-not $cmd) {
|
||||
Write-Host "Add-MpPreference not available, skipping Defender exclusions."
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
# Defender sometimes intercepts process spawning (vitest workers). If this fails
|
||||
# (eg hardened images), keep going and rely on worker limiting above.
|
||||
Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
|
||||
Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
|
||||
Write-Host "Defender exclusions applied."
|
||||
} catch {
|
||||
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
- name: Download dist artifact (lint lane)
|
||||
if: matrix.task == 'lint'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Verify dist artifact (lint lane)
|
||||
if: matrix.task == 'lint'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s dist/index.js
|
||||
test -s dist/plugin-sdk/index.js
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Configure vitest JSON reports
|
||||
if: matrix.task == 'test'
|
||||
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Summarize slowest tests
|
||||
if: matrix.task == 'test'
|
||||
run: |
|
||||
node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null
|
||||
echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md"
|
||||
|
||||
- name: Upload vitest reports
|
||||
if: matrix.task == 'test'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
|
||||
path: |
|
||||
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
|
||||
${{ runner.temp }}/vitest-slowest.md
|
||||
|
||||
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
|
||||
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
|
||||
# running 4 separate jobs per PR (as before) starved the queue. One job
|
||||
# per PR allows 5 PRs to run macOS checks simultaneously.
|
||||
macos:
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
# --- Run all checks sequentially (fast gates first) ---
|
||||
- name: TS tests (macOS)
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: pnpm test
|
||||
|
||||
# --- Xcode/Swift setup ---
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
run: brew install xcodegen swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: Swift lint
|
||||
run: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
|
||||
- name: Cache SwiftPM
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/Library/Caches/org.swift.swiftpm
|
||||
key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-swiftpm-
|
||||
|
||||
- name: Swift build (release)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Swift test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
ios:
|
||||
if: false # ignore iOS in CI for now
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
|
||||
- name: Install SwiftLint / SwiftFormat
|
||||
run: brew install swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: Generate iOS project
|
||||
run: |
|
||||
cd apps/ios
|
||||
xcodegen generate
|
||||
|
||||
- name: iOS tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
||||
DEST_ID="$(
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
def sh(args: list[str]) -> str:
|
||||
return subprocess.check_output(args, text=True).strip()
|
||||
|
||||
# Prefer an already-created iPhone simulator if it exists.
|
||||
devices = json.loads(sh(["xcrun", "simctl", "list", "devices", "-j"]))
|
||||
candidates: list[tuple[str, str]] = []
|
||||
for runtime, devs in (devices.get("devices") or {}).items():
|
||||
for dev in devs or []:
|
||||
if not dev.get("isAvailable"):
|
||||
continue
|
||||
name = str(dev.get("name") or "")
|
||||
udid = str(dev.get("udid") or "")
|
||||
if not udid or not name.startswith("iPhone"):
|
||||
continue
|
||||
candidates.append((name, udid))
|
||||
|
||||
candidates.sort(key=lambda it: (0 if "iPhone 16" in it[0] else 1, it[0]))
|
||||
if candidates:
|
||||
print(candidates[0][1])
|
||||
sys.exit(0)
|
||||
|
||||
# Otherwise, create one from the newest available iOS runtime.
|
||||
runtimes = json.loads(sh(["xcrun", "simctl", "list", "runtimes", "-j"])).get("runtimes") or []
|
||||
ios = [rt for rt in runtimes if rt.get("platform") == "iOS" and rt.get("isAvailable")]
|
||||
if not ios:
|
||||
print("No available iOS runtimes found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def version_key(rt: dict) -> tuple[int, ...]:
|
||||
parts: list[int] = []
|
||||
for p in str(rt.get("version") or "0").split("."):
|
||||
try:
|
||||
parts.append(int(p))
|
||||
except ValueError:
|
||||
parts.append(0)
|
||||
return tuple(parts)
|
||||
|
||||
ios.sort(key=version_key, reverse=True)
|
||||
runtime = ios[0]
|
||||
runtime_id = str(runtime.get("identifier") or "")
|
||||
if not runtime_id:
|
||||
print("Missing iOS runtime identifier.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
supported = runtime.get("supportedDeviceTypes") or []
|
||||
iphones = [dt for dt in supported if dt.get("productFamily") == "iPhone"]
|
||||
if not iphones:
|
||||
print("No iPhone device types for iOS runtime.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
iphones.sort(
|
||||
key=lambda dt: (
|
||||
0 if "iPhone 16" in str(dt.get("name") or "") else 1,
|
||||
str(dt.get("name") or ""),
|
||||
)
|
||||
)
|
||||
device_type_id = str(iphones[0].get("identifier") or "")
|
||||
if not device_type_id:
|
||||
print("Missing iPhone device type identifier.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
sim_name = f"CI iPhone {uuid.uuid4().hex[:8]}"
|
||||
udid = sh(["xcrun", "simctl", "create", sim_name, device_type_id, runtime_id])
|
||||
if not udid:
|
||||
print("Failed to create iPhone simulator.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(udid)
|
||||
PY
|
||||
)"
|
||||
echo "Using iOS Simulator id: $DEST_ID"
|
||||
xcodebuild test \
|
||||
-project apps/ios/Clawdis.xcodeproj \
|
||||
-scheme Clawdis \
|
||||
-destination "platform=iOS Simulator,id=$DEST_ID" \
|
||||
-resultBundlePath "$RESULT_BUNDLE_PATH" \
|
||||
-enableCodeCoverage YES
|
||||
|
||||
- name: iOS coverage summary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
||||
xcrun xccov view --report --only-targets "$RESULT_BUNDLE_PATH"
|
||||
|
||||
- name: iOS coverage gate (43%)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
||||
RESULT_BUNDLE_PATH="$RESULT_BUNDLE_PATH" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
target_name = "Clawdis.app"
|
||||
minimum = 0.43
|
||||
|
||||
report = json.loads(
|
||||
subprocess.check_output(
|
||||
["xcrun", "xccov", "view", "--report", "--json", os.environ["RESULT_BUNDLE_PATH"]],
|
||||
text=True,
|
||||
)
|
||||
)
|
||||
|
||||
target_coverage = None
|
||||
for target in report.get("targets", []):
|
||||
if target.get("name") == target_name:
|
||||
target_coverage = float(target["lineCoverage"])
|
||||
break
|
||||
|
||||
if target_coverage is None:
|
||||
print(f"Could not find coverage for target: {target_name}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"{target_name} line coverage: {target_coverage * 100:.2f}% (min {minimum * 100:.2f}%)")
|
||||
if target_coverage + 1e-12 < minimum:
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
android:
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: test
|
||||
command: ./gradlew --no-daemon :app:testDebugUnitTest
|
||||
- task: build
|
||||
command: ./gradlew --no-daemon :app:assembleDebug
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
# setup-android's sdkmanager currently crashes on JDK 21 in CI.
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
accept-android-sdk-licenses: false
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
gradle-version: 8.11.1
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null
|
||||
sdkmanager --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Run Android ${{ matrix.task }}
|
||||
working-directory: apps/android
|
||||
- name: Run tests (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
198
.github/workflows/docker-release.yml
vendored
198
.github/workflows/docker-release.yml
vendored
@ -1,198 +0,0 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
- "**/*.mdx"
|
||||
- ".agents/**"
|
||||
- "skills/**"
|
||||
|
||||
concurrency:
|
||||
group: docker-release-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# Build amd64 image
|
||||
build-amd64:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
image-digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Resolve image tags (amd64)
|
||||
id: tags
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main-amd64")
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}-amd64")
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No amd64 tags resolved for ref ${GITHUB_REF}"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push amd64 image
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Build arm64 image
|
||||
build-arm64:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
image-digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Resolve image tags (arm64)
|
||||
id: tags
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main-arm64")
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}-arm64")
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No arm64 tags resolved for ref ${GITHUB_REF}"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push arm64 image
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Create multi-platform manifest
|
||||
create-manifest:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
needs: [build-amd64, build-arm64]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Resolve manifest tags
|
||||
id: tags
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main")
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}")
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No manifest tags resolved for ref ${GITHUB_REF}"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create and push manifest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t tags <<< "${{ steps.tags.outputs.value }}"
|
||||
args=()
|
||||
for tag in "${tags[@]}"; do
|
||||
[ -z "$tag" ] && continue
|
||||
args+=("-t" "$tag")
|
||||
done
|
||||
docker buildx imagetools create "${args[@]}" \
|
||||
${{ needs.build-amd64.outputs.image-digest }} \
|
||||
${{ needs.build-arm64.outputs.image-digest }}
|
||||
59
.github/workflows/install-smoke.yml
vendored
59
.github/workflows/install-smoke.yml
vendored
@ -1,59 +0,0 @@
|
||||
name: Install Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
docs-scope:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect docs-only changes
|
||||
id: check
|
||||
uses: ./.github/actions/detect-docs-changes
|
||||
|
||||
install-smoke:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout CLI
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
- name: Run installer docker tests
|
||||
env:
|
||||
CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh
|
||||
CLAWDBOT_INSTALL_CLI_URL: https://openclaw.ai/install-cli.sh
|
||||
CLAWDBOT_NO_ONBOARD: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
|
||||
run: pnpm test:install:smoke
|
||||
519
.github/workflows/labeler.yml
vendored
519
.github/workflows/labeler.yml
vendored
@ -1,519 +0,0 @@
|
||||
name: Labeler
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
issues:
|
||||
types: [opened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
max_prs:
|
||||
description: "Maximum number of open PRs to process (0 = all)"
|
||||
required: false
|
||||
default: "200"
|
||||
per_page:
|
||||
description: "PRs per page (1-100)"
|
||||
required: false
|
||||
default: "50"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
label:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
|
||||
with:
|
||||
configuration-path: .github/labeler.yml
|
||||
repo-token: ${{ steps.app-token.outputs.token }}
|
||||
sync-labels: true
|
||||
- name: Apply PR size label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (!pullRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
|
||||
const labelColor = "b76e79";
|
||||
|
||||
for (const label of sizeLabels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
color: labelColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
||||
const totalChangedLines = files.reduce((total, file) => {
|
||||
const path = file.filename ?? "";
|
||||
if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
|
||||
return total;
|
||||
}
|
||||
return total + (file.additions ?? 0) + (file.deletions ?? 0);
|
||||
}, 0);
|
||||
|
||||
let targetSizeLabel = "size: XL";
|
||||
if (totalChangedLines < 50) {
|
||||
targetSizeLabel = "size: XS";
|
||||
} else if (totalChangedLines < 200) {
|
||||
targetSizeLabel = "size: S";
|
||||
} else if (totalChangedLines < 500) {
|
||||
targetSizeLabel = "size: M";
|
||||
} else if (totalChangedLines < 1000) {
|
||||
targetSizeLabel = "size: L";
|
||||
}
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
for (const label of currentLabels) {
|
||||
const name = label.name ?? "";
|
||||
if (!sizeLabels.includes(name)) {
|
||||
continue;
|
||||
}
|
||||
if (name === targetSizeLabel) {
|
||||
continue;
|
||||
}
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [targetSizeLabel],
|
||||
});
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const login = context.payload.pull_request?.user?.login;
|
||||
if (!login) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = `${context.repo.owner}/${context.repo.repo}`;
|
||||
const trustedLabel = "trusted-contributor";
|
||||
const experiencedLabel = "experienced-contributor";
|
||||
const trustedThreshold = 4;
|
||||
const experiencedThreshold = 10;
|
||||
|
||||
let isMaintainer = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: "maintainer",
|
||||
username: login,
|
||||
});
|
||||
isMaintainer = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMaintainer) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: ["maintainer"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`;
|
||||
let mergedCount = 0;
|
||||
try {
|
||||
const merged = await github.rest.search.issuesAndPullRequests({
|
||||
q: mergedQuery,
|
||||
per_page: 1,
|
||||
});
|
||||
mergedCount = merged?.data?.total_count ?? 0;
|
||||
} catch (error) {
|
||||
if (error?.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
core.warning(`Skipping merged search for ${login}; treating as 0.`);
|
||||
}
|
||||
|
||||
if (mergedCount >= experiencedThreshold) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: [experiencedLabel],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (mergedCount >= trustedThreshold) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: [trustedLabel],
|
||||
});
|
||||
}
|
||||
|
||||
backfill-pr-labels:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Backfill PR labels
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const repoFull = `${owner}/${repo}`;
|
||||
const inputs = context.payload.inputs ?? {};
|
||||
const maxPrsInput = inputs.max_prs ?? "200";
|
||||
const perPageInput = inputs.per_page ?? "50";
|
||||
const parsedMaxPrs = Number.parseInt(maxPrsInput, 10);
|
||||
const parsedPerPage = Number.parseInt(perPageInput, 10);
|
||||
const maxPrs = Number.isFinite(parsedMaxPrs) ? parsedMaxPrs : 200;
|
||||
const perPage = Number.isFinite(parsedPerPage) ? Math.min(100, Math.max(1, parsedPerPage)) : 50;
|
||||
const processAll = maxPrs <= 0;
|
||||
const maxCount = processAll ? Number.POSITIVE_INFINITY : Math.max(1, maxPrs);
|
||||
|
||||
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
|
||||
const labelColor = "b76e79";
|
||||
const trustedLabel = "trusted-contributor";
|
||||
const experiencedLabel = "experienced-contributor";
|
||||
const trustedThreshold = 4;
|
||||
const experiencedThreshold = 10;
|
||||
|
||||
const contributorCache = new Map();
|
||||
|
||||
async function ensureSizeLabels() {
|
||||
for (const label of sizeLabels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label,
|
||||
color: labelColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveContributorLabel(login) {
|
||||
if (contributorCache.has(login)) {
|
||||
return contributorCache.get(login);
|
||||
}
|
||||
|
||||
let isMaintainer = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: owner,
|
||||
team_slug: "maintainer",
|
||||
username: login,
|
||||
});
|
||||
isMaintainer = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMaintainer) {
|
||||
contributorCache.set(login, "maintainer");
|
||||
return "maintainer";
|
||||
}
|
||||
|
||||
const mergedQuery = `repo:${repoFull} is:pr is:merged author:${login}`;
|
||||
let mergedCount = 0;
|
||||
try {
|
||||
const merged = await github.rest.search.issuesAndPullRequests({
|
||||
q: mergedQuery,
|
||||
per_page: 1,
|
||||
});
|
||||
mergedCount = merged?.data?.total_count ?? 0;
|
||||
} catch (error) {
|
||||
if (error?.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
core.warning(`Skipping merged search for ${login}; treating as 0.`);
|
||||
}
|
||||
|
||||
let label = null;
|
||||
if (mergedCount >= experiencedThreshold) {
|
||||
label = experiencedLabel;
|
||||
} else if (mergedCount >= trustedThreshold) {
|
||||
label = trustedLabel;
|
||||
}
|
||||
|
||||
contributorCache.set(login, label);
|
||||
return label;
|
||||
}
|
||||
|
||||
async function applySizeLabel(pullRequest, currentLabels, labelNames) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
||||
const totalChangedLines = files.reduce((total, file) => {
|
||||
const path = file.filename ?? "";
|
||||
if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
|
||||
return total;
|
||||
}
|
||||
return total + (file.additions ?? 0) + (file.deletions ?? 0);
|
||||
}, 0);
|
||||
|
||||
let targetSizeLabel = "size: XL";
|
||||
if (totalChangedLines < 50) {
|
||||
targetSizeLabel = "size: XS";
|
||||
} else if (totalChangedLines < 200) {
|
||||
targetSizeLabel = "size: S";
|
||||
} else if (totalChangedLines < 500) {
|
||||
targetSizeLabel = "size: M";
|
||||
} else if (totalChangedLines < 1000) {
|
||||
targetSizeLabel = "size: L";
|
||||
}
|
||||
|
||||
for (const label of currentLabels) {
|
||||
const name = label.name ?? "";
|
||||
if (!sizeLabels.includes(name)) {
|
||||
continue;
|
||||
}
|
||||
if (name === targetSizeLabel) {
|
||||
continue;
|
||||
}
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
name,
|
||||
});
|
||||
labelNames.delete(name);
|
||||
}
|
||||
|
||||
if (!labelNames.has(targetSizeLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [targetSizeLabel],
|
||||
});
|
||||
labelNames.add(targetSizeLabel);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyContributorLabel(pullRequest, labelNames) {
|
||||
const login = pullRequest.user?.login;
|
||||
if (!login) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = await resolveContributorLabel(login);
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (labelNames.has(label)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [label],
|
||||
});
|
||||
labelNames.add(label);
|
||||
}
|
||||
|
||||
await ensureSizeLabels();
|
||||
|
||||
let page = 1;
|
||||
let processed = 0;
|
||||
|
||||
while (processed < maxCount) {
|
||||
const remaining = maxCount - processed;
|
||||
const pageSize = processAll ? perPage : Math.min(perPage, remaining);
|
||||
const { data: pullRequests } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: pageSize,
|
||||
page,
|
||||
});
|
||||
|
||||
if (pullRequests.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const pullRequest of pullRequests) {
|
||||
if (!processAll && processed >= maxCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const labelNames = new Set(
|
||||
currentLabels.map((label) => label.name).filter((name) => typeof name === "string"),
|
||||
);
|
||||
|
||||
await applySizeLabel(pullRequest, currentLabels, labelNames);
|
||||
await applyContributorLabel(pullRequest, labelNames);
|
||||
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
if (pullRequests.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
core.info(`Processed ${processed} pull requests.`);
|
||||
|
||||
label-issues:
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const login = context.payload.issue?.user?.login;
|
||||
if (!login) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = `${context.repo.owner}/${context.repo.repo}`;
|
||||
const trustedLabel = "trusted-contributor";
|
||||
const experiencedLabel = "experienced-contributor";
|
||||
const trustedThreshold = 4;
|
||||
const experiencedThreshold = 10;
|
||||
|
||||
let isMaintainer = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: "maintainer",
|
||||
username: login,
|
||||
});
|
||||
isMaintainer = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMaintainer) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
labels: ["maintainer"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`;
|
||||
let mergedCount = 0;
|
||||
try {
|
||||
const merged = await github.rest.search.issuesAndPullRequests({
|
||||
q: mergedQuery,
|
||||
per_page: 1,
|
||||
});
|
||||
mergedCount = merged?.data?.total_count ?? 0;
|
||||
} catch (error) {
|
||||
if (error?.status !== 422) {
|
||||
throw error;
|
||||
}
|
||||
core.warning(`Skipping merged search for ${login}; treating as 0.`);
|
||||
}
|
||||
|
||||
if (mergedCount >= experiencedThreshold) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
labels: [experiencedLabel],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (mergedCount >= trustedThreshold) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
labels: [trustedLabel],
|
||||
});
|
||||
}
|
||||
56
.github/workflows/sandbox-common-smoke.yml
vendored
56
.github/workflows/sandbox-common-smoke.yml
vendored
@ -1,56 +0,0 @@
|
||||
name: Sandbox Common Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- Dockerfile.sandbox
|
||||
- Dockerfile.sandbox-common
|
||||
- scripts/sandbox-common-setup.sh
|
||||
pull_request:
|
||||
paths:
|
||||
- Dockerfile.sandbox
|
||||
- Dockerfile.sandbox-common
|
||||
- scripts/sandbox-common-setup.sh
|
||||
|
||||
concurrency:
|
||||
group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
sandbox-common-smoke:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Build minimal sandbox base (USER sandbox)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker build -t openclaw-sandbox-smoke-base:bookworm-slim - <<'EOF'
|
||||
FROM debian:bookworm-slim
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
EOF
|
||||
|
||||
- name: Build sandbox-common image (root for installs, sandbox at runtime)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE_IMAGE="openclaw-sandbox-smoke-base:bookworm-slim" \
|
||||
TARGET_IMAGE="openclaw-sandbox-common-smoke:bookworm-slim" \
|
||||
PACKAGES="ca-certificates" \
|
||||
INSTALL_PNPM=0 \
|
||||
INSTALL_BUN=0 \
|
||||
INSTALL_BREW=0 \
|
||||
FINAL_USER=sandbox \
|
||||
scripts/sandbox-common-setup.sh
|
||||
|
||||
u="$(docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')"
|
||||
test "$u" = "sandbox"
|
||||
51
.github/workflows/stale.yml
vendored
51
.github/workflows/stale.yml
vendored
@ -1,51 +0,0 @@
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Mark stale issues and pull requests
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token }}
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 5
|
||||
days-before-pr-stale: 5
|
||||
days-before-pr-close: 3
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
operations-per-run: 10000
|
||||
exempt-all-assignees: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
stale-pr-message: |
|
||||
This pull request has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
|
||||
close-issue-reason: not_planned
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
|
||||
That channel is the escape hatch for high-quality PRs that get auto-closed.
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -9,7 +9,7 @@ bun.lockb
|
||||
coverage
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
@ -83,6 +83,10 @@ USER.md
|
||||
.tgz
|
||||
.idea
|
||||
|
||||
# Next.js
|
||||
**/.next/
|
||||
next-env.d.ts
|
||||
|
||||
# local tooling
|
||||
.serena/
|
||||
|
||||
|
||||
797
README.md
797
README.md
@ -1,580 +1,421 @@
|
||||
# 🦞 OpenClaw — Personal AI Assistant
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" width="500">
|
||||
</picture>
|
||||
<pre align="center">
|
||||
██╗██████╗ ██████╗ ███╗ ██╗ ██████╗██╗ █████╗ ██╗ ██╗
|
||||
██║██╔══██╗██╔═══██╗████╗ ██║██╔════╝██║ ██╔══██╗██║ ██║
|
||||
██║██████╔╝██║ ██║██╔██╗ ██║██║ ██║ ███████║██║ █╗ ██║
|
||||
██║██╔══██╗██║ ██║██║╚██╗██║██║ ██║ ██╔══██║██║███╗██║
|
||||
██║██║ ██║╚██████╔╝██║ ╚████║╚██████╗███████╗██║ ██║╚███╔███╔╝
|
||||
╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝
|
||||
</pre>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>EXFOLIATE! EXFOLIATE!</strong>
|
||||
<strong>AI CRM, hosted locally on your Mac.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/openclaw/openclaw/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/openclaw/openclaw/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/openclaw/openclaw/releases"><img src="https://img.shields.io/github/v/release/openclaw/openclaw?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
Chat with your database. Automate outreach. Enrich leads. All from a single prompt.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/ironclaw"><img src="https://img.shields.io/npm/v/ironclaw?style=for-the-badge&color=000" alt="npm version"></a>
|
||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
**OpenClaw** is a _personal AI assistant_ you run on your own devices.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
<p align="center">
|
||||
<a href="https://ironclaw.sh">Website</a> · <a href="https://docs.openclaw.ai">Docs</a> · <a href="https://github.com/openclaw/openclaw">OpenClaw Framework</a> · <a href="https://discord.gg/clawd">Discord</a> · <a href="https://clawhub.com">Skills Store</a>
|
||||
</p>
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
---
|
||||
|
||||
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
## Install
|
||||
|
||||
Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal.
|
||||
The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Works with npm, pnpm, or bun.
|
||||
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
## Sponsors
|
||||
|
||||
| OpenAI | Blacksmith |
|
||||
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| [](https://openai.com/) | [](https://blacksmith.sh/) |
|
||||
|
||||
**Subscriptions (OAuth):**
|
||||
|
||||
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
- Models config + CLI: [Models](https://docs.openclaw.ai/concepts/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.openclaw.ai/concepts/model-failover)
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
**Runtime: Node 22+**
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
# or: pnpm add -g openclaw@latest
|
||||
|
||||
openclaw onboard --install-daemon
|
||||
npm i -g ironclaw
|
||||
ironclaw onboard --install-daemon
|
||||
```
|
||||
|
||||
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
|
||||
Opens at `localhost:3100`. That's it.
|
||||
|
||||
## Quick start (TL;DR)
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
```bash
|
||||
openclaw onboard --install-daemon
|
||||
|
||||
openclaw gateway --port 18789 --verbose
|
||||
|
||||
# Send a message
|
||||
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
||||
openclaw agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
Upgrading? [Updating guide](https://docs.openclaw.ai/install/updating) (and run `openclaw doctor`).
|
||||
|
||||
## Development channels
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
|
||||
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
|
||||
|
||||
Switch channels (git + npm): `openclaw update --channel stable|beta|dev`.
|
||||
Details: [Development channels](https://docs.openclaw.ai/install/development-channels).
|
||||
|
||||
## From source (development)
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openclaw/openclaw.git
|
||||
cd openclaw
|
||||
|
||||
pnpm install
|
||||
pnpm ui:build # auto-installs UI deps on first run
|
||||
pnpm build
|
||||
|
||||
pnpm openclaw onboard --install-daemon
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary.
|
||||
|
||||
## Security defaults (DM access)
|
||||
|
||||
OpenClaw connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
||||
|
||||
Full security guide: [Security](https://docs.openclaw.ai/gateway/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
|
||||
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dmPolicy="pairing"` / `channels.slack.dmPolicy="pairing"`; legacy: `channels.discord.dm.policy`, `channels.slack.dm.policy`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `openclaw pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`).
|
||||
|
||||
Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.openclaw.ai/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://docs.openclaw.ai/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes).
|
||||
- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#openclaw/openclaw&type=date&legend=top-left)
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
|
||||
- [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor).
|
||||
- [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/concepts/groups).
|
||||
- [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio).
|
||||
|
||||
### Channels
|
||||
|
||||
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
|
||||
- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
|
||||
|
||||
### Apps + nodes
|
||||
|
||||
- [macOS app](https://docs.openclaw.ai/platforms/macos): menu bar control plane, [Voice Wake](https://docs.openclaw.ai/nodes/voicewake)/PTT, [Talk Mode](https://docs.openclaw.ai/nodes/talk) overlay, [WebChat](https://docs.openclaw.ai/web/webchat), debug tools, [remote gateway](https://docs.openclaw.ai/gateway/remote) control.
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, optional SMS.
|
||||
- [macOS node mode](https://docs.openclaw.ai/nodes): system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
|
||||
- [Browser control](https://docs.openclaw.ai/tools/browser): dedicated openclaw Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.openclaw.ai/platforms/mac/canvas): [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.openclaw.ai/nodes): camera snap/clip, screen record, [location.get](https://docs.openclaw.ai/nodes/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.openclaw.ai/automation/cron-jobs); [webhooks](https://docs.openclaw.ai/automation/webhook); [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub).
|
||||
- [Skills platform](https://docs.openclaw.ai/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Runtime + safety
|
||||
|
||||
- [Channel routing](https://docs.openclaw.ai/concepts/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming).
|
||||
- [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking).
|
||||
- [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning).
|
||||
- [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting).
|
||||
|
||||
### Ops + packaging
|
||||
|
||||
- [Control UI](https://docs.openclaw.ai/web) + [WebChat](https://docs.openclaw.ai/web/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.openclaw.ai/gateway/tailscale) or [SSH tunnels](https://docs.openclaw.ai/gateway/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.openclaw.ai/install/nix) for declarative config; [Docker](https://docs.openclaw.ai/install/docker)-based installs.
|
||||
- [Doctor](https://docs.openclaw.ai/gateway/doctor) migrations, [logging](https://docs.openclaw.ai/logging).
|
||||
|
||||
## How it works (short)
|
||||
Three steps total:
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
||||
1. npm i -g ironclaw
|
||||
2. ironclaw onboard
|
||||
3. ironclaw gateway start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What is Ironclaw?
|
||||
|
||||
Ironclaw is a personal AI agent and CRM that runs locally on your machine. It connects to every messaging channel you use, manages structured data through DuckDB, browses the web with your Chrome profile, and gives you a full web UI for pipeline management, analytics, and document management.
|
||||
|
||||
Built on [OpenClaw](https://github.com/openclaw/openclaw) with **Vercel AI SDK v6** as the LLM orchestration layer.
|
||||
|
||||
**One prompt does everything:**
|
||||
|
||||
- "Find YC W26 founders building AI companies" → scrapes YC directory + LinkedIn, returns 127 matches
|
||||
- "Enrich all contacts with LinkedIn and email" → fills in profiles with 98% coverage
|
||||
- "Send personalized messages to qualified leads" → crafts and sends custom outreach
|
||||
- "Show me pipeline stats for this quarter" → generates interactive charts from live data
|
||||
- "Set up weekly follow-up sequences for all leads" → creates automation rules that run in the background
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Find Leads
|
||||
|
||||
Type a prompt, Ironclaw scrapes the web using your actual Chrome profile (all your auth sessions, cookies, history). It logs into LinkedIn, browses YC batches, pulls company data. No separate login, no API keys for browsing.
|
||||
|
||||
### Enrich Data
|
||||
|
||||
Point it at your contacts table. It fills in LinkedIn URLs, email addresses, education, company info. Enrichment runs in bulk with real-time progress.
|
||||
|
||||
### Send Outreach
|
||||
|
||||
Personalized LinkedIn messages, cold emails, follow-up sequences. Each message is customized per lead. You see status (Sent, Sending, Queued) in real time.
|
||||
|
||||
### Analyze Pipeline
|
||||
|
||||
Ask for analytics in plain English. Ironclaw queries your DuckDB workspace and generates interactive Recharts dashboards inline. Pipeline funnels, outreach activity charts, conversion rates, donut breakdowns.
|
||||
|
||||
### Automate Everything
|
||||
|
||||
Cron jobs that run on schedule. Follow-up if no reply after 3 days. Move leads to Qualified when they reply. Weekly pipeline reports every Monday. Alert on high-intent replies.
|
||||
|
||||
---
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
### Uses Your Chrome Profile
|
||||
|
||||
Unlike other AI tools, Ironclaw copies your existing Chrome profile with all your auth sessions, cookies, and history. It logs into LinkedIn, scrapes YC batches, and sends messages as you. No separate browser login needed.
|
||||
|
||||
### Chat with Your Database
|
||||
|
||||
Ask questions in plain English. Ironclaw translates to SQL, queries your local DuckDB, and returns structured results. Like having a data analyst on speed dial.
|
||||
|
||||
```
|
||||
You: "How many founders have we contacted from YC W26?"
|
||||
|
||||
→ SELECT "Status", COUNT(*) as count FROM v_founders GROUP BY "Status";
|
||||
|
||||
You've contacted 67 of 200 founders. 31 are qualified, 13 converted.
|
||||
Reply rate is 34%.
|
||||
```
|
||||
|
||||
### Coding Agent with Diffs
|
||||
|
||||
Ironclaw writes code. Review changes in a rich diff viewer before applying. Config changes, automation scripts, data transformations. All with diffs you approve.
|
||||
|
||||
### Your Second Brain
|
||||
|
||||
Full access to your Mac: files, apps, documents. It remembers context across sessions via persistent memory files. Learns your preferences. Proactively handles tasks during heartbeat checks.
|
||||
|
||||
---
|
||||
|
||||
## Web UI (Dench)
|
||||
|
||||
The web app runs at `localhost:3100` and includes:
|
||||
|
||||
- **Chat panel** with streaming responses, chain-of-thought reasoning display, and markdown rendering
|
||||
- **Workspace sidebar** with file manager tree, knowledge base, and database viewer
|
||||
- **Object tables** powered by TanStack, with sorting, filtering, row selection, and bulk operations
|
||||
- **Entry detail modals** with field editing and media previews
|
||||
- **Kanban boards** with drag-and-drop that auto-update as leads reply
|
||||
- **Interactive report cards** with chart panels (bar, line, area, pie, donut, funnel, scatter, radar) and filter bars
|
||||
- **Document editor** with embedded live charts
|
||||
- **Media viewer** supporting images, video, audio, and PDFs
|
||||
|
||||
---
|
||||
|
||||
## Multi-Channel Inbox
|
||||
|
||||
One agent, every channel. Connect any messaging platform. Your AI agent responds everywhere, managed from a single terminal.
|
||||
|
||||
| Channel | Setup |
|
||||
| ------------------- | ------------------------------------------------------------- |
|
||||
| **WhatsApp** | `ironclaw channels login` + set `channels.whatsapp.allowFrom` |
|
||||
| **Telegram** | Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` |
|
||||
| **Slack** | Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` |
|
||||
| **Discord** | Set `DISCORD_BOT_TOKEN` or `channels.discord.token` |
|
||||
| **Signal** | Requires `signal-cli` + `channels.signal` config |
|
||||
| **iMessage** | Via BlueBubbles (recommended) or legacy macOS integration |
|
||||
| **Microsoft Teams** | Configure Teams app + Bot Framework |
|
||||
| **Google Chat** | Chat API integration |
|
||||
| **Matrix** | Extension channel |
|
||||
| **WebChat** | Built-in, uses Gateway WebSocket directly |
|
||||
|
||||
```
|
||||
WhatsApp · Telegram · Slack · Discord
|
||||
Signal · iMessage · Teams · WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │
|
||||
│ (control plane) │
|
||||
│ ws://127.0.0.1:18789 │
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (openclaw …)
|
||||
├─ WebChat UI
|
||||
├─ macOS app
|
||||
└─ iOS / Android nodes
|
||||
┌────────────────────────────┐
|
||||
│ Ironclaw Gateway │
|
||||
│ ws://127.0.0.1:18789 │
|
||||
└─────────────┬──────────────┘
|
||||
│
|
||||
┌───────────┼───────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
AI SDK Web UI CLI
|
||||
Engine (Dench) (ironclaw)
|
||||
```
|
||||
|
||||
## Key subsystems
|
||||
---
|
||||
|
||||
- **[Gateway WebSocket network](https://docs.openclaw.ai/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.openclaw.ai/gateway)).
|
||||
- **[Tailscale exposure](https://docs.openclaw.ai/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.openclaw.ai/gateway/remote)).
|
||||
- **[Browser control](https://docs.openclaw.ai/tools/browser)** — openclaw‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.openclaw.ai/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always‑on speech and continuous conversation.
|
||||
- **[Nodes](https://docs.openclaw.ai/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
## Integrations
|
||||
|
||||
## Tailscale access (Gateway dashboard)
|
||||
Import your data from anywhere: Google Drive, Notion, Salesforce, HubSpot, Gmail, Calendar, Obsidian, Slack, LinkedIn, Asana, Monday, ClickUp, PostHog, Sheets, Apple Notes, GitHub, and 50+ more via the Skills Store.
|
||||
|
||||
OpenClaw can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
|
||||
---
|
||||
|
||||
- `off`: no Tailscale automation (default).
|
||||
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
|
||||
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
|
||||
## Skills Platform
|
||||
|
||||
Notes:
|
||||
Extend your agent with a single command. Browse skills from [skills.sh](https://skills.sh) and [ClawHub](https://clawhub.com).
|
||||
|
||||
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (OpenClaw enforces this).
|
||||
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
|
||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
||||
```bash
|
||||
npx skills add vercel-labs/agent-browser
|
||||
```
|
||||
|
||||
Details: [Tailscale guide](https://docs.openclaw.ai/gateway/tailscale) · [Web surfaces](https://docs.openclaw.ai/web)
|
||||
Popular skills:
|
||||
|
||||
## Remote Gateway (Linux is great)
|
||||
| Skill | Description | Installs |
|
||||
| ----------------------- | ---------------------------------------------------------- | -------- |
|
||||
| `crm-automation` | CRM workflow automation, lead scoring, pipeline management | 18.2K |
|
||||
| `linkedin-outreach` | Automated LinkedIn prospecting and follow-up sequences | 14.8K |
|
||||
| `lead-enrichment` | Enrich contacts with LinkedIn, email, and company data | 12.1K |
|
||||
| `email-sequences` | Multi-step cold email campaigns with personalization | 9.7K |
|
||||
| `agent-browser` | Browser automation and web scraping for agents | 35.8K |
|
||||
| `web-design-guidelines` | Best practices for modern web design | 99.4K |
|
||||
| `frontend-design` | Expert frontend engineering patterns | 68.9K |
|
||||
| `typescript-expert` | Advanced TypeScript patterns and best practices | 15.1K |
|
||||
|
||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||
Or write your own. Skills are just a `SKILL.md` file with instructions + optional scripts.
|
||||
|
||||
- **Gateway host** runs the exec tool and channel connections by default.
|
||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
||||
---
|
||||
|
||||
Details: [Remote access](https://docs.openclaw.ai/gateway/remote) · [Nodes](https://docs.openclaw.ai/nodes) · [Security](https://docs.openclaw.ai/gateway/security)
|
||||
## Analytics & Reports
|
||||
|
||||
## macOS permissions via the Gateway protocol
|
||||
Ask "show me pipeline analytics" and get interactive charts generated from your live DuckDB data.
|
||||
|
||||
The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:
|
||||
- **Outreach Activity** — area charts tracking LinkedIn and email volume over time
|
||||
- **Pipeline Breakdown** — donut charts showing lead distribution by status
|
||||
- **Conversion Funnel** — stage-by-stage conversion rates with overall percentage
|
||||
- **Deal Pipeline** — bar charts, funnel views, revenue by stage
|
||||
- **Custom Reports** — save as `.report.json` files that render as live dashboards
|
||||
|
||||
- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`).
|
||||
- `system.notify` posts a user notification and fails if notifications are denied.
|
||||
- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.
|
||||
Reports use the `report-json` format and render inline in chat as interactive Recharts components.
|
||||
|
||||
Elevated bash (host permissions) is separate from macOS TCC:
|
||||
---
|
||||
|
||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||
## Kanban Pipeline
|
||||
|
||||
Details: [Nodes](https://docs.openclaw.ai/nodes) · [macOS app](https://docs.openclaw.ai/platforms/macos) · [Gateway protocol](https://docs.openclaw.ai/concepts/architecture)
|
||||
Drag-and-drop kanban boards that auto-update as leads reply. Ironclaw moves cards through your pipeline automatically.
|
||||
|
||||
## Agent to Agent (sessions\_\* tools)
|
||||
Columns like New Lead → Contacted → Qualified → Demo Scheduled → Closed map to your sales process. Each card shows the lead name, company, and last action taken.
|
||||
|
||||
- Use these to coordinate work across sessions without jumping between chat surfaces.
|
||||
- `sessions_list` — discover active sessions (agents) and their metadata.
|
||||
- `sessions_history` — fetch transcript logs for a session.
|
||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||
---
|
||||
|
||||
Details: [Session tools](https://docs.openclaw.ai/concepts/session-tool)
|
||||
## Documents, Reports & Cron Jobs
|
||||
|
||||
## Skills registry (ClawHub)
|
||||
### Documents
|
||||
|
||||
ClawHub is a minimal skill registry. With ClawHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
Rich markdown documents with embedded live charts. SOPs, playbooks, onboarding guides. Documents nest under objects or stand alone in the file tree.
|
||||
|
||||
[ClawHub](https://clawhub.com)
|
||||
### Cron Jobs
|
||||
|
||||
## Chat commands
|
||||
Scheduled automations that run in the background:
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
| Job | Schedule | Description |
|
||||
| ---------------------- | -------------- | ------------------------------------ |
|
||||
| Weekly pipeline report | `0 9 * * MON` | Auto-generates pipeline summary |
|
||||
| Lead enrichment sync | Every 6h | Enriches new contacts |
|
||||
| Email follow-up check | Every 30m | Checks for replies needing follow-up |
|
||||
| Inbox digest | `0 8,18 * * *` | Morning and evening inbox summary |
|
||||
| Competitor monitoring | `0 6 * * *` | Tracks competitor activity |
|
||||
| CRM backup to S3 | `0 2 * * *` | Nightly workspace backup |
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
- `/verbose on|off`
|
||||
- `/usage off|tokens|full` — per-response usage footer
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
```bash
|
||||
ironclaw cron list
|
||||
```
|
||||
|
||||
## Apps (optional)
|
||||
---
|
||||
|
||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||
## Gateway
|
||||
|
||||
If you plan to build/run companion apps, follow the platform runbooks below.
|
||||
The Gateway is the local-first WebSocket control plane that routes everything:
|
||||
|
||||
### macOS (OpenClaw.app) (optional)
|
||||
- **Sessions** — main sessions for DMs, isolated sessions for group chats, sub-agent sessions for background tasks
|
||||
- **Channels** — route inbound messages from any platform to the right session
|
||||
- **Tools** — browser control, canvas, nodes, cron, messaging, file operations
|
||||
- **Events** — webhooks, Gmail Pub/Sub, cron triggers, heartbeats
|
||||
- **Multi-agent routing** — route channels/accounts/peers to isolated agents with separate workspaces
|
||||
|
||||
- Menu bar control for the Gateway and health.
|
||||
- Voice Wake + push-to-talk overlay.
|
||||
- WebChat + debug tools.
|
||||
- Remote gateway control over SSH.
|
||||
### Session Model
|
||||
|
||||
Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
|
||||
- `main` — direct 1:1 chats with persistent context
|
||||
- `group` — isolated per-group sessions with mention gating
|
||||
- `isolated` — sub-agent sessions for background tasks (cron jobs, spawned work)
|
||||
|
||||
### iOS node (optional)
|
||||
### Security
|
||||
|
||||
- Pairs as a node via the Bridge.
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `openclaw nodes …`.
|
||||
- **DM pairing** enabled by default. Unknown senders get a pairing code.
|
||||
- Approve with `ironclaw pairing approve <channel> <code>`
|
||||
- Non-main sessions can be sandboxed in Docker
|
||||
- Run `ironclaw doctor` to audit DM policies
|
||||
|
||||
Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
|
||||
---
|
||||
|
||||
### Android node (optional)
|
||||
## Companion Apps
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
|
||||
- **macOS** — menu bar app with Voice Wake, Push-to-Talk, Talk Mode overlay, WebChat, and debug tools
|
||||
- **iOS** — Canvas, Voice Wake, Talk Mode, camera, screen recording, Bonjour pairing
|
||||
- **Android** — Canvas, Talk Mode, camera, screen recording, optional SMS
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
- Workspace root: `~/.openclaw/workspace` (configurable via `agents.defaults.workspace`).
|
||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||
- Skills: `~/.openclaw/workspace/skills/<skill>/SKILL.md`.
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
||||
Config lives at `~/.openclaw/openclaw.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
},
|
||||
}
|
||||
Supports all latest and greatest mainstream LLM models. BYOK.
|
||||
|
||||
---
|
||||
|
||||
## Chat Commands
|
||||
|
||||
Send these in any connected channel:
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------- | ------------------------------- |
|
||||
| `/status` | Session status (model + tokens) |
|
||||
| `/new` or `/reset` | Reset the session |
|
||||
| `/compact` | Compact session context |
|
||||
| `/think <level>` | Set thinking level |
|
||||
| `/verbose on\|off` | Toggle verbose output |
|
||||
| `/usage off\|tokens\|full` | Per-response usage footer |
|
||||
| `/restart` | Restart the gateway |
|
||||
| `/activation mention\|always` | Group activation toggle |
|
||||
|
||||
---
|
||||
|
||||
## DuckDB Workspace
|
||||
|
||||
All structured data lives in a local DuckDB database. Objects, fields, entries, relations. EAV pattern with auto-generated PIVOT views so you query like normal tables:
|
||||
|
||||
```sql
|
||||
SELECT * FROM v_leads WHERE "Status" = 'New' ORDER BY created_at DESC LIMIT 50;
|
||||
SELECT "Status", COUNT(*) FROM v_leads GROUP BY "Status";
|
||||
```
|
||||
|
||||
[Full configuration reference (all keys + examples).](https://docs.openclaw.ai/gateway/configuration)
|
||||
Features:
|
||||
|
||||
## Security model (important)
|
||||
- Custom objects with typed fields (text, email, phone, number, boolean, date, enum, relation, user)
|
||||
- Full-text search
|
||||
- Bulk import/export (CSV, Parquet)
|
||||
- Automatic view generation
|
||||
- Kanban support with drag-and-drop
|
||||
|
||||
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
||||
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
---
|
||||
|
||||
Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker + sandboxing](https://docs.openclaw.ai/install/docker) · [Sandbox config](https://docs.openclaw.ai/gateway/configuration)
|
||||
## Quick Start
|
||||
|
||||
### [WhatsApp](https://docs.openclaw.ai/channels/whatsapp)
|
||||
```bash
|
||||
# Install
|
||||
npm i -g ironclaw
|
||||
|
||||
- Link the device: `pnpm openclaw channels login` (stores creds in `~/.openclaw/credentials`).
|
||||
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
|
||||
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
# Run onboarding wizard
|
||||
ironclaw onboard --install-daemon
|
||||
|
||||
### [Telegram](https://docs.openclaw.ai/channels/telegram)
|
||||
# Start the gateway
|
||||
ironclaw gateway start
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
|
||||
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` as needed.
|
||||
# Open the web UI
|
||||
open http://localhost:3100
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF",
|
||||
},
|
||||
},
|
||||
}
|
||||
# Talk to your agent from CLI
|
||||
ironclaw agent --message "Summarize my inbox" --thinking high
|
||||
|
||||
# Send a message
|
||||
ironclaw message send --to +1234567890 --message "Hello from Ironclaw"
|
||||
```
|
||||
|
||||
### [Slack](https://docs.openclaw.ai/channels/slack)
|
||||
---
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
|
||||
## From Source
|
||||
|
||||
### [Discord](https://docs.openclaw.ai/channels/discord)
|
||||
```bash
|
||||
git clone https://github.com/kumarabhirup/ironclaw.git
|
||||
cd ironclaw
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
token: "1234abcd",
|
||||
},
|
||||
},
|
||||
}
|
||||
pnpm dev onboard --install-daemon
|
||||
```
|
||||
|
||||
### [Signal](https://docs.openclaw.ai/channels/signal)
|
||||
Web UI development:
|
||||
|
||||
- Requires `signal-cli` and a `channels.signal` config section.
|
||||
|
||||
### [BlueBubbles (iMessage)](https://docs.openclaw.ai/channels/bluebubbles)
|
||||
|
||||
- **Recommended** iMessage integration.
|
||||
- Configure `channels.bluebubbles.serverUrl` + `channels.bluebubbles.password` and a webhook (`channels.bluebubbles.webhookPath`).
|
||||
- The BlueBubbles server runs on macOS; the Gateway can run on macOS or elsewhere.
|
||||
|
||||
### [iMessage (legacy)](https://docs.openclaw.ai/channels/imessage)
|
||||
|
||||
- Legacy macOS-only integration via `imsg` (Messages must be signed in).
|
||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams)
|
||||
|
||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
||||
|
||||
### [WebChat](https://docs.openclaw.ai/web/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
|
||||
Browser control (optional):
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
color: "#FF4500",
|
||||
},
|
||||
}
|
||||
```bash
|
||||
cd apps/web
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Docs
|
||||
---
|
||||
|
||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
## Project Structure
|
||||
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.openclaw.ai)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.openclaw.ai/concepts/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.openclaw.ai/gateway/configuration)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.openclaw.ai/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.openclaw.ai/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.openclaw.ai/gateway/remote)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.openclaw.ai/start/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.openclaw.ai/automation/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.openclaw.ai/platforms/windows), [Linux](https://docs.openclaw.ai/platforms/linux), [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.openclaw.ai/channels/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.openclaw.ai/gateway/security)
|
||||
```
|
||||
src/ Core CLI, commands, gateway, agent, media pipeline
|
||||
apps/web/ Next.js web UI (Dench)
|
||||
apps/ios/ iOS companion node
|
||||
apps/android/ Android companion node
|
||||
apps/macos/ macOS menu bar app
|
||||
extensions/ Channel plugins (MS Teams, Matrix, Zalo, voice-call)
|
||||
docs/ Documentation
|
||||
scripts/ Build, deploy, and utility scripts
|
||||
skills/ Workspace skills
|
||||
```
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
---
|
||||
|
||||
- [Discovery + transports](https://docs.openclaw.ai/gateway/discovery)
|
||||
- [Bonjour/mDNS](https://docs.openclaw.ai/gateway/bonjour)
|
||||
- [Gateway pairing](https://docs.openclaw.ai/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.openclaw.ai/gateway/remote-gateway-readme)
|
||||
- [Control UI](https://docs.openclaw.ai/web/control-ui)
|
||||
- [Dashboard](https://docs.openclaw.ai/web/dashboard)
|
||||
## Development
|
||||
|
||||
## Operations & troubleshooting
|
||||
```bash
|
||||
pnpm install # Install deps
|
||||
pnpm build # Type-check + build
|
||||
pnpm check # Lint + format check
|
||||
pnpm test # Run tests (vitest)
|
||||
pnpm test:coverage # Tests with coverage
|
||||
pnpm dev # Dev mode (auto-reload)
|
||||
```
|
||||
|
||||
- [Health checks](https://docs.openclaw.ai/gateway/health)
|
||||
- [Gateway lock](https://docs.openclaw.ai/gateway/gateway-lock)
|
||||
- [Background process](https://docs.openclaw.ai/gateway/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.openclaw.ai/tools/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.openclaw.ai/logging)
|
||||
---
|
||||
|
||||
## Deep dives
|
||||
## Upstream
|
||||
|
||||
- [Agent loop](https://docs.openclaw.ai/concepts/agent-loop)
|
||||
- [Presence](https://docs.openclaw.ai/concepts/presence)
|
||||
- [TypeBox schemas](https://docs.openclaw.ai/concepts/typebox)
|
||||
- [RPC adapters](https://docs.openclaw.ai/reference/rpc)
|
||||
- [Queue](https://docs.openclaw.ai/concepts/queue)
|
||||
Ironclaw is built on [OpenClaw](https://github.com/openclaw/openclaw). To sync with upstream:
|
||||
|
||||
## Workspace & skills
|
||||
```bash
|
||||
git remote add upstream https://github.com/openclaw/openclaw.git
|
||||
git fetch upstream
|
||||
git merge upstream/main
|
||||
```
|
||||
|
||||
- [Skills config](https://docs.openclaw.ai/tools/skills-config)
|
||||
- [Default AGENTS](https://docs.openclaw.ai/reference/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.openclaw.ai/reference/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.openclaw.ai/reference/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.openclaw.ai/reference/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.openclaw.ai/reference/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.openclaw.ai/reference/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.openclaw.ai/reference/templates/USER)
|
||||
---
|
||||
|
||||
## Platform internals
|
||||
## Open Source
|
||||
|
||||
- [macOS dev setup](https://docs.openclaw.ai/platforms/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.openclaw.ai/platforms/mac/voicewake)
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios)
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android)
|
||||
- [Windows (WSL2)](https://docs.openclaw.ai/platforms/windows)
|
||||
- [Linux app](https://docs.openclaw.ai/platforms/linux)
|
||||
MIT Licensed. Fork it, extend it, make it yours.
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
- [docs.openclaw.ai/gmail-pubsub](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
|
||||
## Molty
|
||||
|
||||
OpenClaw was built for **Molty**, a space lobster AI assistant. 🦞
|
||||
by Peter Steinberger and the community.
|
||||
|
||||
- [openclaw.ai](https://openclaw.ai)
|
||||
- [soul.md](https://soul.md)
|
||||
- [steipete.me](https://steipete.me)
|
||||
- [@openclaw](https://x.com/openclaw)
|
||||
|
||||
## Community
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
[pi-mono](https://github.com/badlogic/pi-mono).
|
||||
Special thanks to Adam Doppelt for lobster.bot.
|
||||
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/sktbrd"><img src="https://avatars.githubusercontent.com/u/116202536?v=4&s=48" width="48" height="48" alt="sktbrd" title="sktbrd"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/quotentiroler"><img src="https://avatars.githubusercontent.com/u/40643627?v=4&s=48" width="48" height="48" alt="quotentiroler" title="quotentiroler"/></a> <a href="https://github.com/VeriteIgiraneza"><img src="https://avatars.githubusercontent.com/u/69280208?v=4&s=48" width="48" height="48" alt="Verite Igiraneza" title="Verite Igiraneza"/></a>
|
||||
<a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a>
|
||||
<a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/vincentkoc"><img src="https://avatars.githubusercontent.com/u/25068?v=4&s=48" width="48" height="48" alt="vincentkoc" title="vincentkoc"/></a> <a href="https://github.com/smartprogrammer93"><img src="https://avatars.githubusercontent.com/u/33181301?v=4&s=48" width="48" height="48" alt="smartprogrammer93" title="smartprogrammer93"/></a> <a href="https://github.com/advaitpaliwal"><img src="https://avatars.githubusercontent.com/u/66044327?v=4&s=48" width="48" height="48" alt="advaitpaliwal" title="advaitpaliwal"/></a> <a href="https://github.com/HenryLoenwind"><img src="https://avatars.githubusercontent.com/u/1485873?v=4&s=48" width="48" height="48" alt="HenryLoenwind" title="HenryLoenwind"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/abdelsfane"><img src="https://avatars.githubusercontent.com/u/32418586?v=4&s=48" width="48" height="48" alt="abdelsfane" title="abdelsfane"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshavant"><img src="https://avatars.githubusercontent.com/u/830519?v=4&s=48" width="48" height="48" alt="joshavant" title="joshavant"/></a>
|
||||
<a href="https://github.com/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/ranausmanai"><img src="https://avatars.githubusercontent.com/u/257128159?v=4&s=48" width="48" height="48" alt="ranausmanai" title="ranausmanai"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/heyhudson"><img src="https://avatars.githubusercontent.com/u/258693705?v=4&s=48" width="48" height="48" alt="heyhudson" title="heyhudson"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/ethanpalm"><img src="https://avatars.githubusercontent.com/u/56270045?v=4&s=48" width="48" height="48" alt="ethanpalm" title="ethanpalm"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/yinghaosang"><img src="https://avatars.githubusercontent.com/u/261132136?v=4&s=48" width="48" height="48" alt="yinghaosang" title="yinghaosang"/></a> <a href="https://github.com/aether-ai-agent"><img src="https://avatars.githubusercontent.com/u/261339948?v=4&s=48" width="48" height="48" alt="aether-ai-agent" title="aether-ai-agent"/></a>
|
||||
<a href="https://github.com/nabbilkhan"><img src="https://avatars.githubusercontent.com/u/203121263?v=4&s=48" width="48" height="48" alt="nabbilkhan" title="nabbilkhan"/></a> <a href="https://github.com/Mrseenz"><img src="https://avatars.githubusercontent.com/u/101962919?v=4&s=48" width="48" height="48" alt="Mrseenz" title="Mrseenz"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/coygeek"><img src="https://avatars.githubusercontent.com/u/65363919?v=4&s=48" width="48" height="48" alt="coygeek" title="coygeek"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></a> <a href="https://github.com/buerbaumer"><img src="https://avatars.githubusercontent.com/u/44548809?v=4&s=48" width="48" height="48" alt="buerbaumer" title="buerbaumer"/></a> <a href="https://github.com/Bridgerz"><img src="https://avatars.githubusercontent.com/u/24499532?v=4&s=48" width="48" height="48" alt="Bridgerz" title="Bridgerz"/></a>
|
||||
<a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/openclaw-bot"><img src="https://avatars.githubusercontent.com/u/258178069?v=4&s=48" width="48" height="48" alt="openclaw-bot" title="openclaw-bot"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/mudrii"><img src="https://avatars.githubusercontent.com/u/220262?v=4&s=48" width="48" height="48" alt="mudrii" title="mudrii"/></a> <a href="https://github.com/JustasMonkev"><img src="https://avatars.githubusercontent.com/u/59362982?v=4&s=48" width="48" height="48" alt="JustasM" title="JustasM"/></a> <a href="https://github.com/ENCHIGO"><img src="https://avatars.githubusercontent.com/u/38551565?v=4&s=48" width="48" height="48" alt="ENCHIGO" title="ENCHIGO"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
|
||||
<a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/theonejvo"><img src="https://avatars.githubusercontent.com/u/125909656?v=4&s=48" width="48" height="48" alt="theonejvo" title="theonejvo"/></a> <a href="https://github.com/Blakeshannon"><img src="https://avatars.githubusercontent.com/u/257822860?v=4&s=48" width="48" height="48" alt="Blakeshannon" title="Blakeshannon"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Marvae"><img src="https://avatars.githubusercontent.com/u/11957602?v=4&s=48" width="48" height="48" alt="Marvae" title="Marvae"/></a> <a href="https://github.com/BunsDev"><img src="https://avatars.githubusercontent.com/u/68980965?v=4&s=48" width="48" height="48" alt="BunsDev" title="BunsDev"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/gejifeng"><img src="https://avatars.githubusercontent.com/u/17561857?v=4&s=48" width="48" height="48" alt="gejifeng" title="gejifeng"/></a> <a href="https://github.com/akoscz"><img src="https://avatars.githubusercontent.com/u/1360047?v=4&s=48" width="48" height="48" alt="akoscz" title="akoscz"/></a>
|
||||
<a href="https://github.com/divanoli"><img src="https://avatars.githubusercontent.com/u/12023205?v=4&s=48" width="48" height="48" alt="divanoli" title="divanoli"/></a> <a href="https://github.com/ryan-crabbe"><img src="https://avatars.githubusercontent.com/u/128659760?v=4&s=48" width="48" height="48" alt="ryan-crabbe" title="ryan-crabbe"/></a> <a href="https://github.com/nyanjou"><img src="https://avatars.githubusercontent.com/u/258645604?v=4&s=48" width="48" height="48" alt="nyanjou" title="nyanjou"/></a> <a href="https://github.com/theSamPadilla"><img src="https://avatars.githubusercontent.com/u/35386211?v=4&s=48" width="48" height="48" alt="Sam Padilla" title="Sam Padilla"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/solstead"><img src="https://avatars.githubusercontent.com/u/168413654?v=4&s=48" width="48" height="48" alt="solstead" title="solstead"/></a> <a href="https://github.com/natefikru"><img src="https://avatars.githubusercontent.com/u/10344644?v=4&s=48" width="48" height="48" alt="natefikru" title="natefikru"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/xzq-xu"><img src="https://avatars.githubusercontent.com/u/53989315?v=4&s=48" width="48" height="48" alt="LeftX" title="LeftX"/></a>
|
||||
<a href="https://github.com/Yida-Dev"><img src="https://avatars.githubusercontent.com/u/92713555?v=4&s=48" width="48" height="48" alt="Yida-Dev" title="Yida-Dev"/></a> <a href="https://github.com/harhogefoo"><img src="https://avatars.githubusercontent.com/u/11906529?v=4&s=48" width="48" height="48" alt="Masataka Shinohara" title="Masataka Shinohara"/></a> <a href="https://github.com/arosstale"><img src="https://avatars.githubusercontent.com/u/117890364?v=4&s=48" width="48" height="48" alt="arosstale" title="arosstale"/></a> <a href="https://github.com/riccardogiorato"><img src="https://avatars.githubusercontent.com/u/4527364?v=4&s=48" width="48" height="48" alt="riccardogiorato" title="riccardogiorato"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/BillChirico"><img src="https://avatars.githubusercontent.com/u/13951316?v=4&s=48" width="48" height="48" alt="BillChirico" title="BillChirico"/></a> <a href="https://github.com/shadril238"><img src="https://avatars.githubusercontent.com/u/63901551?v=4&s=48" width="48" height="48" alt="shadril238" title="shadril238"/></a> <a href="https://github.com/CharlieGreenman"><img src="https://avatars.githubusercontent.com/u/8540141?v=4&s=48" width="48" height="48" alt="CharlieGreenman" title="CharlieGreenman"/></a>
|
||||
<a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/mcrolly"><img src="https://avatars.githubusercontent.com/u/60803337?v=4&s=48" width="48" height="48" alt="McRolly NWANGWU" title="McRolly NWANGWU"/></a> <a href="https://github.com/durenzidu"><img src="https://avatars.githubusercontent.com/u/38130340?v=4&s=48" width="48" height="48" alt="durenzidu" title="durenzidu"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/Minidoracat"><img src="https://avatars.githubusercontent.com/u/11269639?v=4&s=48" width="48" height="48" alt="Minidoracat" title="Minidoracat"/></a> <a href="https://github.com/magendary"><img src="https://avatars.githubusercontent.com/u/30611068?v=4&s=48" width="48" height="48" alt="magendary" title="magendary"/></a> <a href="https://github.com/jessy2027"><img src="https://avatars.githubusercontent.com/u/89694096?v=4&s=48" width="48" height="48" alt="jessy2027" title="jessy2027"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a>
|
||||
<a href="https://github.com/M00N7682"><img src="https://avatars.githubusercontent.com/u/170746674?v=4&s=48" width="48" height="48" alt="M00N7682" title="M00N7682"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/Harrington-bot"><img src="https://avatars.githubusercontent.com/u/261410808?v=4&s=48" width="48" height="48" alt="Harrington-bot" title="Harrington-bot"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="Lalit Singh" title="Lalit Singh"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/jscaldwell55"><img src="https://avatars.githubusercontent.com/u/111952840?v=4&s=48" width="48" height="48" alt="jscaldwell55" title="jscaldwell55"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/TsekaLuk"><img src="https://avatars.githubusercontent.com/u/79151285?v=4&s=48" width="48" height="48" alt="TsekaLuk" title="TsekaLuk"/></a>
|
||||
<a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/gut-puncture"><img src="https://avatars.githubusercontent.com/u/75851986?v=4&s=48" width="48" height="48" alt="Shailesh" title="Shailesh"/></a> <a href="https://github.com/loiie45e"><img src="https://avatars.githubusercontent.com/u/15420100?v=4&s=48" width="48" height="48" alt="loiie45e" title="loiie45e"/></a> <a href="https://github.com/El-Fitz"><img src="https://avatars.githubusercontent.com/u/8971906?v=4&s=48" width="48" height="48" alt="El-Fitz" title="El-Fitz"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/pvtclawn"><img src="https://avatars.githubusercontent.com/u/258811507?v=4&s=48" width="48" height="48" alt="pvtclawn" title="pvtclawn"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/0xRaini"><img src="https://avatars.githubusercontent.com/u/190923101?v=4&s=48" width="48" height="48" alt="0xRaini" title="0xRaini"/></a> <a href="https://github.com/DrCrinkle"><img src="https://avatars.githubusercontent.com/u/62564740?v=4&s=48" width="48" height="48" alt="Taylor Asplund" title="Taylor Asplund"/></a>
|
||||
<a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="Paul van Oorschot" title="Paul van Oorschot"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/AI-Reviewer-QS"><img src="https://avatars.githubusercontent.com/u/255312808?v=4&s=48" width="48" height="48" alt="AI-Reviewer-QS" title="AI-Reviewer-QS"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="Stefan Galescu" title="Stefan Galescu"/></a> <a href="https://github.com/WalterSumbon"><img src="https://avatars.githubusercontent.com/u/45062253?v=4&s=48" width="48" height="48" alt="WalterSumbon" title="WalterSumbon"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/xinhuagu"><img src="https://avatars.githubusercontent.com/u/562450?v=4&s=48" width="48" height="48" alt="xinhuagu" title="xinhuagu"/></a> <a href="https://github.com/brandonwise"><img src="https://avatars.githubusercontent.com/u/21148772?v=4&s=48" width="48" height="48" alt="brandonwise" title="brandonwise"/></a>
|
||||
<a href="https://github.com/rodbland2021"><img src="https://avatars.githubusercontent.com/u/86267410?v=4&s=48" width="48" height="48" alt="rodbland2021" title="rodbland2021"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/fagemx"><img src="https://avatars.githubusercontent.com/u/117356295?v=4&s=48" width="48" height="48" alt="fagemx" title="fagemx"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></a> <a href="https://github.com/davidrudduck"><img src="https://avatars.githubusercontent.com/u/47308254?v=4&s=48" width="48" height="48" alt="davidrudduck" title="davidrudduck"/></a> <a href="https://github.com/Jackten"><img src="https://avatars.githubusercontent.com/u/2895479?v=4&s=48" width="48" height="48" alt="Jackten" title="Jackten"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/pycckuu"><img src="https://avatars.githubusercontent.com/u/1489583?v=4&s=48" width="48" height="48" alt="pycckuu" title="pycckuu"/></a> <a href="https://github.com/parkertoddbrooks"><img src="https://avatars.githubusercontent.com/u/585456?v=4&s=48" width="48" height="48" alt="Parker Todd Brooks" title="Parker Todd Brooks"/></a>
|
||||
<a href="https://github.com/simonemacario"><img src="https://avatars.githubusercontent.com/u/2116609?v=4&s=48" width="48" height="48" alt="simonemacario" title="simonemacario"/></a> <a href="https://github.com/omair445"><img src="https://avatars.githubusercontent.com/u/32237905?v=4&s=48" width="48" height="48" alt="omair445" title="omair445"/></a> <a href="https://github.com/AnonO6"><img src="https://avatars.githubusercontent.com/u/124311066?v=4&s=48" width="48" height="48" alt="AnonO6" title="AnonO6"/></a> <a href="https://github.com/CommanderCrowCode"><img src="https://avatars.githubusercontent.com/u/72845369?v=4&s=48" width="48" height="48" alt="Tanwa Arpornthip" title="Tanwa Arpornthip"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/tomron87"><img src="https://avatars.githubusercontent.com/u/126325152?v=4&s=48" width="48" height="48" alt="Tom Ron" title="Tom Ron"/></a> <a href="https://github.com/popomore"><img src="https://avatars.githubusercontent.com/u/360661?v=4&s=48" width="48" height="48" alt="popomore" title="popomore"/></a>
|
||||
<a href="https://github.com/Patrick-Barletta"><img src="https://avatars.githubusercontent.com/u/67929313?v=4&s=48" width="48" height="48" alt="Patrick Barletta" title="Patrick Barletta"/></a> <a href="https://github.com/shayan919293"><img src="https://avatars.githubusercontent.com/u/60409704?v=4&s=48" width="48" height="48" alt="shayan919293" title="shayan919293"/></a> <a href="https://github.com/stakeswky"><img src="https://avatars.githubusercontent.com/u/64798754?v=4&s=48" width="48" height="48" alt="不做了睡大觉" title="不做了睡大觉"/></a> <a href="https://github.com/L-U-C-K-Y"><img src="https://avatars.githubusercontent.com/u/14868134?v=4&s=48" width="48" height="48" alt="Lucky" title="Lucky"/></a> <a href="https://github.com/TinyTb"><img src="https://avatars.githubusercontent.com/u/5957298?v=4&s=48" width="48" height="48" alt="Michael Lee" title="Michael Lee"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/dakshaymehta"><img src="https://avatars.githubusercontent.com/u/50276213?v=4&s=48" width="48" height="48" alt="dakshaymehta" title="dakshaymehta"/></a> <a href="https://github.com/search?q=nicolasstanley"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="nicolasstanley" title="nicolasstanley"/></a> <a href="https://github.com/davidiach"><img src="https://avatars.githubusercontent.com/u/28102235?v=4&s=48" width="48" height="48" alt="davidiach" title="davidiach"/></a>
|
||||
<a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggia.liang" title="nonggia.liang"/></a> <a href="https://github.com/seheepeak"><img src="https://avatars.githubusercontent.com/u/134766597?v=4&s=48" width="48" height="48" alt="seheepeak" title="seheepeak"/></a> <a href="https://github.com/danielwanwx"><img src="https://avatars.githubusercontent.com/u/144515713?v=4&s=48" width="48" height="48" alt="danielwanwx" title="danielwanwx"/></a> <a href="https://github.com/search?q=hudson-rivera"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="hudson-rivera" title="hudson-rivera"/></a> <a href="https://github.com/misterdas"><img src="https://avatars.githubusercontent.com/u/170702047?v=4&s=48" width="48" height="48" alt="misterdas" title="misterdas"/></a> <a href="https://github.com/Shuai-DaiDai"><img src="https://avatars.githubusercontent.com/u/134567396?v=4&s=48" width="48" height="48" alt="Shuai-DaiDai" title="Shuai-DaiDai"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></a> <a href="https://github.com/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></a>
|
||||
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/dirbalak"><img src="https://avatars.githubusercontent.com/u/30323349?v=4&s=48" width="48" height="48" alt="dirbalak" title="dirbalak"/></a> <a href="https://github.com/cathrynlavery"><img src="https://avatars.githubusercontent.com/u/50469282?v=4&s=48" width="48" height="48" alt="cathrynlavery" title="cathrynlavery"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/Iranb"><img src="https://avatars.githubusercontent.com/u/49674669?v=4&s=48" width="48" height="48" alt="Iranb" title="Iranb"/></a> <a href="https://github.com/cdorsey"><img src="https://avatars.githubusercontent.com/u/12650570?v=4&s=48" width="48" height="48" alt="cdorsey" title="cdorsey"/></a> <a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/j2h4u"><img src="https://avatars.githubusercontent.com/u/39818683?v=4&s=48" width="48" height="48" alt="j2h4u" title="j2h4u"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a>
|
||||
<a href="https://github.com/adao-max"><img src="https://avatars.githubusercontent.com/u/153898832?v=4&s=48" width="48" height="48" alt="Skyler Miao" title="Skyler Miao"/></a> <a href="https://github.com/peetzweg"><img src="https://avatars.githubusercontent.com/u/839848?v=4&s=48" width="48" height="48" alt="peetzweg/" title="peetzweg/"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="TideFinder" title="TideFinder"/></a> <a href="https://github.com/Clawborn"><img src="https://avatars.githubusercontent.com/u/261310391?v=4&s=48" width="48" height="48" alt="Clawborn" title="Clawborn"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/bsormagec"><img src="https://avatars.githubusercontent.com/u/965219?v=4&s=48" width="48" height="48" alt="bsormagec" title="bsormagec"/></a> <a href="https://github.com/Diaspar4u"><img src="https://avatars.githubusercontent.com/u/3605840?v=4&s=48" width="48" height="48" alt="Diaspar4u" title="Diaspar4u"/></a> <a href="https://github.com/evanotero"><img src="https://avatars.githubusercontent.com/u/13204105?v=4&s=48" width="48" height="48" alt="evanotero" title="evanotero"/></a> <a href="https://github.com/nk1tz"><img src="https://avatars.githubusercontent.com/u/12980165?v=4&s=48" width="48" height="48" alt="Nate" title="Nate"/></a> <a href="https://github.com/OscarMinjarez"><img src="https://avatars.githubusercontent.com/u/86080038?v=4&s=48" width="48" height="48" alt="OscarMinjarez" title="OscarMinjarez"/></a>
|
||||
<a href="https://github.com/webvijayi"><img src="https://avatars.githubusercontent.com/u/49924855?v=4&s=48" width="48" height="48" alt="webvijayi" title="webvijayi"/></a> <a href="https://github.com/garnetlyx"><img src="https://avatars.githubusercontent.com/u/12513503?v=4&s=48" width="48" height="48" alt="garnetlyx" title="garnetlyx"/></a> <a href="https://github.com/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="jlowin" title="jlowin"/></a> <a href="https://github.com/liebertar"><img src="https://avatars.githubusercontent.com/u/99405438?v=4&s=48" width="48" height="48" alt="liebertar" title="liebertar"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="Max" title="Max"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||
<a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/asklee-klawd"><img src="https://avatars.githubusercontent.com/u/105007315?v=4&s=48" width="48" height="48" alt="asklee-klawd" title="asklee-klawd"/></a> <a href="https://github.com/h0tp-ftw"><img src="https://avatars.githubusercontent.com/u/141889580?v=4&s=48" width="48" height="48" alt="h0tp-ftw" title="h0tp-ftw"/></a> <a href="https://github.com/constansino"><img src="https://avatars.githubusercontent.com/u/65108260?v=4&s=48" width="48" height="48" alt="constansino" title="constansino"/></a> <a href="https://github.com/carrotRakko"><img src="https://avatars.githubusercontent.com/u/24588751?v=4&s=48" width="48" height="48" alt="Mitsuyuki Osabe" title="Mitsuyuki Osabe"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryan" title="ryan"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Solvely-Colin"><img src="https://avatars.githubusercontent.com/u/211764741?v=4&s=48" width="48" height="48" alt="Solvely-Colin" title="Solvely-Colin"/></a> <a href="https://github.com/mcaxtr"><img src="https://avatars.githubusercontent.com/u/7562095?v=4&s=48" width="48" height="48" alt="mcaxtr" title="mcaxtr"/></a>
|
||||
<a href="https://github.com/HirokiKobayashi-R"><img src="https://avatars.githubusercontent.com/u/37167840?v=4&s=48" width="48" height="48" alt="HirokiKobayashi-R" title="HirokiKobayashi-R"/></a> <a href="https://github.com/taw0002"><img src="https://avatars.githubusercontent.com/u/42811278?v=4&s=48" width="48" height="48" alt="taw0002" title="taw0002"/></a> <a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="Kimitaka Watanabe" title="Kimitaka Watanabe"/></a> <a href="https://github.com/detecti1"><img src="https://avatars.githubusercontent.com/u/1622461?v=4&s=48" width="48" height="48" alt="Lilo" title="Lilo"/></a> <a href="https://github.com/18-RAJAT"><img src="https://avatars.githubusercontent.com/u/78920780?v=4&s=48" width="48" height="48" alt="Rajat Joshi" title="Rajat Joshi"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="Yuting Lin" title="Yuting Lin"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="Neo" title="Neo"/></a> <a href="https://github.com/miloudbelarebia"><img src="https://avatars.githubusercontent.com/u/136994453?v=4&s=48" width="48" height="48" alt="Thorfinn" title="Thorfinn"/></a> <a href="https://github.com/wu-tian807"><img src="https://avatars.githubusercontent.com/u/61640083?v=4&s=48" width="48" height="48" alt="wu-tian807" title="wu-tian807"/></a> <a href="https://github.com/crimeacs"><img src="https://avatars.githubusercontent.com/u/35071559?v=4&s=48" width="48" height="48" alt="crimeacs" title="crimeacs"/></a>
|
||||
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/unisone"><img src="https://avatars.githubusercontent.com/u/32521398?v=4&s=48" width="48" height="48" alt="unisone" title="unisone"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="Manik Vahsith" title="Manik Vahsith"/></a> <a href="https://github.com/alexgleason"><img src="https://avatars.githubusercontent.com/u/3639540?v=4&s=48" width="48" height="48" alt="alexgleason" title="alexgleason"/></a> <a href="https://github.com/nicholascyh"><img src="https://avatars.githubusercontent.com/u/188132635?v=4&s=48" width="48" height="48" alt="Nicholas" title="Nicholas"/></a> <a href="https://github.com/sbking"><img src="https://avatars.githubusercontent.com/u/3913213?v=4&s=48" width="48" height="48" alt="Stephen Brian King" title="Stephen Brian King"/></a> <a href="https://github.com/mahanandhi"><img src="https://avatars.githubusercontent.com/u/46371575?v=4&s=48" width="48" height="48" alt="mahanandhi" title="mahanandhi"/></a> <a href="https://github.com/andreesg"><img src="https://avatars.githubusercontent.com/u/810322?v=4&s=48" width="48" height="48" alt="andreesg" title="andreesg"/></a>
|
||||
<a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/dinakars777"><img src="https://avatars.githubusercontent.com/u/250428393?v=4&s=48" width="48" height="48" alt="dinakars777" title="dinakars777"/></a> <a href="https://github.com/divisonofficer"><img src="https://avatars.githubusercontent.com/u/41609506?v=4&s=48" width="48" height="48" alt="divisonofficer" title="divisonofficer"/></a> <a href="https://github.com/Flash-LHR"><img src="https://avatars.githubusercontent.com/u/47357603?v=4&s=48" width="48" height="48" alt="Flash-LHR" title="Flash-LHR"/></a> <a href="https://github.com/Protocol-zero-0"><img src="https://avatars.githubusercontent.com/u/257158451?v=4&s=48" width="48" height="48" alt="Protocol Zero" title="Protocol Zero"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/Limitless2023"><img src="https://avatars.githubusercontent.com/u/127183162?v=4&s=48" width="48" height="48" alt="Limitless" title="Limitless"/></a> <a href="https://github.com/slonce70"><img src="https://avatars.githubusercontent.com/u/130596182?v=4&s=48" width="48" height="48" alt="slonce70" title="slonce70"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a>
|
||||
<a href="https://github.com/JayMishra-source"><img src="https://avatars.githubusercontent.com/u/82963117?v=4&s=48" width="48" height="48" alt="JayMishra-source" title="JayMishra-source"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ide-rea"><img src="https://avatars.githubusercontent.com/u/30512600?v=4&s=48" width="48" height="48" alt="ide-rea" title="ide-rea"/></a> <a href="https://github.com/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a> <a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo" title="lailoo"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/Iron9521"><img src="https://avatars.githubusercontent.com/u/261863182?v=4&s=48" width="48" height="48" alt="Iron9521" title="Iron9521"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
|
||||
<a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/ezhikkk"><img src="https://avatars.githubusercontent.com/u/105670095?v=4&s=48" width="48" height="48" alt="ezhikkk" title="ezhikkk"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="Shivam Kumar Raut" title="Shivam Kumar Raut"/></a> <a href="https://github.com/jabezborja"><img src="https://avatars.githubusercontent.com/u/64759159?v=4&s=48" width="48" height="48" alt="jabezborja" title="jabezborja"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="Mykyta Bozhenko" title="Mykyta Bozhenko"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Wangnov"><img src="https://avatars.githubusercontent.com/u/48670012?v=4&s=48" width="48" height="48" alt="Wangnov" title="Wangnov"/></a> <a href="https://github.com/jadilson12"><img src="https://avatars.githubusercontent.com/u/36805474?v=4&s=48" width="48" height="48" alt="jadilson12" title="jadilson12"/></a>
|
||||
<a href="https://github.com/search?q=%E5%BA%B7%E7%86%99"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="康熙" title="康熙"/></a> <a href="https://github.com/akramcodez"><img src="https://avatars.githubusercontent.com/u/179671552?v=4&s=48" width="48" height="48" alt="akramcodez" title="akramcodez"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></a> <a href="https://github.com/emonty"><img src="https://avatars.githubusercontent.com/u/95156?v=4&s=48" width="48" height="48" alt="emonty" title="emonty"/></a> <a href="https://github.com/kaizen403"><img src="https://avatars.githubusercontent.com/u/134706404?v=4&s=48" width="48" height="48" alt="kaizen403" title="kaizen403"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/wangai-studio"><img src="https://avatars.githubusercontent.com/u/256938352?v=4&s=48" width="48" height="48" alt="wangai-studio" title="wangai-studio"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
||||
<a href="https://github.com/17jmumford"><img src="https://avatars.githubusercontent.com/u/36290330?v=4&s=48" width="48" height="48" alt="17jmumford" title="17jmumford"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/hyf0-agent"><img src="https://avatars.githubusercontent.com/u/258783736?v=4&s=48" width="48" height="48" alt="hyf0-agent" title="hyf0-agent"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="Kenny Lee" title="Kenny Lee"/></a> <a href="https://github.com/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></a> <a href="https://github.com/search?q=Operative-001"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Operative-001" title="Operative-001"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/DylanWoodAkers"><img src="https://avatars.githubusercontent.com/u/253595314?v=4&s=48" width="48" height="48" alt="DylanWoodAkers" title="DylanWoodAkers"/></a> <a href="https://github.com/Hisleren"><img src="https://avatars.githubusercontent.com/u/83217244?v=4&s=48" width="48" height="48" alt="Hisleren" title="Hisleren"/></a>
|
||||
<a href="https://github.com/widingmarcus-cyber"><img src="https://avatars.githubusercontent.com/u/245375637?v=4&s=48" width="48" height="48" alt="widingmarcus-cyber" title="widingmarcus-cyber"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/boris721"><img src="https://avatars.githubusercontent.com/u/257853888?v=4&s=48" width="48" height="48" alt="boris721" title="boris721"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/doodlewind"><img src="https://avatars.githubusercontent.com/u/7312949?v=4&s=48" width="48" height="48" alt="doodlewind" title="doodlewind"/></a> <a href="https://github.com/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="GHesericsu" title="GHesericsu"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a>
|
||||
<a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/sumleo"><img src="https://avatars.githubusercontent.com/u/29517764?v=4&s=48" width="48" height="48" alt="sumleo" title="sumleo"/></a> <a href="https://github.com/Yeom-JinHo"><img src="https://avatars.githubusercontent.com/u/81306489?v=4&s=48" width="48" height="48" alt="Yeom-JinHo" title="Yeom-JinHo"/></a> <a href="https://github.com/search?q=zisisp"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="zisisp" title="zisisp"/></a>
|
||||
<a href="https://github.com/akyourowngames"><img src="https://avatars.githubusercontent.com/u/123736861?v=4&s=48" width="48" height="48" alt="akyourowngames" title="akyourowngames"/></a> <a href="https://github.com/aldoeliacim"><img src="https://avatars.githubusercontent.com/u/17973757?v=4&s=48" width="48" height="48" alt="aldoeliacim" title="aldoeliacim"/></a> <a href="https://github.com/Dithilli"><img src="https://avatars.githubusercontent.com/u/41286037?v=4&s=48" width="48" height="48" alt="Dithilli" title="Dithilli"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a>
|
||||
<a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/orenyomtov"><img src="https://avatars.githubusercontent.com/u/168856?v=4&s=48" width="48" height="48" alt="Oren" title="Oren"/></a> <a href="https://github.com/search?q=Rain"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rain" title="Rain"/></a> <a href="https://github.com/shtse8"><img src="https://avatars.githubusercontent.com/u/8020099?v=4&s=48" width="48" height="48" alt="shtse8" title="shtse8"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/thesomewhatyou"><img src="https://avatars.githubusercontent.com/u/162917831?v=4&s=48" width="48" height="48" alt="thesomewhatyou" title="thesomewhatyou"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
|
||||
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/echoVic"><img src="https://avatars.githubusercontent.com/u/16428813?v=4&s=48" width="48" height="48" alt="echoVic" title="echoVic"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/ghsmc"><img src="https://avatars.githubusercontent.com/u/68118719?v=4&s=48" width="48" height="48" alt="ghsmc" title="ghsmc"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/ibrahimq21"><img src="https://avatars.githubusercontent.com/u/8392472?v=4&s=48" width="48" height="48" alt="ibrahimq21" title="ibrahimq21"/></a> <a href="https://github.com/irtiq7"><img src="https://avatars.githubusercontent.com/u/3823029?v=4&s=48" width="48" height="48" alt="irtiq7" title="irtiq7"/></a> <a href="https://github.com/jeann2013"><img src="https://avatars.githubusercontent.com/u/3299025?v=4&s=48" width="48" height="48" alt="jeann2013" title="jeann2013"/></a> <a href="https://github.com/jogelin"><img src="https://avatars.githubusercontent.com/u/954509?v=4&s=48" width="48" height="48" alt="jogelin" title="jogelin"/></a>
|
||||
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></a> <a href="https://github.com/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="Justin Ling" title="Justin Ling"/></a> <a href="https://github.com/kelvinCB"><img src="https://avatars.githubusercontent.com/u/50544379?v=4&s=48" width="48" height="48" alt="kelvinCB" title="kelvinCB"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/mattqdev"><img src="https://avatars.githubusercontent.com/u/115874885?v=4&s=48" width="48" height="48" alt="MattQ" title="MattQ"/></a> <a href="https://github.com/Milofax"><img src="https://avatars.githubusercontent.com/u/2537423?v=4&s=48" width="48" height="48" alt="Milofax" title="Milofax"/></a> <a href="https://github.com/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
|
||||
<a href="https://github.com/pejmanjohn"><img src="https://avatars.githubusercontent.com/u/481729?v=4&s=48" width="48" height="48" alt="pejmanjohn" title="pejmanjohn"/></a> <a href="https://github.com/search?q=Ralph"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ralph" title="Ralph"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/rybnikov"><img src="https://avatars.githubusercontent.com/u/7761808?v=4&s=48" width="48" height="48" alt="rybnikov" title="rybnikov"/></a> <a href="https://github.com/stevebot-alive"><img src="https://avatars.githubusercontent.com/u/261149299?v=4&s=48" width="48" height="48" alt="Steve (OpenClaw)" title="Steve (OpenClaw)"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
|
||||
<a href="https://github.com/AkashKobal"><img src="https://avatars.githubusercontent.com/u/98216083?v=4&s=48" width="48" height="48" alt="AkashKobal" title="AkashKobal"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/awkoy"><img src="https://avatars.githubusercontent.com/u/13995636?v=4&s=48" width="48" height="48" alt="awkoy" title="awkoy"/></a> <a href="https://github.com/BinHPdev"><img src="https://avatars.githubusercontent.com/u/219093083?v=4&s=48" width="48" height="48" alt="BinHPdev" title="BinHPdev"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/dawondyifraw"><img src="https://avatars.githubusercontent.com/u/9797257?v=4&s=48" width="48" height="48" alt="dawondyifraw" title="dawondyifraw"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a>
|
||||
<a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/hyojin"><img src="https://avatars.githubusercontent.com/u/3413183?v=4&s=48" width="48" height="48" alt="hyojin" title="hyojin"/></a> <a href="https://github.com/joeykrug"><img src="https://avatars.githubusercontent.com/u/5925937?v=4&s=48" width="48" height="48" alt="joeykrug" title="joeykrug"/></a> <a href="https://github.com/justinhuangcode"><img src="https://avatars.githubusercontent.com/u/252443740?v=4&s=48" width="48" height="48" alt="justinhuangcode" title="justinhuangcode"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/liuy"><img src="https://avatars.githubusercontent.com/u/1192888?v=4&s=48" width="48" height="48" alt="liuy" title="liuy"/></a> <a href="https://github.com/search?q=ludd50155"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ludd50155" title="ludd50155"/></a> <a href="https://github.com/liuxiaopai-ai"><img src="https://avatars.githubusercontent.com/u/73659136?v=4&s=48" width="48" height="48" alt="Mark Liu" title="Mark Liu"/></a> <a href="https://github.com/natedenh"><img src="https://avatars.githubusercontent.com/u/13399956?v=4&s=48" width="48" height="48" alt="natedenh" title="natedenh"/></a>
|
||||
<a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a> <a href="https://github.com/search?q=Roopak%20Nijhara"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Roopak Nijhara" title="Roopak Nijhara"/></a> <a href="https://github.com/search?q=Sean%20McLellan"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sean McLellan" title="Sean McLellan"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/tmchow"><img src="https://avatars.githubusercontent.com/u/517103?v=4&s=48" width="48" height="48" alt="tmchow" title="tmchow"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/uli-will-code"><img src="https://avatars.githubusercontent.com/u/49715419?v=4&s=48" width="48" height="48" alt="uli-will-code" title="uli-will-code"/></a> <a href="https://github.com/search?q=xiaose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="xiaose" title="xiaose"/></a>
|
||||
<a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/search?q=Aditya%20Singh"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aditya Singh" title="Aditya Singh"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/battman21"><img src="https://avatars.githubusercontent.com/u/2656916?v=4&s=48" width="48" height="48" alt="battman21" title="battman21"/></a> <a href="https://github.com/BinaryMuse"><img src="https://avatars.githubusercontent.com/u/189606?v=4&s=48" width="48" height="48" alt="BinaryMuse" title="BinaryMuse"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/CJWTRUST"><img src="https://avatars.githubusercontent.com/u/235565898?v=4&s=48" width="48" height="48" alt="CJWTRUST" title="CJWTRUST"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
|
||||
<a href="https://github.com/search?q=Clawdbot"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot" title="Clawdbot"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/cordx56"><img src="https://avatars.githubusercontent.com/u/23298744?v=4&s=48" width="48" height="48" alt="cordx56" title="cordx56"/></a> <a href="https://github.com/danballance"><img src="https://avatars.githubusercontent.com/u/13839912?v=4&s=48" width="48" height="48" alt="danballance" title="danballance"/></a> <a href="https://github.com/Elarwei001"><img src="https://avatars.githubusercontent.com/u/168552401?v=4&s=48" width="48" height="48" alt="Elarwei001" title="Elarwei001"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gildo"><img src="https://avatars.githubusercontent.com/u/133645?v=4&s=48" width="48" height="48" alt="gildo" title="gildo"/></a>
|
||||
<a href="https://github.com/Grynn"><img src="https://avatars.githubusercontent.com/u/212880?v=4&s=48" width="48" height="48" alt="Grynn" title="Grynn"/></a> <a href="https://github.com/hanxiao"><img src="https://avatars.githubusercontent.com/u/2041322?v=4&s=48" width="48" height="48" alt="hanxiao" title="hanxiao"/></a> <a href="https://github.com/search?q=Ignacio"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ignacio" title="Ignacio"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
||||
<a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></a> <a href="https://github.com/loeclos"><img src="https://avatars.githubusercontent.com/u/116607327?v=4&s=48" width="48" height="48" alt="loeclos" title="loeclos"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Marco%20Marandiz"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marco Marandiz" title="Marco Marandiz"/></a> <a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
|
||||
<a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></a> <a href="https://github.com/RamiNoodle733"><img src="https://avatars.githubusercontent.com/u/117773986?v=4&s=48" width="48" height="48" alt="RamiNoodle733" title="RamiNoodle733"/></a> <a href="https://github.com/RayBB"><img src="https://avatars.githubusercontent.com/u/921217?v=4&s=48" width="48" height="48" alt="Raymond Berger" title="Raymond Berger"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="Rob Axelsen" title="Rob Axelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/sauerdaniel"><img src="https://avatars.githubusercontent.com/u/81422812?v=4&s=48" width="48" height="48" alt="sauerdaniel" title="sauerdaniel"/></a> <a href="https://github.com/search?q=Sriram%20Naidu%20Thota"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sriram Naidu Thota" title="Sriram Naidu Thota"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a>
|
||||
<a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/search?q=Yao"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yao" title="Yao"/></a> <a href="https://github.com/yudshj"><img src="https://avatars.githubusercontent.com/u/16971372?v=4&s=48" width="48" height="48" alt="yudshj" title="yudshj"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=%E5%B0%B9%E5%87%AF"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="尹凯" title="尹凯"/></a> <a href="https://github.com/search?q=%7BSuksham-sharma%7D"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="{Suksham-sharma}" title="{Suksham-sharma}"/></a> <a href="https://github.com/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a>
|
||||
<a href="https://github.com/8BlT"><img src="https://avatars.githubusercontent.com/u/162764392?v=4&s=48" width="48" height="48" alt="8BlT" title="8BlT"/></a> <a href="https://github.com/Abdul535"><img src="https://avatars.githubusercontent.com/u/54276938?v=4&s=48" width="48" height="48" alt="Abdul535" title="Abdul535"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/search?q=abhijeet117"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="abhijeet117" title="abhijeet117"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a> <a href="https://github.com/afurm"><img src="https://avatars.githubusercontent.com/u/6375192?v=4&s=48" width="48" height="48" alt="afurm" title="afurm"/></a> <a href="https://github.com/aisling404"><img src="https://avatars.githubusercontent.com/u/211950534?v=4&s=48" width="48" height="48" alt="aisling404" title="aisling404"/></a> <a href="https://github.com/akari-musubi"><img src="https://avatars.githubusercontent.com/u/259925157?v=4&s=48" width="48" height="48" alt="akari-musubi" title="akari-musubi"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a>
|
||||
<a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/AlexZhangji"><img src="https://avatars.githubusercontent.com/u/3280924?v=4&s=48" width="48" height="48" alt="AlexZhangji" title="AlexZhangji"/></a> <a href="https://github.com/search?q=amabito"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="amabito" title="amabito"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anisoptera"><img src="https://avatars.githubusercontent.com/u/768771?v=4&s=48" width="48" height="48" alt="anisoptera" title="anisoptera"/></a> <a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/search?q=Ayush%20Ojha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ayush Ojha" title="Ayush Ojha"/></a>
|
||||
<a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/search?q=baccula"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="baccula" title="baccula"/></a> <a href="https://github.com/beefiker"><img src="https://avatars.githubusercontent.com/u/55247450?v=4&s=48" width="48" height="48" alt="beefiker" title="beefiker"/></a> <a href="https://github.com/bennewton999"><img src="https://avatars.githubusercontent.com/u/458991?v=4&s=48" width="48" height="48" alt="bennewton999" title="bennewton999"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></a> <a href="https://github.com/search?q=blacksmith-sh%5Bbot%5D"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/search?q=bqcfjwhz85-arch"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="bqcfjwhz85-arch" title="bqcfjwhz85-arch"/></a> <a href="https://github.com/search?q=bravostation"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="bravostation" title="bravostation"/></a> <a href="https://github.com/search?q=Buddy%20(AI)"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Buddy (AI)" title="Buddy (AI)"/></a> <a href="https://github.com/caelum0x"><img src="https://avatars.githubusercontent.com/u/130079063?v=4&s=48" width="48" height="48" alt="caelum0x" title="caelum0x"/></a>
|
||||
<a href="https://github.com/search?q=calvin-hpnet"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="calvin-hpnet" title="calvin-hpnet"/></a> <a href="https://github.com/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a> <a href="https://github.com/search?q=chenglun.hu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="chenglun.hu" title="chenglun.hu"/></a> <a href="https://github.com/Chloe-VP"><img src="https://avatars.githubusercontent.com/u/257371598?v=4&s=48" width="48" height="48" alt="Chloe-VP" title="Chloe-VP"/></a> <a href="https://github.com/search?q=Claw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Claw" title="Claw"/></a> <a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></a> <a href="https://github.com/search?q=cristip73"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/search?q=danielcadenhead"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="danielcadenhead" title="danielcadenhead"/></a> <a href="https://github.com/dario-github"><img src="https://avatars.githubusercontent.com/u/40749119?v=4&s=48" width="48" height="48" alt="dario-github" title="dario-github"/></a> <a href="https://github.com/DarwinsBuddy"><img src="https://avatars.githubusercontent.com/u/490836?v=4&s=48" width="48" height="48" alt="DarwinsBuddy" title="DarwinsBuddy"/></a>
|
||||
<a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/search?q=davidbors-snyk"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="davidbors-snyk" title="davidbors-snyk"/></a> <a href="https://github.com/dcantu96"><img src="https://avatars.githubusercontent.com/u/32658690?v=4&s=48" width="48" height="48" alt="dcantu96" title="dcantu96"/></a> <a href="https://github.com/search?q=dependabot%5Bbot%5D"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/dvrshil"><img src="https://avatars.githubusercontent.com/u/81693876?v=4&s=48" width="48" height="48" alt="dvrshil" title="dvrshil"/></a> <a href="https://github.com/dxd5001"><img src="https://avatars.githubusercontent.com/u/1886046?v=4&s=48" width="48" height="48" alt="dxd5001" title="dxd5001"/></a> <a href="https://github.com/dylanneve1"><img src="https://avatars.githubusercontent.com/u/31746704?v=4&s=48" width="48" height="48" alt="dylanneve1" title="dylanneve1"/></a>
|
||||
<a href="https://github.com/search?q=elliotsecops"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="elliotsecops" title="elliotsecops"/></a> <a href="https://github.com/EmberCF"><img src="https://avatars.githubusercontent.com/u/258471336?v=4&s=48" width="48" height="48" alt="EmberCF" title="EmberCF"/></a> <a href="https://github.com/ereid7"><img src="https://avatars.githubusercontent.com/u/27597719?v=4&s=48" width="48" height="48" alt="ereid7" title="ereid7"/></a> <a href="https://github.com/eternauta1337"><img src="https://avatars.githubusercontent.com/u/550409?v=4&s=48" width="48" height="48" alt="eternauta1337" title="eternauta1337"/></a> <a href="https://github.com/search?q=f-trycua"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/search?q=fan"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="fan" title="fan"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a> <a href="https://github.com/search?q=fujiwara-tofu-shop"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="fujiwara-tofu-shop" title="fujiwara-tofu-shop"/></a>
|
||||
<a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/search?q=gaowanqi08141999"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="gaowanqi08141999" title="gaowanqi08141999"/></a> <a href="https://github.com/search?q=gerardward2007"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/search?q=gitpds"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="gitpds" title="gitpds"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/search?q=habakan"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="habakan" title="habakan"/></a> <a href="https://github.com/HassanFleyah"><img src="https://avatars.githubusercontent.com/u/228002017?v=4&s=48" width="48" height="48" alt="HassanFleyah" title="HassanFleyah"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/search?q=hcl"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="hcl" title="hcl"/></a> <a href="https://github.com/search?q=headswim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="headswim" title="headswim"/></a>
|
||||
<a href="https://github.com/search?q=hlbbbbbbb"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="hlbbbbbbb" title="hlbbbbbbb"/></a> <a href="https://github.com/search?q=Hubert"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Hubert" title="Hubert"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=hyaxia"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="hyaxia" title="hyaxia"/></a> <a href="https://github.com/iamEvanYT"><img src="https://avatars.githubusercontent.com/u/47493765?v=4&s=48" width="48" height="48" alt="iamEvanYT" title="iamEvanYT"/></a> <a href="https://github.com/search?q=ikari"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ikari" title="ikari"/></a> <a href="https://github.com/ikari-pl"><img src="https://avatars.githubusercontent.com/u/811702?v=4&s=48" width="48" height="48" alt="ikari-pl" title="ikari-pl"/></a> <a href="https://github.com/search?q=Iron"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Iron" title="Iron"/></a> <a href="https://github.com/search?q=ironbyte-rgb"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ironbyte-rgb" title="ironbyte-rgb"/></a> <a href="https://github.com/search?q=%C3%8Dtalo%20Souza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ítalo Souza" title="Ítalo Souza"/></a>
|
||||
<a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></a> <a href="https://github.com/search?q=Jarvis%20Deploy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis Deploy" title="Jarvis Deploy"/></a> <a href="https://github.com/search?q=jarvis89757"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jarvis89757" title="jarvis89757"/></a> <a href="https://github.com/search?q=jasonftl"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jasonftl" title="jasonftl"/></a> <a href="https://github.com/search?q=jasonsschin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jasonsschin" title="jasonsschin"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=jg-noncelogic"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jg-noncelogic" title="jg-noncelogic"/></a> <a href="https://github.com/search?q=jigar"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jigar" title="jigar"/></a> <a href="https://github.com/search?q=joeynyc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="joeynyc" title="joeynyc"/></a>
|
||||
<a href="https://github.com/search?q=Jon%20Uleis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jon Uleis" title="Jon Uleis"/></a> <a href="https://github.com/search?q=Josh%20Long"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Josh Long" title="Josh Long"/></a> <a href="https://github.com/search?q=justyannicc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="justyannicc" title="justyannicc"/></a> <a href="https://github.com/search?q=Karim%20Naguib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Karim Naguib" title="Karim Naguib"/></a> <a href="https://github.com/search?q=Kasper%20Neist%20Christjansen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kasper Neist Christjansen" title="Kasper Neist Christjansen"/></a> <a href="https://github.com/search?q=Keshav%20Rao"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keshav Rao" title="Keshav Rao"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/search?q=Kira"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kira" title="Kira"/></a> <a href="https://github.com/knocte"><img src="https://avatars.githubusercontent.com/u/331303?v=4&s=48" width="48" height="48" alt="knocte" title="knocte"/></a> <a href="https://github.com/search?q=Knox"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Knox" title="Knox"/></a>
|
||||
<a href="https://github.com/search?q=Kristijan%20Jovanovski"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kristijan Jovanovski" title="Kristijan Jovanovski"/></a> <a href="https://github.com/search?q=Kyle%20Chen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kyle Chen" title="Kyle Chen"/></a> <a href="https://github.com/search?q=Latitude%20Bot"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Latitude Bot" title="Latitude Bot"/></a> <a href="https://github.com/search?q=Levi%20Figueira"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Levi Figueira" title="Levi Figueira"/></a> <a href="https://github.com/search?q=Liu%20Weizhan"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Liu Weizhan" title="Liu Weizhan"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/search?q=Loganaden%20Velvindron"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Loganaden Velvindron" title="Loganaden Velvindron"/></a> <a href="https://github.com/search?q=lsh411"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="lsh411" title="lsh411"/></a> <a href="https://github.com/search?q=Lucas%20Kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lucas Kim" title="Lucas Kim"/></a> <a href="https://github.com/search?q=Luka%20Zhang"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Luka Zhang" title="Luka Zhang"/></a>
|
||||
<a href="https://github.com/search?q=Luk%C3%A1%C5%A1%20Loukota"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lukáš Loukota" title="Lukáš Loukota"/></a> <a href="https://github.com/search?q=Lukin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lukin" title="Lukin"/></a> <a href="https://github.com/search?q=mac%20mimi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mac mimi" title="mac mimi"/></a> <a href="https://github.com/search?q=mac26ai"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mac26ai" title="mac26ai"/></a> <a href="https://github.com/MackDing"><img src="https://avatars.githubusercontent.com/u/19878893?v=4&s=48" width="48" height="48" alt="MackDing" title="MackDing"/></a> <a href="https://github.com/search?q=Mahsum%20Aktas"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mahsum Aktas" title="Mahsum Aktas"/></a> <a href="https://github.com/search?q=Marc%20Beaupre"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc Beaupre" title="Marc Beaupre"/></a> <a href="https://github.com/search?q=Marcus%20Neves"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marcus Neves" title="Marcus Neves"/></a> <a href="https://github.com/search?q=Mario%20Zechner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mario Zechner" title="Mario Zechner"/></a> <a href="https://github.com/search?q=Markus%20Buhatem%20Koch"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Markus Buhatem Koch" title="Markus Buhatem Koch"/></a>
|
||||
<a href="https://github.com/search?q=Martin%20P%C3%BA%C4%8Dik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Martin Púčik" title="Martin Púčik"/></a> <a href="https://github.com/search?q=Martin%20Sch%C3%BCrrer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Martin Schürrer" title="Martin Schürrer"/></a> <a href="https://github.com/search?q=MarvinDontPanic"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="MarvinDontPanic" title="MarvinDontPanic"/></a> <a href="https://github.com/search?q=Mateusz%20Michalik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mateusz Michalik" title="Mateusz Michalik"/></a> <a href="https://github.com/search?q=Matias%20Wainsten"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matias Wainsten" title="Matias Wainsten"/></a> <a href="https://github.com/search?q=Matt%20Ezell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt Ezell" title="Matt Ezell"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Matthew%20Dicembrino"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matthew Dicembrino" title="Matthew Dicembrino"/></a> <a href="https://github.com/search?q=Mauro%20Bolis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mauro Bolis" title="Mauro Bolis"/></a> <a href="https://github.com/search?q=mcwigglesmcgee"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="mcwigglesmcgee" title="mcwigglesmcgee"/></a>
|
||||
<a href="https://github.com/search?q=meaadore1221-afk"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="meaadore1221-afk" title="meaadore1221-afk"/></a> <a href="https://github.com/search?q=Mert%20%C3%87i%C3%A7ek%C3%A7i"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mert Çiçekçi" title="Mert Çiçekçi"/></a> <a href="https://github.com/search?q=Michael%20Verrilli"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Michael Verrilli" title="Michael Verrilli"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/search?q=minghinmatthewlam"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/search?q=Mr.%20Guy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mr. Guy" title="Mr. Guy"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/search?q=myfunc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/search?q=Nate"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Nate" title="Nate"/></a>
|
||||
<a href="https://github.com/search?q=Nathaniel%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Nathaniel Kelner" title="Nathaniel Kelner"/></a> <a href="https://github.com/search?q=Netanel%20Draiman"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Netanel Draiman" title="Netanel Draiman"/></a> <a href="https://github.com/search?q=niceysam"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="niceysam" title="niceysam"/></a> <a href="https://github.com/search?q=Nick%20Lamb"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Nick Lamb" title="Nick Lamb"/></a> <a href="https://github.com/search?q=Nick%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Nick Taylor" title="Nick Taylor"/></a> <a href="https://github.com/search?q=Nikolay%20Petrov"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Nikolay Petrov" title="Nikolay Petrov"/></a> <a href="https://github.com/search?q=NM"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="NM" title="NM"/></a> <a href="https://github.com/nobrainer-tech"><img src="https://avatars.githubusercontent.com/u/445466?v=4&s=48" width="48" height="48" alt="nobrainer-tech" title="nobrainer-tech"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/search?q=norunners"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="norunners" title="norunners"/></a>
|
||||
<a href="https://github.com/search?q=Ocean%20Vael"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ocean Vael" title="Ocean Vael"/></a> <a href="https://github.com/search?q=Ogulcan%20Celik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ogulcan Celik" title="Ogulcan Celik"/></a> <a href="https://github.com/search?q=Oleg%20Kossoy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Oleg Kossoy" title="Oleg Kossoy"/></a> <a href="https://github.com/Olshansk"><img src="https://avatars.githubusercontent.com/u/1892194?v=4&s=48" width="48" height="48" alt="Olshansk" title="Olshansk"/></a> <a href="https://github.com/search?q=Omar%20Khaleel"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Omar Khaleel" title="Omar Khaleel"/></a> <a href="https://github.com/search?q=OpenClaw%20Agent"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="OpenClaw Agent" title="OpenClaw Agent"/></a> <a href="https://github.com/search?q=Ozgur%20Polat"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ozgur Polat" title="Ozgur Polat"/></a> <a href="https://github.com/search?q=Pablo%20Nunez"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pablo Nunez" title="Pablo Nunez"/></a> <a href="https://github.com/search?q=Palash%20Oswal"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Palash Oswal" title="Palash Oswal"/></a> <a href="https://github.com/search?q=pasogott"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pasogott" title="pasogott"/></a>
|
||||
<a href="https://github.com/search?q=Patrick%20Shao"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Patrick Shao" title="Patrick Shao"/></a> <a href="https://github.com/search?q=Paul%20Pamment"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Paul Pamment" title="Paul Pamment"/></a> <a href="https://github.com/search?q=Paulo%20Portella"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Paulo Portella" title="Paulo Portella"/></a> <a href="https://github.com/search?q=Peter%20Lee"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Peter Lee" title="Peter Lee"/></a> <a href="https://github.com/search?q=Petra%20Donka"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Petra Donka" title="Petra Donka"/></a> <a href="https://github.com/search?q=Pham%20Nam"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pham Nam" title="Pham Nam"/></a> <a href="https://github.com/search?q=pierreeurope"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pierreeurope" title="pierreeurope"/></a> <a href="https://github.com/search?q=pip-nomel"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pip-nomel" title="pip-nomel"/></a> <a href="https://github.com/search?q=plum-dawg"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/search?q=pookNast"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pookNast" title="pookNast"/></a>
|
||||
<a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="Pratham Dubey" title="Pratham Dubey"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=rafaelreis-r"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/Raikan10"><img src="https://avatars.githubusercontent.com/u/20675476?v=4&s=48" width="48" height="48" alt="Raikan10" title="Raikan10"/></a> <a href="https://github.com/search?q=Ramin%20Shirali%20Hossein%20Zade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ramin Shirali Hossein Zade" title="Ramin Shirali Hossein Zade"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/search?q=Raphael%20Borg%20Ellul%20Vincenti"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Raphael Borg Ellul Vincenti" title="Raphael Borg Ellul Vincenti"/></a> <a href="https://github.com/search?q=Ratul%20Sarna"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ratul Sarna" title="Ratul Sarna"/></a> <a href="https://github.com/search?q=Richard%20Pinedo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Richard Pinedo" title="Richard Pinedo"/></a> <a href="https://github.com/search?q=Rick%20Qian"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rick Qian" title="Rick Qian"/></a>
|
||||
<a href="https://github.com/search?q=robhparker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="robhparker" title="robhparker"/></a> <a href="https://github.com/search?q=Rohan%20Nagpal"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rohan Nagpal" title="Rohan Nagpal"/></a> <a href="https://github.com/search?q=Rohan%20Patil"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rohan Patil" title="Rohan Patil"/></a> <a href="https://github.com/rohanpatriot"><img src="https://avatars.githubusercontent.com/u/59978389?v=4&s=48" width="48" height="48" alt="rohanpatriot" title="rohanpatriot"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Ryan%20Nelson"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Nelson" title="Ryan Nelson"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/search?q=Santosh"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Santosh" title="Santosh"/></a> <a href="https://github.com/search?q=Sascha%20Reuter"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sascha Reuter" title="Sascha Reuter"/></a>
|
||||
<a href="https://github.com/search?q=Saurabh.Chopade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Saurabh.Chopade" title="Saurabh.Chopade"/></a> <a href="https://github.com/search?q=saurav470"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="saurav470" title="saurav470"/></a> <a href="https://github.com/search?q=seans-openclawbot"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="seans-openclawbot" title="seans-openclawbot"/></a> <a href="https://github.com/SecondThread"><img src="https://avatars.githubusercontent.com/u/18317476?v=4&s=48" width="48" height="48" alt="SecondThread" title="SecondThread"/></a> <a href="https://github.com/search?q=seewhy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="seewhy" title="seewhy"/></a> <a href="https://github.com/search?q=Senol%20Dogan"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Senol Dogan" title="Senol Dogan"/></a> <a href="https://github.com/search?q=Sergiy%20Dybskiy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sergiy Dybskiy" title="Sergiy Dybskiy"/></a> <a href="https://github.com/search?q=Shadow"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Shadow" title="Shadow"/></a> <a href="https://github.com/search?q=shatner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="shatner" title="shatner"/></a> <a href="https://github.com/search?q=Shaun%20Loo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Shaun Loo" title="Shaun Loo"/></a>
|
||||
<a href="https://github.com/search?q=Shaun%20Mason"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Shaun Mason" title="Shaun Mason"/></a> <a href="https://github.com/search?q=Shiva%20Prasad"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Shiva Prasad" title="Shiva Prasad"/></a> <a href="https://github.com/search?q=Shrinija%20Kummari"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Shrinija Kummari" title="Shrinija Kummari"/></a> <a href="https://github.com/search?q=Siddhant%20Jain"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Siddhant Jain" title="Siddhant Jain"/></a> <a href="https://github.com/search?q=Simon%20Kelly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Simon Kelly" title="Simon Kelly"/></a> <a href="https://github.com/search?q=SK%20Heavy%20Industries"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="SK Heavy Industries" title="SK Heavy Industries"/></a> <a href="https://github.com/sldkfoiweuaranwdlaiwyeoaw"><img src="https://avatars.githubusercontent.com/u/2593660?v=4&s=48" width="48" height="48" alt="sldkfoiweuaranwdlaiwyeoaw" title="sldkfoiweuaranwdlaiwyeoaw"/></a> <a href="https://github.com/search?q=Soumyadeep%20Ghosh"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Soumyadeep Ghosh" title="Soumyadeep Ghosh"/></a> <a href="https://github.com/search?q=Spacefish"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Spacefish" title="Spacefish"/></a> <a href="https://github.com/search?q=spiceoogway"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="spiceoogway" title="spiceoogway"/></a>
|
||||
<a href="https://github.com/search?q=Stephen%20Chen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Stephen Chen" title="Stephen Chen"/></a> <a href="https://github.com/search?q=Steve"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Steve" title="Steve"/></a> <a href="https://github.com/search?q=succ985"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="succ985" title="succ985"/></a> <a href="https://github.com/search?q=Suksham"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Suksham" title="Suksham"/></a> <a href="https://github.com/search?q=Sunwoo%20Yu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sunwoo Yu" title="Sunwoo Yu"/></a> <a href="https://github.com/search?q=Suvin%20Nimnaka"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Suvin Nimnaka" title="Suvin Nimnaka"/></a> <a href="https://github.com/Swader"><img src="https://avatars.githubusercontent.com/u/1430603?v=4&s=48" width="48" height="48" alt="Swader" title="Swader"/></a> <a href="https://github.com/swizzmagik"><img src="https://avatars.githubusercontent.com/u/3955528?v=4&s=48" width="48" height="48" alt="swizzmagik" title="swizzmagik"/></a> <a href="https://github.com/search?q=Tag"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tag" title="Tag"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></a>
|
||||
<a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=tewatia"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="tewatia" title="tewatia"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/search?q=therealZpoint-bot"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="therealZpoint-bot" title="therealZpoint-bot"/></a> <a href="https://github.com/search?q=tian%20Xiao"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="tian Xiao" title="tian Xiao"/></a> <a href="https://github.com/search?q=Tim%20Krase"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tim Krase" title="Tim Krase"/></a> <a href="https://github.com/search?q=Timo%20Lins"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Timo Lins" title="Timo Lins"/></a> <a href="https://github.com/search?q=Tom%20McKenzie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tom McKenzie" title="Tom McKenzie"/></a> <a href="https://github.com/search?q=Tom%20Peri"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tom Peri" title="Tom Peri"/></a> <a href="https://github.com/search?q=Tomas%20Hajek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tomas Hajek" title="Tomas Hajek"/></a>
|
||||
<a href="https://github.com/search?q=Tomsun28"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tomsun28" title="Tomsun28"/></a> <a href="https://github.com/search?q=Tonic"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tonic" title="Tonic"/></a> <a href="https://github.com/search?q=Travis%20Hinton"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Travis Hinton" title="Travis Hinton"/></a> <a href="https://github.com/search?q=Travis%20Irby"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Travis Irby" title="Travis Irby"/></a> <a href="https://github.com/search?q=Tulsi%20Prasad"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tulsi Prasad" title="Tulsi Prasad"/></a> <a href="https://github.com/search?q=Ty%20Sabs"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ty Sabs" title="Ty Sabs"/></a> <a href="https://github.com/search?q=Tyler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tyler" title="Tyler"/></a> <a href="https://github.com/search?q=uos-status"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/search?q=Vai"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vai" title="Vai"/></a> <a href="https://github.com/search?q=Varun%20Kruthiventi"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Varun Kruthiventi" title="Varun Kruthiventi"/></a>
|
||||
<a href="https://github.com/search?q=Vibe%20Kanban"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vibe Kanban" title="Vibe Kanban"/></a> <a href="https://github.com/search?q=Victor%20Castell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Victor Castell" title="Victor Castell"/></a> <a href="https://github.com/search?q=victor-wu.eth"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="victor-wu.eth" title="victor-wu.eth"/></a> <a href="https://github.com/search?q=vikpos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="vikpos" title="vikpos"/></a> <a href="https://github.com/search?q=Vincent"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vincent" title="Vincent"/></a> <a href="https://github.com/search?q=VintLin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VintLin" title="VintLin"/></a> <a href="https://github.com/search?q=Vladimir%20Peshekhonov"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vladimir Peshekhonov" title="Vladimir Peshekhonov"/></a> <a href="https://github.com/search?q=void"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="void" title="void"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/search?q=williamtwomey"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="williamtwomey" title="williamtwomey"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=Winry"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Winry" title="Winry"/></a> <a href="https://github.com/search?q=Winston"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Winston" title="Winston"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></a> <a href="https://github.com/search?q=Xin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Xin" title="Xin"/></a> <a href="https://github.com/search?q=Xinhe%20Hu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Xinhe Hu" title="Xinhe Hu"/></a> <a href="https://github.com/search?q=Xu%20Haoran"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Xu Haoran" title="Xu Haoran"/></a> <a href="https://github.com/search?q=Yash"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yash" title="Yash"/></a> <a href="https://github.com/search?q=Yaxuan42"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yaxuan42" title="Yaxuan42"/></a>
|
||||
<a href="https://github.com/search?q=Yazin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yazin" title="Yazin"/></a> <a href="https://github.com/search?q=Yevhen%20Bobrov"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yevhen Bobrov" title="Yevhen Bobrov"/></a> <a href="https://github.com/search?q=Yi%20Wang"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yi Wang" title="Yi Wang"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Yuan%20Chen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yuan Chen" title="Yuan Chen"/></a> <a href="https://github.com/search?q=Yuanhai"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yuanhai" title="Yuanhai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/search?q=Zaf%20(via%20OpenClaw)"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zaf (via OpenClaw)" title="Zaf (via OpenClaw)"/></a> <a href="https://github.com/search?q=zhixian"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="zhixian" title="zhixian"/></a> <a href="https://github.com/search?q=%E7%9F%B3%E5%B7%9D%20%E8%AB%92"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="石川 諒" title="石川 諒"/></a>
|
||||
<a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></a>
|
||||
<a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
||||
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
|
||||
<p align="center">
|
||||
<a href="https://github.com/DenchHQ/ironclaw"><img src="https://img.shields.io/github/stars/DenchHQ/ironclaw?style=for-the-badge" alt="GitHub stars"></a>
|
||||
</p>
|
||||
|
||||
13
apps/web/app/api/chat/active/route.ts
Normal file
13
apps/web/app/api/chat/active/route.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* GET /api/chat/active
|
||||
*
|
||||
* Returns the session IDs of all currently running agent sessions.
|
||||
* Used by the sidebar to show streaming indicators.
|
||||
*/
|
||||
import { getRunningSessionIds } from "@/lib/active-runs";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function GET() {
|
||||
return Response.json({ sessionIds: getRunningSessionIds() });
|
||||
}
|
||||
269
apps/web/app/api/chat/chat.test.ts
Normal file
269
apps/web/app/api/chat/chat.test.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock active-runs module
|
||||
vi.mock("@/lib/active-runs", () => ({
|
||||
startRun: vi.fn(),
|
||||
hasActiveRun: vi.fn(() => false),
|
||||
subscribeToRun: vi.fn(),
|
||||
persistUserMessage: vi.fn(),
|
||||
abortRun: vi.fn(() => false),
|
||||
getActiveRun: vi.fn(),
|
||||
getRunningSessionIds: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
// Mock workspace module
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
resolveAgentWorkspacePrefix: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
describe("Chat API routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
// Re-wire mocks
|
||||
vi.mock("@/lib/active-runs", () => ({
|
||||
startRun: vi.fn(),
|
||||
hasActiveRun: vi.fn(() => false),
|
||||
subscribeToRun: vi.fn(),
|
||||
persistUserMessage: vi.fn(),
|
||||
abortRun: vi.fn(() => false),
|
||||
getActiveRun: vi.fn(),
|
||||
getRunningSessionIds: vi.fn(() => []),
|
||||
}));
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
resolveAgentWorkspacePrefix: vi.fn(() => null),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── POST /api/chat ──────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/chat", () => {
|
||||
it("returns 400 when no user message text", async () => {
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: "user", parts: [{ type: "text", text: "" }] }],
|
||||
}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 409 when active run exists for session", async () => {
|
||||
const { hasActiveRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(hasActiveRun).mockReturnValue(true);
|
||||
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: "user", parts: [{ type: "text", text: "hello" }] }],
|
||||
sessionId: "s1",
|
||||
}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("starts a run and returns streaming response", async () => {
|
||||
const { startRun, hasActiveRun, subscribeToRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(hasActiveRun).mockReturnValue(false);
|
||||
vi.mocked(subscribeToRun).mockReturnValue(() => {});
|
||||
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{ id: "m1", role: "user", parts: [{ type: "text", text: "hello" }] },
|
||||
],
|
||||
sessionId: "s1",
|
||||
}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||
expect(startRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists user message when sessionId provided", async () => {
|
||||
const { hasActiveRun, subscribeToRun, persistUserMessage } = await import("@/lib/active-runs");
|
||||
vi.mocked(hasActiveRun).mockReturnValue(false);
|
||||
vi.mocked(subscribeToRun).mockReturnValue(() => {});
|
||||
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
||||
],
|
||||
sessionId: "s1",
|
||||
}),
|
||||
});
|
||||
await POST(req);
|
||||
expect(persistUserMessage).toHaveBeenCalledWith("s1", expect.objectContaining({ id: "m1" }));
|
||||
});
|
||||
|
||||
it("resolves workspace file paths in message", async () => {
|
||||
const { resolveAgentWorkspacePrefix } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveAgentWorkspacePrefix).mockReturnValue("workspace");
|
||||
const { startRun, hasActiveRun, subscribeToRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(hasActiveRun).mockReturnValue(false);
|
||||
vi.mocked(subscribeToRun).mockReturnValue(() => {});
|
||||
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "[Context: workspace file 'doc.md']" }],
|
||||
},
|
||||
],
|
||||
sessionId: "s1",
|
||||
}),
|
||||
});
|
||||
await POST(req);
|
||||
expect(startRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("workspace/doc.md"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/chat/stop ────────────────────────────────────────
|
||||
|
||||
describe("POST /api/chat/stop", () => {
|
||||
it("returns 400 when sessionId missing", async () => {
|
||||
const { POST } = await import("./stop/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("aborts run and returns result", async () => {
|
||||
const { abortRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(abortRun).mockReturnValue(true);
|
||||
|
||||
const { POST } = await import("./stop/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: "s1" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("returns aborted=false for unknown session", async () => {
|
||||
const { abortRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(abortRun).mockReturnValue(false);
|
||||
|
||||
const { POST } = await import("./stop/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: "nonexistent" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
const json = await res.json();
|
||||
expect(json.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("handles invalid JSON body gracefully", async () => {
|
||||
const { POST } = await import("./stop/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/chat/active ────────────────────────────────────────
|
||||
|
||||
describe("GET /api/chat/active", () => {
|
||||
it("returns empty sessionIds when no active runs", async () => {
|
||||
const { GET } = await import("./active/route.js");
|
||||
const res = GET();
|
||||
const json = await res.json();
|
||||
expect(json.sessionIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns active session IDs", async () => {
|
||||
const { getRunningSessionIds } = await import("@/lib/active-runs");
|
||||
vi.mocked(getRunningSessionIds).mockReturnValue(["s1", "s2"]);
|
||||
|
||||
const { GET } = await import("./active/route.js");
|
||||
const res = GET();
|
||||
const json = await res.json();
|
||||
expect(json.sessionIds).toEqual(["s1", "s2"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/chat/stream ───────────────────────────────────────
|
||||
|
||||
describe("GET /api/chat/stream", () => {
|
||||
it("returns 400 when sessionId is missing", async () => {
|
||||
const { GET } = await import("./stream/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stream");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when no run exists for session", async () => {
|
||||
const { getActiveRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(getActiveRun).mockReturnValue(undefined);
|
||||
|
||||
const { GET } = await import("./stream/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stream?sessionId=nonexistent");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns SSE stream for active run", async () => {
|
||||
const { getActiveRun, subscribeToRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(getActiveRun).mockReturnValue({ status: "running" } as never);
|
||||
vi.mocked(subscribeToRun).mockReturnValue(() => {});
|
||||
|
||||
const { GET } = await import("./stream/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stream?sessionId=s1");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||
expect(res.headers.get("X-Run-Active")).toBe("true");
|
||||
});
|
||||
|
||||
it("returns X-Run-Active=false for completed run", async () => {
|
||||
const { getActiveRun, subscribeToRun } = await import("@/lib/active-runs");
|
||||
vi.mocked(getActiveRun).mockReturnValue({ status: "completed" } as never);
|
||||
vi.mocked(subscribeToRun).mockReturnValue(() => {});
|
||||
|
||||
const { GET } = await import("./stream/route.js");
|
||||
const req = new Request("http://localhost/api/chat/stream?sessionId=s1");
|
||||
const res = await GET(req);
|
||||
expect(res.headers.get("X-Run-Active")).toBe("false");
|
||||
});
|
||||
});
|
||||
});
|
||||
223
apps/web/app/api/chat/route.ts
Normal file
223
apps/web/app/api/chat/route.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveAgentWorkspacePrefix } from "@/lib/workspace";
|
||||
import {
|
||||
startRun,
|
||||
hasActiveRun,
|
||||
subscribeToRun,
|
||||
persistUserMessage,
|
||||
type SseEvent as ParentSseEvent,
|
||||
} from "@/lib/active-runs";
|
||||
import {
|
||||
hasActiveSubagent,
|
||||
isSubagentRunning,
|
||||
ensureRegisteredFromDisk,
|
||||
subscribeToSubagent,
|
||||
persistUserMessage as persistSubagentUserMessage,
|
||||
reactivateSubagent,
|
||||
spawnSubagentMessage,
|
||||
type SseEvent as SubagentSseEvent,
|
||||
} from "@/lib/subagent-runs";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
// Force Node.js runtime (required for child_process)
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// Allow streaming responses up to 10 minutes
|
||||
export const maxDuration = 600;
|
||||
|
||||
function deriveSubagentParentSessionId(sessionKey: string): string {
|
||||
const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
|
||||
if (!existsSync(registryPath)) {return "";}
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(registryPath, "utf-8")) as {
|
||||
runs?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
for (const entry of Object.values(raw.runs ?? {})) {
|
||||
if (entry.childSessionKey !== sessionKey) {continue;}
|
||||
const requester = typeof entry.requesterSessionKey === "string" ? entry.requesterSessionKey : "";
|
||||
const match = requester.match(/^agent:[^:]+:web:(.+)$/);
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function ensureSubagentRegistered(sessionKey: string): boolean {
|
||||
if (hasActiveSubagent(sessionKey)) {return true;}
|
||||
const parentWebSessionId = deriveSubagentParentSessionId(sessionKey);
|
||||
return ensureRegisteredFromDisk(sessionKey, parentWebSessionId);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const {
|
||||
messages,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
}: { messages: UIMessage[]; sessionId?: string; sessionKey?: string } = await req.json();
|
||||
|
||||
// Extract the latest user message text
|
||||
const lastUserMessage = messages.filter((m) => m.role === "user").pop();
|
||||
const userText =
|
||||
lastUserMessage?.parts
|
||||
?.filter(
|
||||
(p): p is { type: "text"; text: string } =>
|
||||
p.type === "text",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("\n") ?? "";
|
||||
|
||||
if (!userText.trim()) {
|
||||
return new Response("No message provided", { status: 400 });
|
||||
}
|
||||
|
||||
const isSubagentSession = typeof sessionKey === "string" && sessionKey.includes(":subagent:");
|
||||
|
||||
// Reject if a run is already active for this session.
|
||||
if (!isSubagentSession && sessionId && hasActiveRun(sessionId)) {
|
||||
return new Response("Active run in progress", { status: 409 });
|
||||
}
|
||||
if (isSubagentSession && isSubagentRunning(sessionKey)) {
|
||||
return new Response("Active subagent run in progress", { status: 409 });
|
||||
}
|
||||
|
||||
// Resolve workspace file paths to be agent-cwd-relative.
|
||||
let agentMessage = userText;
|
||||
const wsPrefix = resolveAgentWorkspacePrefix();
|
||||
if (wsPrefix) {
|
||||
agentMessage = userText.replace(
|
||||
/\[Context: workspace file '([^']+)'\]/,
|
||||
`[Context: workspace file '${wsPrefix}/$1']`,
|
||||
);
|
||||
}
|
||||
|
||||
// Persist the user message server-side so it survives a page reload
|
||||
// even if the client never gets a chance to save.
|
||||
if (isSubagentSession && sessionKey && lastUserMessage) {
|
||||
if (!ensureSubagentRegistered(sessionKey)) {
|
||||
return new Response("Subagent not found", { status: 404 });
|
||||
}
|
||||
persistSubagentUserMessage(sessionKey, {
|
||||
id: lastUserMessage.id,
|
||||
text: userText,
|
||||
});
|
||||
} else if (sessionId && lastUserMessage) {
|
||||
persistUserMessage(sessionId, {
|
||||
id: lastUserMessage.id,
|
||||
content: userText,
|
||||
parts: lastUserMessage.parts as unknown[],
|
||||
});
|
||||
}
|
||||
|
||||
// Start the agent run (decoupled from this HTTP connection).
|
||||
// The child process will keep running even if this response is cancelled.
|
||||
if (isSubagentSession && sessionKey) {
|
||||
if (!reactivateSubagent(sessionKey)) {
|
||||
return new Response("Subagent not found", { status: 404 });
|
||||
}
|
||||
if (!spawnSubagentMessage(sessionKey, agentMessage)) {
|
||||
return new Response("Failed to start subagent run", { status: 500 });
|
||||
}
|
||||
} else if (sessionId) {
|
||||
try {
|
||||
startRun({
|
||||
sessionId,
|
||||
message: agentMessage,
|
||||
agentSessionId: sessionId,
|
||||
});
|
||||
} catch (err) {
|
||||
return new Response(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stream SSE events to the client using the AI SDK v6 wire format.
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
if (!sessionId && !sessionKey) {
|
||||
// No session — shouldn't happen but close gracefully.
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribe = isSubagentSession && sessionKey
|
||||
? subscribeToSubagent(
|
||||
sessionKey,
|
||||
(event: SubagentSseEvent | null) => {
|
||||
if (closed) {return;}
|
||||
if (event === null) {
|
||||
closed = true;
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const json = JSON.stringify(event);
|
||||
controller.enqueue(encoder.encode(`data: ${json}\n\n`));
|
||||
} catch {
|
||||
/* ignore enqueue errors on closed stream */
|
||||
}
|
||||
},
|
||||
{ replay: false },
|
||||
)
|
||||
: subscribeToRun(
|
||||
sessionId as string,
|
||||
(event: ParentSseEvent | null) => {
|
||||
if (closed) {return;}
|
||||
if (event === null) {
|
||||
// Run completed — close the SSE stream.
|
||||
closed = true;
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const json = JSON.stringify(event);
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${json}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
/* ignore enqueue errors on closed stream */
|
||||
}
|
||||
},
|
||||
// Don't replay — we just created the run, the buffer is empty.
|
||||
{ replay: false },
|
||||
);
|
||||
|
||||
if (!unsubscribe) {
|
||||
// Race: run was cleaned up between startRun and subscribe.
|
||||
closed = true;
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
// Client disconnected — unsubscribe but keep the run alive.
|
||||
// The ActiveRunManager continues buffering + persisting in the background.
|
||||
closed = true;
|
||||
unsubscribe?.();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
60
apps/web/app/api/chat/stop/route.ts
Normal file
60
apps/web/app/api/chat/stop/route.ts
Normal file
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* POST /api/chat/stop
|
||||
*
|
||||
* Abort an active agent run. Called by the Stop button.
|
||||
* The child process is sent SIGTERM and the run transitions to "error" state.
|
||||
*/
|
||||
import { abortRun } from "@/lib/active-runs";
|
||||
import {
|
||||
abortSubagent,
|
||||
hasActiveSubagent,
|
||||
isSubagentRunning,
|
||||
ensureRegisteredFromDisk,
|
||||
} from "@/lib/subagent-runs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function deriveSubagentParentSessionId(sessionKey: string): string {
|
||||
const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
|
||||
if (!existsSync(registryPath)) {return "";}
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(registryPath, "utf-8")) as {
|
||||
runs?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
for (const entry of Object.values(raw.runs ?? {})) {
|
||||
if (entry.childSessionKey !== sessionKey) {continue;}
|
||||
const requester = typeof entry.requesterSessionKey === "string" ? entry.requesterSessionKey : "";
|
||||
const match = requester.match(/^agent:[^:]+:web:(.+)$/);
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body: { sessionId?: string; sessionKey?: string } = await req
|
||||
.json()
|
||||
.catch(() => ({}));
|
||||
|
||||
const isSubagentSession = typeof body.sessionKey === "string" && body.sessionKey.includes(":subagent:");
|
||||
if (isSubagentSession && body.sessionKey) {
|
||||
if (!hasActiveSubagent(body.sessionKey)) {
|
||||
const parentWebSessionId = deriveSubagentParentSessionId(body.sessionKey);
|
||||
ensureRegisteredFromDisk(body.sessionKey, parentWebSessionId);
|
||||
}
|
||||
const aborted = isSubagentRunning(body.sessionKey) ? abortSubagent(body.sessionKey) : false;
|
||||
return Response.json({ aborted });
|
||||
}
|
||||
|
||||
if (!body.sessionId) {
|
||||
return new Response("sessionId or subagent sessionKey required", { status: 400 });
|
||||
}
|
||||
|
||||
const aborted = abortRun(body.sessionId);
|
||||
return Response.json({ aborted });
|
||||
}
|
||||
201
apps/web/app/api/chat/stream/route.ts
Normal file
201
apps/web/app/api/chat/stream/route.ts
Normal file
@ -0,0 +1,201 @@
|
||||
/**
|
||||
* GET /api/chat/stream?sessionId=xxx
|
||||
*
|
||||
* Reconnect to an active (or recently-completed) agent run.
|
||||
* Replays all buffered SSE events from the start of the run, then
|
||||
* streams live events until the run finishes.
|
||||
*
|
||||
* Returns 404 if no run exists for the given session.
|
||||
*/
|
||||
import {
|
||||
getActiveRun,
|
||||
subscribeToRun,
|
||||
type SseEvent as ParentSseEvent,
|
||||
} from "@/lib/active-runs";
|
||||
import {
|
||||
subscribeToSubagent,
|
||||
hasActiveSubagent,
|
||||
isSubagentRunning,
|
||||
ensureRegisteredFromDisk,
|
||||
ensureSubagentStreamable,
|
||||
type SseEvent as SubagentSseEvent,
|
||||
} from "@/lib/subagent-runs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 600;
|
||||
|
||||
function deriveSubagentParentSessionId(sessionKey: string): string {
|
||||
const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
|
||||
if (!existsSync(registryPath)) {return "";}
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(registryPath, "utf-8")) as {
|
||||
runs?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
for (const entry of Object.values(raw.runs ?? {})) {
|
||||
if (entry.childSessionKey !== sessionKey) {continue;}
|
||||
const requester = typeof entry.requesterSessionKey === "string" ? entry.requesterSessionKey : "";
|
||||
const match = requester.match(/^agent:[^:]+:web:(.+)$/);
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const sessionId = url.searchParams.get("sessionId");
|
||||
const sessionKey = url.searchParams.get("sessionKey");
|
||||
const isSubagentSession = typeof sessionKey === "string" && sessionKey.includes(":subagent:");
|
||||
|
||||
if (!sessionId && !sessionKey) {
|
||||
return new Response("sessionId or subagent sessionKey required", { status: 400 });
|
||||
}
|
||||
|
||||
if (isSubagentSession && sessionKey) {
|
||||
if (!hasActiveSubagent(sessionKey)) {
|
||||
const parentWebSessionId = deriveSubagentParentSessionId(sessionKey);
|
||||
const registered = ensureRegisteredFromDisk(sessionKey, parentWebSessionId);
|
||||
if (!registered && !hasActiveSubagent(sessionKey)) {
|
||||
return Response.json({ active: false }, { status: 404 });
|
||||
}
|
||||
}
|
||||
ensureSubagentStreamable(sessionKey);
|
||||
const isActive = isSubagentRunning(sessionKey);
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
unsubscribe = subscribeToSubagent(
|
||||
sessionKey,
|
||||
(event: SubagentSseEvent | null) => {
|
||||
if (closed) {return;}
|
||||
if (event === null) {
|
||||
closed = true;
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const json = JSON.stringify(event);
|
||||
controller.enqueue(encoder.encode(`data: ${json}\n\n`));
|
||||
} catch {
|
||||
/* ignore enqueue errors on closed stream */
|
||||
}
|
||||
},
|
||||
{ replay: true },
|
||||
);
|
||||
|
||||
if (!unsubscribe) {
|
||||
closed = true;
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
unsubscribe?.();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Run-Active": isActive ? "true" : "false",
|
||||
},
|
||||
});
|
||||
}
|
||||
const run = getActiveRun(sessionId as string);
|
||||
if (!run) {
|
||||
return Response.json({ active: false }, { status: 404 });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let keepalive: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Keep idle SSE connections alive while waiting for subagent announcements.
|
||||
keepalive = setInterval(() => {
|
||||
if (closed) {return;}
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
} catch {
|
||||
/* ignore enqueue errors on closed stream */
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
// subscribeToRun with replay=true replays the full event buffer
|
||||
// synchronously, then subscribes for live events.
|
||||
unsubscribe = subscribeToRun(
|
||||
sessionId as string,
|
||||
(event: ParentSseEvent | null) => {
|
||||
if (closed) {return;}
|
||||
if (event === null) {
|
||||
// Run completed — close the SSE stream.
|
||||
closed = true;
|
||||
if (keepalive) {
|
||||
clearInterval(keepalive);
|
||||
keepalive = null;
|
||||
}
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const json = JSON.stringify(event);
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${json}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
/* ignore enqueue errors on closed stream */
|
||||
}
|
||||
},
|
||||
{ replay: true },
|
||||
);
|
||||
|
||||
if (!unsubscribe) {
|
||||
// Run was cleaned up between getActiveRun and subscribe.
|
||||
closed = true;
|
||||
if (keepalive) {
|
||||
clearInterval(keepalive);
|
||||
keepalive = null;
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
// Client disconnected — unsubscribe only (don't kill the run).
|
||||
closed = true;
|
||||
if (keepalive) {
|
||||
clearInterval(keepalive);
|
||||
keepalive = null;
|
||||
}
|
||||
unsubscribe?.();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Run-Active": run.status === "running" || run.status === "waiting-for-subagents" ? "true" : "false",
|
||||
},
|
||||
});
|
||||
}
|
||||
64
apps/web/app/api/chat/subagents/route.ts
Normal file
64
apps/web/app/api/chat/subagents/route.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RegistryEntry = {
|
||||
runId: string;
|
||||
childSessionKey: string;
|
||||
requesterSessionKey: string;
|
||||
task: string;
|
||||
label?: string;
|
||||
createdAt?: number;
|
||||
endedAt?: number;
|
||||
outcome?: { status: string; error?: string };
|
||||
};
|
||||
|
||||
function readSubagentRegistry(): RegistryEntry[] {
|
||||
const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
|
||||
if (!existsSync(registryPath)) {return [];}
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(registryPath, "utf-8"));
|
||||
if (!raw || typeof raw !== "object") {return [];}
|
||||
const runs = raw.runs;
|
||||
if (!runs || typeof runs !== "object") {return [];}
|
||||
return Object.values(runs);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const sessionId = url.searchParams.get("sessionId");
|
||||
|
||||
if (!sessionId) {
|
||||
return Response.json({ error: "sessionId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const webSessionKey = `agent:main:web:${sessionId}`;
|
||||
const entries = readSubagentRegistry();
|
||||
|
||||
const subagents = entries
|
||||
.filter((e) => e.requesterSessionKey === webSessionKey)
|
||||
.map((e) => ({
|
||||
sessionKey: e.childSessionKey,
|
||||
runId: e.runId,
|
||||
task: e.task,
|
||||
label: e.label || undefined,
|
||||
status: resolveStatus(e),
|
||||
startedAt: e.createdAt,
|
||||
endedAt: e.endedAt,
|
||||
}))
|
||||
.toSorted((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
|
||||
|
||||
return Response.json({ subagents });
|
||||
}
|
||||
|
||||
function resolveStatus(e: RegistryEntry): "running" | "completed" | "error" {
|
||||
if (typeof e.endedAt !== "number") {return "running";}
|
||||
if (e.outcome?.status === "error") {return "error";}
|
||||
return "completed";
|
||||
}
|
||||
153
apps/web/app/api/cron/cron.test.ts
Normal file
153
apps/web/app/api/cron/cron.test.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
|
||||
}));
|
||||
|
||||
// Mock node:os
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
|
||||
describe("Cron API routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
|
||||
}));
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── GET /api/cron/jobs ─────────────────────────────────────────
|
||||
|
||||
describe("GET /api/cron/jobs", () => {
|
||||
it("returns empty jobs when no config file", async () => {
|
||||
const { GET } = await import("./jobs/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.jobs).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns jobs from config file", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
const cronStore = {
|
||||
version: 1,
|
||||
jobs: [
|
||||
{ id: "j1", name: "Daily sync", schedule: "0 8 * * *", enabled: true, command: "sync" },
|
||||
],
|
||||
};
|
||||
vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(cronStore) as never);
|
||||
|
||||
const { GET } = await import("./jobs/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.jobs).toHaveLength(1);
|
||||
expect(json.jobs[0].name).toBe("Daily sync");
|
||||
});
|
||||
|
||||
it("handles corrupt jobs file gracefully", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReadFile).mockReturnValue("not json" as never);
|
||||
|
||||
const { GET } = await import("./jobs/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.jobs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/cron/jobs/[jobId]/runs ────────────────────────────
|
||||
|
||||
describe("GET /api/cron/jobs/[jobId]/runs", () => {
|
||||
it("returns empty entries when no runs file", async () => {
|
||||
const { GET } = await import("./jobs/[jobId]/runs/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/cron/jobs/j1/runs"),
|
||||
{ params: Promise.resolve({ jobId: "j1" }) },
|
||||
);
|
||||
const json = await res.json();
|
||||
expect(json.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns run entries from jsonl file", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
const lines = [
|
||||
JSON.stringify({ ts: 1000, jobId: "j1", action: "finished", status: "completed", summary: "Done" }),
|
||||
JSON.stringify({ ts: 2000, jobId: "j1", action: "finished", status: "completed", summary: "In progress" }),
|
||||
].join("\n");
|
||||
vi.mocked(mockReadFile).mockReturnValue(lines as never);
|
||||
|
||||
const { GET } = await import("./jobs/[jobId]/runs/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/cron/jobs/j1/runs"),
|
||||
{ params: Promise.resolve({ jobId: "j1" }) },
|
||||
);
|
||||
const json = await res.json();
|
||||
expect(json.entries.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("respects limit query param", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
const lines = Array.from({ length: 50 }, (_, i) =>
|
||||
JSON.stringify({ ts: i, status: "completed" }),
|
||||
).join("\n");
|
||||
vi.mocked(mockReadFile).mockReturnValue(lines as never);
|
||||
|
||||
const { GET } = await import("./jobs/[jobId]/runs/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/cron/jobs/j1/runs?limit=5"),
|
||||
{ params: Promise.resolve({ jobId: "j1" }) },
|
||||
);
|
||||
const json = await res.json();
|
||||
expect(json.entries.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/cron/runs/[sessionId] ─────────────────────────────
|
||||
|
||||
describe("GET /api/cron/runs/[sessionId]", () => {
|
||||
it("returns 404 when session not found", async () => {
|
||||
const { GET } = await import("./runs/[sessionId]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost"),
|
||||
{ params: Promise.resolve({ sessionId: "nonexistent" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/cron/runs/search-transcript ───────────────────────
|
||||
|
||||
describe("GET /api/cron/runs/search-transcript", () => {
|
||||
it("returns 400 when missing required params", async () => {
|
||||
const { GET } = await import("./runs/search-transcript/route.js");
|
||||
const req = new Request("http://localhost/api/cron/runs/search-transcript");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when no transcript found", async () => {
|
||||
const { GET } = await import("./runs/search-transcript/route.js");
|
||||
const req = new Request("http://localhost/api/cron/runs/search-transcript?jobId=j1&runAtMs=1000");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
86
apps/web/app/api/cron/jobs/[jobId]/runs/route.ts
Normal file
86
apps/web/app/api/cron/jobs/[jobId]/runs/route.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CRON_DIR = join(resolveOpenClawStateDir(), "cron");
|
||||
|
||||
type CronRunLogEntry = {
|
||||
ts: number;
|
||||
jobId: string;
|
||||
action: "finished";
|
||||
status?: string;
|
||||
error?: string;
|
||||
summary?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
runAtMs?: number;
|
||||
durationMs?: number;
|
||||
nextRunAtMs?: number;
|
||||
};
|
||||
|
||||
/** Read run log entries from a JSONL file, returning most recent first (then reversed). */
|
||||
function readRunLog(filePath: string, limit: number): CronRunLogEntry[] {
|
||||
if (!existsSync(filePath)) {return [];}
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
if (!raw.trim()) {return [];}
|
||||
const lines = raw.split("\n");
|
||||
const parsed: CronRunLogEntry[] = [];
|
||||
for (let i = lines.length - 1; i >= 0 && parsed.length < limit; i--) {
|
||||
const line = lines[i]?.trim();
|
||||
if (!line) {continue;}
|
||||
try {
|
||||
const obj = JSON.parse(line) as Partial<CronRunLogEntry>;
|
||||
if (!obj || typeof obj !== "object") {continue;}
|
||||
if (obj.action !== "finished") {continue;}
|
||||
if (typeof obj.jobId !== "string" || !obj.jobId.trim()) {continue;}
|
||||
if (typeof obj.ts !== "number" || !Number.isFinite(obj.ts)) {continue;}
|
||||
const entry: CronRunLogEntry = {
|
||||
ts: obj.ts,
|
||||
jobId: obj.jobId,
|
||||
action: "finished",
|
||||
status: obj.status,
|
||||
error: obj.error,
|
||||
summary: obj.summary,
|
||||
runAtMs: obj.runAtMs,
|
||||
durationMs: obj.durationMs,
|
||||
nextRunAtMs: obj.nextRunAtMs,
|
||||
};
|
||||
if (typeof obj.sessionId === "string" && obj.sessionId.trim()) {
|
||||
entry.sessionId = obj.sessionId;
|
||||
}
|
||||
if (typeof obj.sessionKey === "string" && obj.sessionKey.trim()) {
|
||||
entry.sessionKey = obj.sessionKey;
|
||||
}
|
||||
parsed.push(entry);
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
return parsed.toReversed();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/cron/jobs/[jobId]/runs -- list run log entries for a cron job */
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ jobId: string }> },
|
||||
) {
|
||||
const { jobId } = await params;
|
||||
if (!jobId) {
|
||||
return Response.json({ error: "Job ID required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limitParam = url.searchParams.get("limit");
|
||||
const limit = Math.max(1, Math.min(500, Number(limitParam) || 100));
|
||||
|
||||
const logPath = join(CRON_DIR, "runs", `${jobId}.jsonl`);
|
||||
const entries = readRunLog(logPath, limit);
|
||||
|
||||
return Response.json({ entries });
|
||||
}
|
||||
99
apps/web/app/api/cron/jobs/route.ts
Normal file
99
apps/web/app/api/cron/jobs/route.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CRON_DIR = join(resolveOpenClawStateDir(), "cron");
|
||||
const JOBS_FILE = join(CRON_DIR, "jobs.json");
|
||||
|
||||
type CronStoreFile = {
|
||||
version: 1;
|
||||
jobs: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** Read cron jobs.json, returning empty array if missing or invalid. */
|
||||
function readJobsFile(): Array<Record<string, unknown>> {
|
||||
if (!existsSync(JOBS_FILE)) {return [];}
|
||||
try {
|
||||
const raw = readFileSync(JOBS_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw) as CronStoreFile;
|
||||
if (parsed && Array.isArray(parsed.jobs)) {return parsed.jobs;}
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute next wake time from job states (minimum nextRunAtMs among enabled jobs). */
|
||||
function computeNextWakeAtMs(jobs: Array<Record<string, unknown>>): number | null {
|
||||
let min: number | null = null;
|
||||
for (const job of jobs) {
|
||||
if (job.enabled !== true) {continue;}
|
||||
const state = job.state as Record<string, unknown> | undefined;
|
||||
if (!state) {continue;}
|
||||
const next = state.nextRunAtMs;
|
||||
if (typeof next === "number" && Number.isFinite(next)) {
|
||||
if (min === null || next < min) {min = next;}
|
||||
}
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
/** Read heartbeat config from ~/.openclaw/config.yaml (best-effort). */
|
||||
function readHeartbeatInfo(): { intervalMs: number; nextDueEstimateMs: number | null } {
|
||||
const defaults = { intervalMs: 30 * 60_000, nextDueEstimateMs: null as number | null };
|
||||
|
||||
// Try to read agent session stores to estimate next heartbeat from lastRunMs
|
||||
try {
|
||||
const agentsDir = join(resolveOpenClawStateDir(), "agents");
|
||||
if (!existsSync(agentsDir)) {return defaults;}
|
||||
|
||||
const agentDirs = readdirSync(agentsDir, { withFileTypes: true });
|
||||
let latestHeartbeat: number | null = null;
|
||||
|
||||
for (const d of agentDirs) {
|
||||
if (!d.isDirectory()) {continue;}
|
||||
const storePath = join(agentsDir, d.name, "sessions", "sessions.json");
|
||||
if (!existsSync(storePath)) {continue;}
|
||||
try {
|
||||
const raw = readFileSync(storePath, "utf-8");
|
||||
const store = JSON.parse(raw) as Record<string, { updatedAt?: number }>;
|
||||
// Look for the main agent session (shortest key, most recently updated)
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
if (key.startsWith("agent:") && !key.includes(":cron:") && entry.updatedAt) {
|
||||
if (latestHeartbeat === null || entry.updatedAt > latestHeartbeat) {
|
||||
latestHeartbeat = entry.updatedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
if (latestHeartbeat) {
|
||||
defaults.nextDueEstimateMs = latestHeartbeat + defaults.intervalMs;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/** GET /api/cron/jobs -- list all cron jobs with heartbeat & status info */
|
||||
export async function GET() {
|
||||
const jobs = readJobsFile();
|
||||
const heartbeat = readHeartbeatInfo();
|
||||
const nextWakeAtMs = computeNextWakeAtMs(jobs);
|
||||
|
||||
return Response.json({
|
||||
jobs,
|
||||
heartbeat,
|
||||
cronStatus: {
|
||||
enabled: jobs.length > 0,
|
||||
nextWakeAtMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
154
apps/web/app/api/cron/runs/[sessionId]/route.ts
Normal file
154
apps/web/app/api/cron/runs/[sessionId]/route.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type MessagePart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "thinking"; thinking: string }
|
||||
| { type: "tool-call"; toolName: string; toolCallId: string; args?: unknown; output?: string };
|
||||
|
||||
type ParsedMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
parts: MessagePart[];
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
/** Search agent session directories for a session file by ID. */
|
||||
function findSessionFile(sessionId: string): string | null {
|
||||
const agentsDir = join(resolveOpenClawStateDir(), "agents");
|
||||
if (!existsSync(agentsDir)) {return null;}
|
||||
|
||||
try {
|
||||
const agentDirs = readdirSync(agentsDir, { withFileTypes: true });
|
||||
for (const agentDir of agentDirs) {
|
||||
if (!agentDir.isDirectory()) {continue;}
|
||||
const sessionFile = join(agentsDir, agentDir.name, "sessions", `${sessionId}.jsonl`);
|
||||
if (existsSync(sessionFile)) {return sessionFile;}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse a JSONL session transcript into structured messages with thinking and tool calls. */
|
||||
function parseSessionTranscript(content: string): ParsedMessage[] {
|
||||
const lines = content.trim().split("\n").filter((l) => l.trim());
|
||||
const messages: ParsedMessage[] = [];
|
||||
|
||||
// Track tool calls for linking invocations with results
|
||||
const pendingToolCalls = new Map<string, { toolName: string; args?: unknown }>();
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
if (entry.type === "message" && entry.message) {
|
||||
const msg = entry.message;
|
||||
const role = msg.role as "user" | "assistant" | "system";
|
||||
const parts: MessagePart[] = [];
|
||||
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
||||
parts.push({ type: "text", text: part.text });
|
||||
} else if (part.type === "thinking" && typeof part.thinking === "string" && part.thinking.trim()) {
|
||||
parts.push({ type: "thinking", thinking: part.thinking });
|
||||
} else if (part.type === "tool_use" || part.type === "tool-call") {
|
||||
const toolName = part.name ?? part.toolName ?? "unknown";
|
||||
const toolCallId = part.id ?? part.toolCallId ?? `tool-${Date.now()}`;
|
||||
pendingToolCalls.set(toolCallId, { toolName, args: part.input ?? part.args });
|
||||
parts.push({
|
||||
type: "tool-call",
|
||||
toolName,
|
||||
toolCallId,
|
||||
args: part.input ?? part.args,
|
||||
});
|
||||
} else if (part.type === "tool_result" || part.type === "tool-result") {
|
||||
const toolCallId = part.tool_use_id ?? part.toolCallId ?? "";
|
||||
const pending = pendingToolCalls.get(toolCallId);
|
||||
const outputText = typeof part.content === "string"
|
||||
? part.content
|
||||
: Array.isArray(part.content)
|
||||
? part.content.filter((c: { type: string }) => c.type === "text").map((c: { text: string }) => c.text).join("\n")
|
||||
: typeof part.output === "string"
|
||||
? part.output
|
||||
: JSON.stringify(part.output ?? part.content ?? "");
|
||||
|
||||
if (pending) {
|
||||
// Merge output into existing tool-call part
|
||||
const existingMsg = messages[messages.length - 1];
|
||||
if (existingMsg) {
|
||||
const tc = existingMsg.parts.find(
|
||||
(p) => p.type === "tool-call" && (p as { toolCallId: string }).toolCallId === toolCallId,
|
||||
);
|
||||
if (tc && tc.type === "tool-call") {
|
||||
(tc as { output?: string }).output = outputText.slice(0, 5000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
parts.push({
|
||||
type: "tool-call",
|
||||
toolName: pending.toolName,
|
||||
toolCallId,
|
||||
args: pending.args,
|
||||
output: outputText.slice(0, 5000),
|
||||
});
|
||||
} else {
|
||||
parts.push({
|
||||
type: "tool-call",
|
||||
toolName: "tool",
|
||||
toolCallId,
|
||||
output: outputText.slice(0, 5000),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (typeof msg.content === "string" && msg.content.trim()) {
|
||||
parts.push({ type: "text", text: msg.content });
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
messages.push({
|
||||
id: entry.id ?? `msg-${messages.length}`,
|
||||
role,
|
||||
parts,
|
||||
timestamp: entry.timestamp ?? new Date(entry.ts ?? Date.now()).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/** GET /api/cron/runs/[sessionId] -- get full session transcript for a cron run */
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> },
|
||||
) {
|
||||
const { sessionId } = await params;
|
||||
if (!sessionId) {
|
||||
return Response.json({ error: "Session ID required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const sessionFile = findSessionFile(sessionId);
|
||||
if (!sessionFile) {
|
||||
return Response.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(sessionFile, "utf-8");
|
||||
const messages = parseSessionTranscript(content);
|
||||
return Response.json({ sessionId, messages });
|
||||
} catch (error) {
|
||||
console.error("Error reading cron session:", error);
|
||||
return Response.json({ error: "Failed to read session" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
315
apps/web/app/api/cron/runs/search-transcript/route.ts
Normal file
315
apps/web/app/api/cron/runs/search-transcript/route.ts
Normal file
@ -0,0 +1,315 @@
|
||||
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const AGENTS_DIR = join(resolveOpenClawStateDir(), "agents");
|
||||
|
||||
type MessagePart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "thinking"; thinking: string }
|
||||
| { type: "tool-call"; toolName: string; toolCallId: string; args?: unknown; output?: string };
|
||||
|
||||
type ParsedMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
parts: MessagePart[];
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Search for the actual agent transcript for a cron run.
|
||||
*
|
||||
* For main-target cron runs, the agent response lives in the main session
|
||||
* transcript files. This endpoint searches session files for the cron payload
|
||||
* text near the run timestamp and returns the matching conversation
|
||||
* (user message + assistant response).
|
||||
*/
|
||||
|
||||
/** Try to find a cron-specific session from sessions.json. */
|
||||
function findCronSessionId(jobId: string): string | null {
|
||||
if (!existsSync(AGENTS_DIR)) {return null;}
|
||||
try {
|
||||
const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true });
|
||||
for (const agentDir of agentDirs) {
|
||||
if (!agentDir.isDirectory()) {continue;}
|
||||
const sessionsJsonPath = join(AGENTS_DIR, agentDir.name, "sessions", "sessions.json");
|
||||
if (!existsSync(sessionsJsonPath)) {continue;}
|
||||
try {
|
||||
const store = JSON.parse(readFileSync(sessionsJsonPath, "utf-8"));
|
||||
// Look for cron session key matching this job
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
if (key.includes(`:cron:${jobId}`) && !key.includes(":run:")) {
|
||||
const sessionId = (entry as { sessionId?: string })?.sessionId;
|
||||
if (typeof sessionId === "string" && sessionId.trim()) {
|
||||
// Verify the session file actually exists
|
||||
const sessionFile = join(AGENTS_DIR, agentDir.name, "sessions", `${sessionId}.jsonl`);
|
||||
if (existsSync(sessionFile)) {
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip malformed sessions.json
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Find session files that might contain the cron run's transcript. */
|
||||
function findCandidateSessionFiles(runAtMs: number): string[] {
|
||||
const candidates: Array<{ path: string; mtimeMs: number }> = [];
|
||||
if (!existsSync(AGENTS_DIR)) {return [];}
|
||||
|
||||
try {
|
||||
const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true });
|
||||
for (const agentDir of agentDirs) {
|
||||
if (!agentDir.isDirectory()) {continue;}
|
||||
const sessionsDir = join(AGENTS_DIR, agentDir.name, "sessions");
|
||||
if (!existsSync(sessionsDir)) {continue;}
|
||||
try {
|
||||
const files = readdirSync(sessionsDir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".jsonl")) {continue;}
|
||||
const filePath = join(sessionsDir, file);
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
// Only consider files modified within ±2 hours of the run
|
||||
const windowMs = 2 * 60 * 60 * 1000;
|
||||
if (Math.abs(stat.mtimeMs - runAtMs) < windowMs) {
|
||||
candidates.push({ path: filePath, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Sort by closest modification time to runAtMs
|
||||
candidates.sort((a, b) => Math.abs(a.mtimeMs - runAtMs) - Math.abs(b.mtimeMs - runAtMs));
|
||||
|
||||
// Limit to 10 most likely candidates
|
||||
return candidates.slice(0, 10).map((c) => c.path);
|
||||
}
|
||||
|
||||
/** Parse message entries from a JSONL transcript, optionally filtered by time range. */
|
||||
function parseMessagesInRange(
|
||||
content: string,
|
||||
opts?: { afterMs?: number; beforeMs?: number },
|
||||
): ParsedMessage[] {
|
||||
const lines = content.trim().split("\n").filter((l) => l.trim());
|
||||
const messages: ParsedMessage[] = [];
|
||||
const pendingToolCalls = new Map<string, { toolName: string; args?: unknown }>();
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type !== "message" || !entry.message) {continue;}
|
||||
|
||||
// Filter by timestamp if provided
|
||||
if (opts?.afterMs || opts?.beforeMs) {
|
||||
const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : (entry.ts ?? 0);
|
||||
if (opts.afterMs && ts < opts.afterMs) {continue;}
|
||||
if (opts.beforeMs && ts > opts.beforeMs) {continue;}
|
||||
}
|
||||
|
||||
const msg = entry.message;
|
||||
const role = msg.role as "user" | "assistant" | "system";
|
||||
const parts: MessagePart[] = [];
|
||||
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
||||
parts.push({ type: "text", text: part.text });
|
||||
} else if (part.type === "thinking" && typeof part.thinking === "string" && part.thinking.trim()) {
|
||||
parts.push({ type: "thinking", thinking: part.thinking });
|
||||
} else if (part.type === "tool_use" || part.type === "tool-call") {
|
||||
const toolName = part.name ?? part.toolName ?? "unknown";
|
||||
const toolCallId = part.id ?? part.toolCallId ?? `tool-${Date.now()}`;
|
||||
pendingToolCalls.set(toolCallId, { toolName, args: part.input ?? part.args });
|
||||
parts.push({ type: "tool-call", toolName, toolCallId, args: part.input ?? part.args });
|
||||
} else if (part.type === "tool_result" || part.type === "tool-result") {
|
||||
const toolCallId = part.tool_use_id ?? part.toolCallId ?? "";
|
||||
const pending = pendingToolCalls.get(toolCallId);
|
||||
const outputText = typeof part.content === "string"
|
||||
? part.content
|
||||
: Array.isArray(part.content)
|
||||
? part.content.filter((c: { type: string }) => c.type === "text").map((c: { text: string }) => c.text).join("\n")
|
||||
: typeof part.output === "string"
|
||||
? part.output
|
||||
: JSON.stringify(part.output ?? part.content ?? "");
|
||||
|
||||
if (pending) {
|
||||
const existingMsg = messages[messages.length - 1];
|
||||
if (existingMsg) {
|
||||
const tc = existingMsg.parts.find(
|
||||
(p) => p.type === "tool-call" && (p as { toolCallId: string }).toolCallId === toolCallId,
|
||||
);
|
||||
if (tc && tc.type === "tool-call") {
|
||||
(tc as { output?: string }).output = outputText.slice(0, 5000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
parts.push({ type: "tool-call", toolName: pending.toolName, toolCallId, args: pending.args, output: outputText.slice(0, 5000) });
|
||||
} else {
|
||||
parts.push({ type: "tool-call", toolName: "tool", toolCallId, output: outputText.slice(0, 5000) });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (typeof msg.content === "string" && msg.content.trim()) {
|
||||
parts.push({ type: "text", text: msg.content });
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
messages.push({
|
||||
id: entry.id ?? `msg-${messages.length}`,
|
||||
role,
|
||||
parts,
|
||||
timestamp: entry.timestamp ?? new Date(entry.ts ?? Date.now()).toISOString(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/** Extract text content from message parts. */
|
||||
function getMessageText(msg: ParsedMessage): string {
|
||||
return msg.parts
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Search session files for the cron run's conversation.
|
||||
* Matches by finding a user message containing the summary text near runAtMs,
|
||||
* then returns that message + all following messages until the next user message.
|
||||
*/
|
||||
function searchForRunTranscript(
|
||||
sessionFiles: string[],
|
||||
summary: string,
|
||||
runAtMs: number,
|
||||
): { messages: ParsedMessage[]; sessionFile: string } | null {
|
||||
// Use a distinctive portion of the summary for matching (first 80 chars)
|
||||
const searchText = summary.slice(0, 80);
|
||||
// Search window: from 5s before run to 10 minutes after (heartbeat delay)
|
||||
const afterMs = runAtMs - 5_000;
|
||||
const beforeMs = runAtMs + 10 * 60_000;
|
||||
|
||||
for (const filePath of sessionFiles) {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
if (!content.includes(searchText.slice(0, 40))) {
|
||||
// Quick pre-check: skip files that don't contain the text at all
|
||||
continue;
|
||||
}
|
||||
|
||||
const allMessages = parseMessagesInRange(content);
|
||||
|
||||
// Find user messages containing the summary text within the time window
|
||||
for (let i = 0; i < allMessages.length; i++) {
|
||||
const msg = allMessages[i];
|
||||
if (msg.role !== "user") {continue;}
|
||||
|
||||
const msgTs = new Date(msg.timestamp).getTime();
|
||||
if (msgTs < afterMs || msgTs > beforeMs) {continue;}
|
||||
|
||||
const text = getMessageText(msg);
|
||||
if (!text.includes(searchText.slice(0, 40))) {continue;}
|
||||
|
||||
// Found the user message! Collect it + all following messages
|
||||
// until the next user message (the full agent turn).
|
||||
const conversation: ParsedMessage[] = [msg];
|
||||
for (let j = i + 1; j < allMessages.length; j++) {
|
||||
const next = allMessages[j];
|
||||
if (next.role === "user") {break;}
|
||||
conversation.push(next);
|
||||
}
|
||||
|
||||
return { messages: conversation, sessionFile: filePath };
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cron/runs/search-transcript?jobId=X&runAtMs=Y&summary=Z
|
||||
*
|
||||
* Search for the actual agent transcript for a cron run that doesn't have
|
||||
* a direct sessionId. Tries:
|
||||
* 1. Sessions.json lookup for a cron-specific session
|
||||
* 2. Time-based search of session files near the run timestamp
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const jobId = url.searchParams.get("jobId");
|
||||
const runAtMsStr = url.searchParams.get("runAtMs");
|
||||
const summary = url.searchParams.get("summary");
|
||||
|
||||
if (!jobId || !runAtMsStr) {
|
||||
return Response.json({ error: "jobId and runAtMs are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const runAtMs = Number(runAtMsStr);
|
||||
if (!Number.isFinite(runAtMs)) {
|
||||
return Response.json({ error: "Invalid runAtMs" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Strategy 1: Look for a cron-specific session in sessions.json
|
||||
const cronSessionId = findCronSessionId(jobId);
|
||||
if (cronSessionId) {
|
||||
try {
|
||||
const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true });
|
||||
for (const agentDir of agentDirs) {
|
||||
if (!agentDir.isDirectory()) {continue;}
|
||||
const sessionFile = join(AGENTS_DIR, agentDir.name, "sessions", `${cronSessionId}.jsonl`);
|
||||
if (!existsSync(sessionFile)) {continue;}
|
||||
|
||||
const content = readFileSync(sessionFile, "utf-8");
|
||||
const messages = parseMessagesInRange(content);
|
||||
if (messages.length > 0) {
|
||||
return Response.json({
|
||||
sessionId: cronSessionId,
|
||||
messages,
|
||||
source: "cron-session",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to search
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Search session files near the run timestamp
|
||||
if (summary) {
|
||||
const candidates = findCandidateSessionFiles(runAtMs);
|
||||
const result = searchForRunTranscript(candidates, summary, runAtMs);
|
||||
if (result) {
|
||||
return Response.json({
|
||||
messages: result.messages,
|
||||
source: "main-session-search",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: "Transcript not found" }, { status: 404 });
|
||||
}
|
||||
66
apps/web/app/api/memories/route.ts
Normal file
66
apps/web/app/api/memories/route.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type MemoryFile = {
|
||||
name: string;
|
||||
path: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const workspaceDir = resolveWorkspaceRoot() ?? join(stateDir, "workspace");
|
||||
let mainMemory: string | null = null;
|
||||
const dailyLogs: MemoryFile[] = [];
|
||||
|
||||
// Read main MEMORY.md
|
||||
for (const filename of ["MEMORY.md", "memory.md"]) {
|
||||
const memPath = join(workspaceDir, filename);
|
||||
if (existsSync(memPath)) {
|
||||
try {
|
||||
mainMemory = readFileSync(memPath, "utf-8");
|
||||
} catch {
|
||||
// skip unreadable
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan daily log files
|
||||
const memoryDir = join(workspaceDir, "memory");
|
||||
if (existsSync(memoryDir)) {
|
||||
try {
|
||||
const entries = readdirSync(memoryDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = join(memoryDir, entry.name);
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
dailyLogs.push({
|
||||
name: entry.name,
|
||||
path: filePath,
|
||||
sizeBytes: Buffer.byteLength(content, "utf-8"),
|
||||
});
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// dir unreadable
|
||||
}
|
||||
}
|
||||
|
||||
// Sort daily logs by name (date-based filenames sort chronologically)
|
||||
dailyLogs.sort((a, b) => b.name.localeCompare(a.name));
|
||||
|
||||
return Response.json({
|
||||
mainMemory,
|
||||
dailyLogs,
|
||||
workspaceDir,
|
||||
});
|
||||
}
|
||||
212
apps/web/app/api/profiles/route.test.ts
Normal file
212
apps/web/app/api/profiles/route.test.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { Dirent } from "node:fs";
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => ""),
|
||||
exec: vi.fn(
|
||||
(
|
||||
_cmd: string,
|
||||
_opts: unknown,
|
||||
cb: (err: Error | null, result: { stdout: string }) => void,
|
||||
) => {
|
||||
cb(null, { stdout: "" });
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
|
||||
import { join } from "node:path";
|
||||
|
||||
function makeDirent(name: string, isDir: boolean): Dirent {
|
||||
return {
|
||||
name,
|
||||
isDirectory: () => isDir,
|
||||
isFile: () => !isDir,
|
||||
isBlockDevice: () => false,
|
||||
isCharacterDevice: () => false,
|
||||
isFIFO: () => false,
|
||||
isSocket: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
path: "",
|
||||
parentPath: "",
|
||||
} as Dirent;
|
||||
}
|
||||
|
||||
describe("profiles API", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const STATE_DIR = join("/home/testuser", ".openclaw");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.OPENCLAW_PROFILE;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
delete process.env.OPENCLAW_WORKSPACE;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => ""),
|
||||
exec: vi.fn(
|
||||
(
|
||||
_cmd: string,
|
||||
_opts: unknown,
|
||||
cb: (err: Error | null, result: { stdout: string }) => void,
|
||||
) => {
|
||||
cb(null, { stdout: "" });
|
||||
},
|
||||
),
|
||||
}));
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
// ─── GET /api/profiles ────────────────────────────────────────────
|
||||
|
||||
describe("GET /api/profiles", () => {
|
||||
async function callGet() {
|
||||
const { GET } = await import("./route.js");
|
||||
return GET();
|
||||
}
|
||||
|
||||
it("returns profiles list with default profile", async () => {
|
||||
const response = await callGet();
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.profiles).toBeDefined();
|
||||
expect(json.profiles.length).toBeGreaterThanOrEqual(1);
|
||||
expect(json.profiles[0].name).toBe("default");
|
||||
});
|
||||
|
||||
it("returns activeProfile", async () => {
|
||||
const response = await callGet();
|
||||
const json = await response.json();
|
||||
expect(json.activeProfile).toBe("default");
|
||||
});
|
||||
|
||||
it("returns stateDir", async () => {
|
||||
const response = await callGet();
|
||||
const json = await response.json();
|
||||
expect(json.stateDir).toBe(STATE_DIR);
|
||||
});
|
||||
|
||||
it("discovers workspace-<name> directories", async () => {
|
||||
const { existsSync: es, readdirSync: rds } = await import("node:fs");
|
||||
vi.mocked(es).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return (
|
||||
s === STATE_DIR ||
|
||||
s === join(STATE_DIR, "workspace-dev")
|
||||
);
|
||||
});
|
||||
vi.mocked(rds).mockReturnValue([
|
||||
makeDirent("workspace-dev", true),
|
||||
] as unknown as Dirent[]);
|
||||
|
||||
const response = await callGet();
|
||||
const json = await response.json();
|
||||
const names = json.profiles.map((p: { name: string }) => p.name);
|
||||
expect(names).toContain("dev");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/profiles/switch ────────────────────────────────────
|
||||
|
||||
describe("POST /api/profiles/switch", () => {
|
||||
async function callSwitch(body: Record<string, unknown>) {
|
||||
const { POST } = await import("./switch/route.js");
|
||||
const req = new Request("http://localhost/api/profiles/switch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return POST(req);
|
||||
}
|
||||
|
||||
it("switches to named profile", async () => {
|
||||
const { writeFileSync: wfs } = await import("node:fs");
|
||||
const { existsSync: es } = await import("node:fs");
|
||||
vi.mocked(es).mockReturnValue(true);
|
||||
|
||||
const response = await callSwitch({ profile: "work" });
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.activeProfile).toBe("work");
|
||||
|
||||
const writeCalls = vi.mocked(wfs).mock.calls;
|
||||
const stateWrite = writeCalls.find((c) =>
|
||||
(c[0] as string).includes(".ironclaw-ui-state.json"),
|
||||
);
|
||||
expect(stateWrite).toBeDefined();
|
||||
});
|
||||
|
||||
it("'default' clears the override", async () => {
|
||||
const { existsSync: es } = await import("node:fs");
|
||||
vi.mocked(es).mockReturnValue(true);
|
||||
|
||||
const response = await callSwitch({ profile: "default" });
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.activeProfile).toBe("default");
|
||||
});
|
||||
|
||||
it("rejects missing profile name", async () => {
|
||||
const response = await callSwitch({});
|
||||
expect(response.status).toBe(400);
|
||||
const json = await response.json();
|
||||
expect(json.error).toContain("Missing profile name");
|
||||
});
|
||||
|
||||
it("rejects invalid profile name characters", async () => {
|
||||
const response = await callSwitch({ profile: "bad name!" });
|
||||
expect(response.status).toBe(400);
|
||||
const json = await response.json();
|
||||
expect(json.error).toContain("Invalid profile name");
|
||||
});
|
||||
|
||||
it("returns workspace root after switching", async () => {
|
||||
const { existsSync: es } = await import("node:fs");
|
||||
const wsDir = join(STATE_DIR, "workspace-dev");
|
||||
vi.mocked(es).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === wsDir || s.includes(".openclaw");
|
||||
});
|
||||
|
||||
const response = await callSwitch({ profile: "dev" });
|
||||
const json = await response.json();
|
||||
expect(json.workspaceRoot).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns stateDir in response", async () => {
|
||||
const { existsSync: es } = await import("node:fs");
|
||||
vi.mocked(es).mockReturnValue(true);
|
||||
|
||||
const response = await callSwitch({ profile: "test" });
|
||||
const json = await response.json();
|
||||
expect(json.stateDir).toBe(STATE_DIR);
|
||||
});
|
||||
});
|
||||
});
|
||||
16
apps/web/app/api/profiles/route.ts
Normal file
16
apps/web/app/api/profiles/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { discoverProfiles, getEffectiveProfile, resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const profiles = discoverProfiles();
|
||||
const activeProfile = getEffectiveProfile();
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
|
||||
return Response.json({
|
||||
profiles,
|
||||
activeProfile: activeProfile || "default",
|
||||
stateDir,
|
||||
});
|
||||
}
|
||||
34
apps/web/app/api/profiles/switch/route.ts
Normal file
34
apps/web/app/api/profiles/switch/route.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = (await req.json()) as { profile?: string };
|
||||
const profileName = body.profile?.trim();
|
||||
|
||||
if (!profileName) {
|
||||
return Response.json({ error: "Missing profile name" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate profile name: letters, numbers, hyphens, underscores only
|
||||
if (profileName !== "default" && !/^[a-zA-Z0-9_-]+$/.test(profileName)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid profile name. Use letters, numbers, hyphens, or underscores." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// "default" clears the override
|
||||
setUIActiveProfile(profileName === "default" ? null : profileName);
|
||||
|
||||
const activeProfile = getEffectiveProfile();
|
||||
const workspaceRoot = resolveWorkspaceRoot();
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
|
||||
return Response.json({
|
||||
activeProfile: activeProfile || "default",
|
||||
workspaceRoot,
|
||||
stateDir,
|
||||
});
|
||||
}
|
||||
129
apps/web/app/api/sessions/[sessionId]/route.ts
Normal file
129
apps/web/app/api/sessions/[sessionId]/route.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type JSONLMessage = {
|
||||
type: string;
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
timestamp: string;
|
||||
message?: {
|
||||
role: "user" | "assistant";
|
||||
content: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; data: string }
|
||||
| { type: "thinking"; thinking: string; thinkingSignature?: string }
|
||||
>;
|
||||
timestamp?: number;
|
||||
};
|
||||
customType?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
function findSessionFile(sessionId: string): string | null {
|
||||
const openclawDir = resolveOpenClawStateDir();
|
||||
const agentsDir = join(openclawDir, "agents");
|
||||
|
||||
if (!existsSync(agentsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const agentDirs = readdirSync(agentsDir, { withFileTypes: true });
|
||||
for (const agentDir of agentDirs) {
|
||||
if (!agentDir.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionFile = join(
|
||||
agentsDir,
|
||||
agentDir.name,
|
||||
"sessions",
|
||||
`${sessionId}.jsonl`
|
||||
);
|
||||
|
||||
if (existsSync(sessionFile)) {
|
||||
return sessionFile;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const { sessionId } = await params;
|
||||
|
||||
if (!sessionId) {
|
||||
return Response.json({ error: "Session ID required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const sessionFile = findSessionFile(sessionId);
|
||||
|
||||
if (!sessionFile) {
|
||||
return Response.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(sessionFile, "utf-8");
|
||||
const lines = content
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
|
||||
const messages: Array<{
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}> = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as JSONLMessage;
|
||||
|
||||
if (entry.type === "message" && entry.message) {
|
||||
// Extract text content from the message
|
||||
const textContent = entry.message.content
|
||||
.filter((part) => part.type === "text" || part.type === "thinking")
|
||||
.map((part) => {
|
||||
if (part.type === "text") {
|
||||
return part.text;
|
||||
}
|
||||
if (part.type === "thinking") {
|
||||
return `[Thinking: ${part.thinking.slice(0, 100)}...]`;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
if (textContent) {
|
||||
messages.push({
|
||||
id: entry.id,
|
||||
role: entry.message.role,
|
||||
content: textContent,
|
||||
timestamp: entry.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ sessionId, messages });
|
||||
} catch (error) {
|
||||
console.error("Error reading session:", error);
|
||||
return Response.json(
|
||||
{ error: "Failed to read session" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
98
apps/web/app/api/sessions/route.ts
Normal file
98
apps/web/app/api/sessions/route.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SessionEntry = {
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
channel?: string;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
thinkingLevel?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
contextTokens?: number;
|
||||
compactionCount?: number;
|
||||
};
|
||||
|
||||
type SessionRow = {
|
||||
key: string;
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
channel?: string;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
thinkingLevel?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
contextTokens?: number;
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const openclawDir = resolveOpenClawStateDir();
|
||||
const agentsDir = join(openclawDir, "agents");
|
||||
|
||||
if (!existsSync(agentsDir)) {
|
||||
return Response.json({ agents: [], sessions: [] });
|
||||
}
|
||||
|
||||
const allSessions: SessionRow[] = [];
|
||||
const agentIds: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = readdirSync(agentsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
agentIds.push(entry.name);
|
||||
|
||||
const storePath = join(agentsDir, entry.name, "sessions", "sessions.json");
|
||||
if (!existsSync(storePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(storePath, "utf-8");
|
||||
const store = JSON.parse(raw) as Record<string, SessionEntry>;
|
||||
for (const [key, session] of Object.entries(store)) {
|
||||
if (!session || typeof session !== "object") {
|
||||
continue;
|
||||
}
|
||||
allSessions.push({
|
||||
key,
|
||||
sessionId: session.sessionId,
|
||||
updatedAt: session.updatedAt,
|
||||
label: session.label,
|
||||
displayName: session.displayName,
|
||||
channel: session.channel,
|
||||
model: session.model,
|
||||
modelProvider: session.modelProvider,
|
||||
thinkingLevel: session.thinkingLevel,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalTokens: session.totalTokens,
|
||||
contextTokens: session.contextTokens,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable store files
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// agents dir unreadable
|
||||
}
|
||||
|
||||
// Sort by updatedAt descending
|
||||
allSessions.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
|
||||
return Response.json({ agents: agentIds, sessions: allSessions });
|
||||
}
|
||||
144
apps/web/app/api/sessions/sessions.test.ts
Normal file
144
apps/web/app/api/sessions/sessions.test.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
|
||||
}));
|
||||
|
||||
// Mock node:os
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
|
||||
describe("Sessions, Memories & Skills API", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
|
||||
}));
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── GET /api/sessions ──────────────────────────────────────────
|
||||
|
||||
describe("GET /api/sessions", () => {
|
||||
it("returns empty agents and sessions when no dir exists", async () => {
|
||||
const { GET } = await import("./route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.agents).toEqual([]);
|
||||
expect(json.sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns sessions from agent directories", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, readdirSync: mockReaddir } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockImplementation((dir) => {
|
||||
const s = String(dir);
|
||||
if (s.endsWith("agents")) {return ["main" as never];}
|
||||
if (s.endsWith("sessions")) {return ["sessions.json" as never];}
|
||||
return [];
|
||||
});
|
||||
const sessionsData = {
|
||||
"s1": { label: "Chat 1", displayName: "Chat 1", channel: "webchat", updatedAt: Date.now() },
|
||||
};
|
||||
vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(sessionsData) as never);
|
||||
|
||||
const { GET } = await import("./route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.sessions.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/sessions/[sessionId] ──────────────────────────────
|
||||
|
||||
describe("GET /api/sessions/[sessionId]", () => {
|
||||
it("returns 404 when session not found", async () => {
|
||||
const { GET } = await import("./[sessionId]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost"),
|
||||
{ params: Promise.resolve({ sessionId: "nonexistent" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent session ID", async () => {
|
||||
const { GET } = await import("./[sessionId]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost"),
|
||||
{ params: Promise.resolve({ sessionId: "missing-id" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/memories ──────────────────────────────────────────
|
||||
|
||||
describe("GET /api/memories", () => {
|
||||
it("returns null mainMemory when no memory file exists", async () => {
|
||||
const { existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(false);
|
||||
|
||||
const { GET } = await import("../memories/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.mainMemory).toBeNull();
|
||||
});
|
||||
|
||||
it("returns memory content when file exists", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, readdirSync: mockReaddir } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith("MEMORY.md") || s.endsWith("memory.md")) {return true;}
|
||||
return false;
|
||||
});
|
||||
vi.mocked(mockReadFile).mockReturnValue("# My memories\n\n- Remember X" as never);
|
||||
vi.mocked(mockReaddir).mockReturnValue([]);
|
||||
|
||||
const { GET } = await import("../memories/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.mainMemory).toContain("memories");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/skills ────────────────────────────────────────────
|
||||
|
||||
describe("GET /api/skills", () => {
|
||||
it("returns empty skills when no skills directories exist", async () => {
|
||||
const { GET } = await import("../skills/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.skills).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns skills from directory", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, readdirSync: mockReaddir } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockImplementation((dir) => {
|
||||
const s = String(dir);
|
||||
if (s.endsWith("skills")) {return ["my-skill" as never];}
|
||||
return [];
|
||||
});
|
||||
vi.mocked(mockReadFile).mockReturnValue("---\nname: My Skill\n---\n# Skill content" as never);
|
||||
|
||||
const { GET } = await import("../skills/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.skills.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
85
apps/web/app/api/skills/route.ts
Normal file
85
apps/web/app/api/skills/route.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SkillEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
emoji?: string;
|
||||
source: string;
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
/** Parse YAML frontmatter from a SKILL.md file (lightweight, no deps). */
|
||||
function parseSkillFrontmatter(content: string): {
|
||||
name?: string;
|
||||
description?: string;
|
||||
emoji?: string;
|
||||
} {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!match) return {};
|
||||
|
||||
const yaml = match[1];
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of yaml.split("\n")) {
|
||||
const kv = line.match(/^(\w+)\s*:\s*(.+)/);
|
||||
if (kv) {
|
||||
result[kv[1]] = kv[2].replace(/^["']|["']$/g, "").trim();
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
emoji: result.emoji,
|
||||
};
|
||||
}
|
||||
|
||||
function scanSkillDir(dir: string, source: string): SkillEntry[] {
|
||||
const skills: SkillEntry[] = [];
|
||||
if (!existsSync(dir)) return skills;
|
||||
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillMdPath = join(dir, entry.name, "SKILL.md");
|
||||
if (!existsSync(skillMdPath)) continue;
|
||||
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, "utf-8");
|
||||
const meta = parseSkillFrontmatter(content);
|
||||
skills.push({
|
||||
name: meta.name ?? entry.name,
|
||||
description: meta.description ?? "",
|
||||
emoji: meta.emoji,
|
||||
source,
|
||||
filePath: skillMdPath,
|
||||
});
|
||||
} catch {
|
||||
// skip unreadable skill files
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// dir unreadable
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const workspaceRoot = resolveWorkspaceRoot() ?? join(stateDir, "workspace");
|
||||
|
||||
const managedSkills = scanSkillDir(join(stateDir, "skills"), "managed");
|
||||
const workspaceSkills = scanSkillDir(
|
||||
join(workspaceRoot, "skills"),
|
||||
"workspace",
|
||||
);
|
||||
|
||||
const allSkills = [...workspaceSkills, ...managedSkills];
|
||||
allSkills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return Response.json({ skills: allSkills });
|
||||
}
|
||||
103
apps/web/app/api/web-sessions/[id]/messages/route.ts
Normal file
103
apps/web/app/api/web-sessions/[id]/messages/route.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveWebChatDir } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type IndexEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/web-sessions/[id]/messages — append or upsert messages.
|
||||
*
|
||||
* Uses upsert semantics: if a message with the same `id` already exists
|
||||
* in the session JSONL, it is replaced in-place. Otherwise the message
|
||||
* is appended. This supports both the client's post-stream save and the
|
||||
* server-side incremental persistence from the ActiveRunManager.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const chatDir = resolveWebChatDir();
|
||||
const filePath = join(chatDir, `${id}.jsonl`);
|
||||
const indexPath = join(chatDir, "index.json");
|
||||
|
||||
// Auto-create the session directory if it doesn't exist yet
|
||||
if (!existsSync(chatDir)) {
|
||||
mkdirSync(chatDir, { recursive: true });
|
||||
}
|
||||
if (!existsSync(filePath)) {
|
||||
writeFileSync(filePath, "");
|
||||
}
|
||||
|
||||
const { messages, title } = await request.json();
|
||||
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return Response.json({ error: "messages array required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Read existing lines for upsert checks.
|
||||
const existing = readFileSync(filePath, "utf-8");
|
||||
const lines = existing.split("\n").filter((l) => l.trim());
|
||||
let newCount = 0;
|
||||
|
||||
for (const msg of messages) {
|
||||
const msgId = typeof msg.id === "string" ? msg.id : undefined;
|
||||
let found = false;
|
||||
|
||||
if (msgId) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
try {
|
||||
const parsed = JSON.parse(lines[i]);
|
||||
if (parsed.id === msgId) {
|
||||
// Replace the existing line in-place.
|
||||
lines[i] = JSON.stringify(msg);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* keep malformed lines as-is */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
lines.push(JSON.stringify(msg));
|
||||
newCount++;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(filePath, lines.join("\n") + "\n");
|
||||
|
||||
// Update index metadata
|
||||
try {
|
||||
if (existsSync(indexPath)) {
|
||||
const index: IndexEntry[] = JSON.parse(
|
||||
readFileSync(indexPath, "utf-8"),
|
||||
);
|
||||
const session = index.find((s) => s.id === id);
|
||||
if (session) {
|
||||
session.updatedAt = Date.now();
|
||||
if (newCount > 0) {session.messageCount += newCount;}
|
||||
if (title) {session.title = title;}
|
||||
writeFileSync(indexPath, JSON.stringify(index, null, 2));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// index update is best-effort
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
94
apps/web/app/api/web-sessions/[id]/route.ts
Normal file
94
apps/web/app/api/web-sessions/[id]/route.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { readFileSync, existsSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveWebChatDir } from "@/lib/workspace";
|
||||
import { readIndex, writeIndex } from "../shared";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export type ChatLine = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
/** Plain text summary (always present, used for sidebar / backward compat). */
|
||||
content: string;
|
||||
/** Full UIMessage parts array — reasoning, tool calls, outputs, text.
|
||||
* Present for sessions saved after the rich-persistence update;
|
||||
* absent for older sessions (fall back to `content` as a text part). */
|
||||
parts?: Array<Record<string, unknown>>;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
/** GET /api/web-sessions/[id] — read all messages for a web chat session */
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const filePath = join(resolveWebChatDir(), `${id}.jsonl`);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return Response.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const messages: ChatLine[] = content
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as ChatLine;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((m): m is ChatLine => m !== null);
|
||||
|
||||
return Response.json({ id, messages });
|
||||
}
|
||||
|
||||
/** DELETE /api/web-sessions/[id] — delete a web chat session */
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const sessions = readIndex();
|
||||
const idx = sessions.findIndex((s) => s.id === id);
|
||||
if (idx === -1) {
|
||||
return Response.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
sessions.splice(idx, 1);
|
||||
writeIndex(sessions);
|
||||
|
||||
const filePath = join(resolveWebChatDir(), `${id}.jsonl`);
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
/** PATCH /api/web-sessions/[id] — update session metadata (e.g. rename) */
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
|
||||
const sessions = readIndex();
|
||||
const session = sessions.find((s) => s.id === id);
|
||||
if (!session) {
|
||||
return Response.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (typeof body.title === "string") {
|
||||
session.title = body.title;
|
||||
}
|
||||
session.updatedAt = Date.now();
|
||||
writeIndex(sessions);
|
||||
|
||||
return Response.json({ session });
|
||||
}
|
||||
45
apps/web/app/api/web-sessions/route.ts
Normal file
45
apps/web/app/api/web-sessions/route.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { type WebSessionMeta, ensureDir, readIndex, writeIndex } from "./shared";
|
||||
|
||||
export { type WebSessionMeta };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** GET /api/web-sessions — list web chat sessions.
|
||||
* ?filePath=... → returns only sessions scoped to that file.
|
||||
* No filePath → returns only global (non-file) sessions. */
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const filePath = url.searchParams.get("filePath");
|
||||
|
||||
const all = readIndex();
|
||||
const sessions = filePath
|
||||
? all.filter((s) => s.filePath === filePath)
|
||||
: all.filter((s) => !s.filePath);
|
||||
|
||||
return Response.json({ sessions });
|
||||
}
|
||||
|
||||
/** POST /api/web-sessions — create a new web chat session */
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const id = randomUUID();
|
||||
const session: WebSessionMeta = {
|
||||
id,
|
||||
title: body.title || "New Chat",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messageCount: 0,
|
||||
...(body.filePath ? { filePath: body.filePath } : {}),
|
||||
};
|
||||
|
||||
const sessions = readIndex();
|
||||
sessions.unshift(session);
|
||||
writeIndex(sessions);
|
||||
|
||||
const dir = ensureDir();
|
||||
writeFileSync(`${dir}/${id}.jsonl`, "");
|
||||
|
||||
return Response.json({ session });
|
||||
}
|
||||
88
apps/web/app/api/web-sessions/shared.ts
Normal file
88
apps/web/app/api/web-sessions/shared.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveWebChatDir } from "@/lib/workspace";
|
||||
|
||||
export type WebSessionMeta = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
/** When set, this session is scoped to a specific workspace file. */
|
||||
filePath?: string;
|
||||
};
|
||||
|
||||
export function ensureDir() {
|
||||
const dir = resolveWebChatDir();
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the session index, auto-discovering any orphaned .jsonl files
|
||||
* that aren't in the index (e.g. from profile switches or missing index).
|
||||
*/
|
||||
export function readIndex(): WebSessionMeta[] {
|
||||
const dir = ensureDir();
|
||||
const indexFile = join(dir, "index.json");
|
||||
let index: WebSessionMeta[] = [];
|
||||
if (existsSync(indexFile)) {
|
||||
try {
|
||||
index = JSON.parse(readFileSync(indexFile, "utf-8"));
|
||||
} catch {
|
||||
index = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Scan for orphaned .jsonl files not in the index
|
||||
try {
|
||||
const indexed = new Set(index.map((s) => s.id));
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
||||
let dirty = false;
|
||||
for (const file of files) {
|
||||
const id = file.replace(/\.jsonl$/, "");
|
||||
if (indexed.has(id)) {continue;}
|
||||
|
||||
const fp = join(dir, file);
|
||||
const stat = statSync(fp);
|
||||
let title = "New Chat";
|
||||
let messageCount = 0;
|
||||
try {
|
||||
const content = readFileSync(fp, "utf-8");
|
||||
const lines = content.split("\n").filter((l) => l.trim());
|
||||
messageCount = lines.length;
|
||||
for (const line of lines) {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed.role === "user" && parsed.content) {
|
||||
const text = String(parsed.content);
|
||||
title = text.length > 60 ? text.slice(0, 60) + "..." : text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
|
||||
index.push({
|
||||
id,
|
||||
title,
|
||||
createdAt: stat.birthtimeMs || stat.mtimeMs,
|
||||
updatedAt: stat.mtimeMs,
|
||||
messageCount,
|
||||
});
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
index.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
writeFileSync(indexFile, JSON.stringify(index, null, 2));
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
export function writeIndex(sessions: WebSessionMeta[]) {
|
||||
const dir = ensureDir();
|
||||
writeFileSync(join(dir, "index.json"), JSON.stringify(sessions, null, 2));
|
||||
}
|
||||
304
apps/web/app/api/web-sessions/web-sessions.test.ts
Normal file
304
apps/web/app/api/web-sessions/web-sessions.test.ts
Normal file
@ -0,0 +1,304 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => "[]"),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
appendFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:os
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
|
||||
// Mock node:crypto
|
||||
vi.mock("node:crypto", () => ({
|
||||
randomUUID: vi.fn(() => "test-uuid-1234"),
|
||||
}));
|
||||
|
||||
describe("Web Sessions API", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => "[]"),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
appendFileSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
vi.mock("node:crypto", () => ({
|
||||
randomUUID: vi.fn(() => "test-uuid-1234"),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── GET /api/web-sessions ──────────────────────────────────────
|
||||
|
||||
describe("GET /api/web-sessions", () => {
|
||||
it("returns empty sessions when no index exists", async () => {
|
||||
const { GET } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions");
|
||||
const res = await GET(req);
|
||||
const json = await res.json();
|
||||
expect(json.sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns global sessions when no filePath param", async () => {
|
||||
const { readFileSync: mockReadFile, existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
const sessions = [
|
||||
{ id: "s1", title: "Chat 1", createdAt: 1, updatedAt: 1, messageCount: 0 },
|
||||
{ id: "s2", title: "File Chat", createdAt: 2, updatedAt: 2, messageCount: 1, filePath: "doc.md" },
|
||||
];
|
||||
vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(sessions) as never);
|
||||
|
||||
const { GET } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions");
|
||||
const res = await GET(req);
|
||||
const json = await res.json();
|
||||
expect(json.sessions).toHaveLength(1);
|
||||
expect(json.sessions[0].id).toBe("s1");
|
||||
});
|
||||
|
||||
it("filters sessions by filePath param", async () => {
|
||||
const { readFileSync: mockReadFile, existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
const sessions = [
|
||||
{ id: "s1", title: "Global", createdAt: 1, updatedAt: 1, messageCount: 0 },
|
||||
{ id: "s2", title: "Doc Chat", createdAt: 2, updatedAt: 2, messageCount: 1, filePath: "doc.md" },
|
||||
];
|
||||
vi.mocked(mockReadFile).mockReturnValue(JSON.stringify(sessions) as never);
|
||||
|
||||
const { GET } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions?filePath=doc.md");
|
||||
const res = await GET(req);
|
||||
const json = await res.json();
|
||||
expect(json.sessions).toHaveLength(1);
|
||||
expect(json.sessions[0].filePath).toBe("doc.md");
|
||||
});
|
||||
|
||||
it("returns empty when no matching filePath sessions", async () => {
|
||||
const { readFileSync: mockReadFile, existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReadFile).mockReturnValue("[]" as never);
|
||||
|
||||
const { GET } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions?filePath=nonexistent.md");
|
||||
const res = await GET(req);
|
||||
const json = await res.json();
|
||||
expect(json.sessions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/web-sessions ────────────────────────────────────
|
||||
|
||||
describe("POST /api/web-sessions", () => {
|
||||
it("creates a new session with default title", async () => {
|
||||
const { writeFileSync: mockWrite } = await import("node:fs");
|
||||
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
const json = await res.json();
|
||||
expect(json.session.id).toBe("test-uuid-1234");
|
||||
expect(json.session.title).toBe("New Chat");
|
||||
expect(json.session.messageCount).toBe(0);
|
||||
expect(mockWrite).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates session with custom title", async () => {
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "My Chat" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
const json = await res.json();
|
||||
expect(json.session.title).toBe("My Chat");
|
||||
});
|
||||
|
||||
it("creates file-scoped session with filePath", async () => {
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "File Chat", filePath: "readme.md" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
const json = await res.json();
|
||||
expect(json.session.filePath).toBe("readme.md");
|
||||
});
|
||||
|
||||
it("handles invalid JSON body gracefully", async () => {
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
const res = await POST(req);
|
||||
const json = await res.json();
|
||||
// Falls back to default title
|
||||
expect(json.session.title).toBe("New Chat");
|
||||
});
|
||||
|
||||
it("creates jsonl file for new session", async () => {
|
||||
const { writeFileSync: mockWrite } = await import("node:fs");
|
||||
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
await POST(req);
|
||||
// Should write at least the index.json and the empty .jsonl
|
||||
expect(mockWrite).toHaveBeenCalled();
|
||||
// Verify that one of the calls is to the jsonl file
|
||||
const calls = mockWrite.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((c) => c.endsWith(".jsonl"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/web-sessions/[id] ────────────────────────────────
|
||||
|
||||
describe("GET /api/web-sessions/[id]", () => {
|
||||
it("returns session messages", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
const lines = [
|
||||
JSON.stringify({ id: "m1", role: "user", content: "hello" }),
|
||||
JSON.stringify({ id: "m2", role: "assistant", content: "hi" }),
|
||||
].join("\n");
|
||||
vi.mocked(mockReadFile).mockReturnValue(lines as never);
|
||||
|
||||
const { GET } = await import("./[id]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/web-sessions/s1"),
|
||||
{ params: Promise.resolve({ id: "s1" }) },
|
||||
);
|
||||
const json = await res.json();
|
||||
expect(json.id).toBe("s1");
|
||||
expect(json.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns 404 when session file does not exist", async () => {
|
||||
const { existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(false);
|
||||
|
||||
const { GET } = await import("./[id]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/web-sessions/nonexistent"),
|
||||
{ params: Promise.resolve({ id: "nonexistent" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("handles empty session file", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReadFile).mockReturnValue("" as never);
|
||||
|
||||
const { GET } = await import("./[id]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/web-sessions/s1"),
|
||||
{ params: Promise.resolve({ id: "s1" }) },
|
||||
);
|
||||
const json = await res.json();
|
||||
expect(json.messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/web-sessions/[id]/messages ──────────────────────
|
||||
|
||||
describe("POST /api/web-sessions/[id]/messages", () => {
|
||||
it("appends messages to session file", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: _mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReadFile).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith("index.json")) {
|
||||
return JSON.stringify([{ id: "s1", title: "Chat", createdAt: 1, updatedAt: 1, messageCount: 0 }]) as never;
|
||||
}
|
||||
return "" as never;
|
||||
});
|
||||
|
||||
const { POST } = await import("./[id]/messages/route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions/s1/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [{ id: "m1", role: "user", content: "hello" }],
|
||||
}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ id: "s1" }) });
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-creates session file if missing", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: _mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith(".jsonl")) {return false;}
|
||||
return true;
|
||||
});
|
||||
vi.mocked(mockReadFile).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith("index.json")) {return "[]" as never;}
|
||||
return "" as never;
|
||||
});
|
||||
|
||||
const { POST } = await import("./[id]/messages/route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions/new-s/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [{ id: "m1", role: "user", content: "first message" }],
|
||||
}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ id: "new-s" }) });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("updates session title when provided", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReadFile).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith("index.json")) {
|
||||
return JSON.stringify([{ id: "s1", title: "Old Title", createdAt: 1, updatedAt: 1, messageCount: 0 }]) as never;
|
||||
}
|
||||
return "" as never;
|
||||
});
|
||||
|
||||
const { POST } = await import("./[id]/messages/route.js");
|
||||
const req = new Request("http://localhost/api/web-sessions/s1/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [{ id: "m1", role: "user", content: "hello" }],
|
||||
title: "New Title",
|
||||
}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ id: "s1" }) });
|
||||
expect(res.status).toBe(200);
|
||||
// Verify index was written with new title
|
||||
expect(mockWrite).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
57
apps/web/app/api/workspace/assets/[...path]/route.ts
Normal file
57
apps/web/app/api/workspace/assets/[...path]/route.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { extname } from "node:path";
|
||||
import { safeResolvePath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".bmp": "image/bmp",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/workspace/assets/<path>
|
||||
* Serves an image file from the workspace's assets/ directory.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const segments = (await params).path;
|
||||
if (!segments || segments.length === 0) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const relPath = "assets/" + segments.join("/");
|
||||
const ext = extname(relPath).toLowerCase();
|
||||
|
||||
// Only serve known image types
|
||||
const mime = MIME_MAP[ext];
|
||||
if (!mime) {
|
||||
return new Response("Unsupported file type", { status: 400 });
|
||||
}
|
||||
|
||||
const absPath = safeResolvePath(relPath);
|
||||
if (!absPath || !existsSync(absPath)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = readFileSync(absPath);
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": mime,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response("Read error", { status: 500 });
|
||||
}
|
||||
}
|
||||
109
apps/web/app/api/workspace/browse-file/route.ts
Normal file
109
apps/web/app/api/workspace/browse-file/route.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { readFileSync, existsSync, statSync } from "node:fs";
|
||||
import { resolve, normalize } from "node:path";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/** MIME types for common file extensions. */
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
pdf: "application/pdf",
|
||||
html: "text/html",
|
||||
htm: "text/html",
|
||||
};
|
||||
|
||||
/** Extensions recognized as code files for syntax-highlighted viewing. */
|
||||
const CODE_EXTENSIONS = new Set([
|
||||
"ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs",
|
||||
"java", "kt", "swift", "c", "cpp", "h", "hpp", "cs", "css", "scss",
|
||||
"less", "html", "htm", "xml", "json", "jsonc", "toml", "sh", "bash",
|
||||
"zsh", "fish", "ps1", "sql", "graphql", "gql", "dockerfile", "makefile",
|
||||
"r", "lua", "php", "vue", "svelte", "diff", "patch", "ini", "env",
|
||||
"tf", "proto", "zig", "elixir", "ex", "erl", "hs", "scala", "clj", "dart",
|
||||
]);
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const filePath = url.searchParams.get("path");
|
||||
const raw = url.searchParams.get("raw") === "true";
|
||||
|
||||
if (!filePath) {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' query parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize and resolve to prevent traversal
|
||||
const resolved = resolve(normalize(filePath));
|
||||
|
||||
if (!existsSync(resolved)) {
|
||||
return Response.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(resolved);
|
||||
if (!stat.isFile()) {
|
||||
return Response.json(
|
||||
{ error: "Path is not a file" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Cannot stat file" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Raw mode: return binary content with appropriate MIME type
|
||||
if (raw) {
|
||||
try {
|
||||
const buffer = readFileSync(resolved);
|
||||
const ext = resolved.split(".").pop()?.toLowerCase() ?? "";
|
||||
const mime = MIME_MAP[ext] ?? "application/octet-stream";
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": mime,
|
||||
"Content-Length": String(buffer.length),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Cannot read file" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Text mode: return content and type metadata (same shape as /api/workspace/file)
|
||||
try {
|
||||
const content = readFileSync(resolved, "utf-8");
|
||||
const ext = resolved.split(".").pop()?.toLowerCase();
|
||||
|
||||
let type: "markdown" | "yaml" | "code" | "text" = "text";
|
||||
if (ext === "md" || ext === "mdx") {type = "markdown";}
|
||||
else if (ext === "yaml" || ext === "yml") {type = "yaml";}
|
||||
else if (CODE_EXTENSIONS.has(ext ?? "")) {type = "code";}
|
||||
|
||||
return Response.json({ content, type });
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Cannot read file" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
134
apps/web/app/api/workspace/browse/route.ts
Normal file
134
apps/web/app/api/workspace/browse/route.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { readdirSync, statSync, type Dirent } from "node:fs";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type BrowseNode = {
|
||||
name: string;
|
||||
path: string; // absolute path
|
||||
type: "folder" | "file" | "document" | "database";
|
||||
children?: BrowseNode[];
|
||||
symlink?: boolean;
|
||||
};
|
||||
|
||||
/** Directories to skip when browsing the filesystem. */
|
||||
const SKIP_DIRS = new Set(["node_modules", ".git", ".Trash", "__pycache__", ".cache"]);
|
||||
|
||||
/** Resolve a dirent's effective type, following symlinks to their target. */
|
||||
function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null {
|
||||
if (entry.isDirectory()) {return "directory";}
|
||||
if (entry.isFile()) {return "file";}
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
const st = statSync(absPath);
|
||||
if (st.isDirectory()) {return "directory";}
|
||||
if (st.isFile()) {return "file";}
|
||||
} catch {
|
||||
// Broken symlink
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build a depth-limited tree from an absolute directory. */
|
||||
function buildBrowseTree(
|
||||
absDir: string,
|
||||
maxDepth: number,
|
||||
currentDepth = 0,
|
||||
showHidden = false,
|
||||
): BrowseNode[] {
|
||||
if (currentDepth >= maxDepth) {return [];}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(absDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filtered = entries
|
||||
.filter((e) => showHidden || !e.name.startsWith("."))
|
||||
.filter((e) => {
|
||||
const absPath = join(absDir, e.name);
|
||||
const t = resolveEntryType(e, absPath);
|
||||
return !(t === "directory" && SKIP_DIRS.has(e.name));
|
||||
});
|
||||
|
||||
const sorted = filtered.toSorted((a, b) => {
|
||||
const absA = join(absDir, a.name);
|
||||
const absB = join(absDir, b.name);
|
||||
const typeA = resolveEntryType(a, absA);
|
||||
const typeB = resolveEntryType(b, absB);
|
||||
const dirA = typeA === "directory";
|
||||
const dirB = typeB === "directory";
|
||||
if (dirA && !dirB) {return -1;}
|
||||
if (!dirA && dirB) {return 1;}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const nodes: BrowseNode[] = [];
|
||||
|
||||
for (const entry of sorted) {
|
||||
const absPath = join(absDir, entry.name);
|
||||
const isSymlink = entry.isSymbolicLink();
|
||||
const effectiveType = resolveEntryType(entry, absPath);
|
||||
|
||||
if (effectiveType === "directory") {
|
||||
const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1, showHidden);
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: absPath,
|
||||
type: "folder",
|
||||
children: children.length > 0 ? children : undefined,
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
} else if (effectiveType === "file") {
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
const isDatabase = ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
|
||||
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: absPath,
|
||||
type: isDatabase ? "database" : isDocument ? "document" : "file",
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
let dir = url.searchParams.get("dir");
|
||||
const showHidden = url.searchParams.get("showHidden") === "1";
|
||||
|
||||
if (!dir) {
|
||||
dir = resolveWorkspaceRoot();
|
||||
}
|
||||
|
||||
if (!dir) {
|
||||
return Response.json(
|
||||
{ entries: [], currentDir: "/", parentDir: null },
|
||||
);
|
||||
}
|
||||
|
||||
if (dir.startsWith("~")) {
|
||||
dir = join(homedir(), dir.slice(1));
|
||||
}
|
||||
|
||||
const resolved = resolve(dir);
|
||||
|
||||
const entries = buildBrowseTree(resolved, 3, 0, showHidden);
|
||||
const parentDir = resolved === "/" ? null : dirname(resolved);
|
||||
|
||||
return Response.json({
|
||||
entries,
|
||||
currentDir: resolved,
|
||||
parentDir,
|
||||
});
|
||||
}
|
||||
116
apps/web/app/api/workspace/context/route.ts
Normal file
116
apps/web/app/api/workspace/context/route.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export type WorkspaceContext = {
|
||||
exists: boolean;
|
||||
organization?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
};
|
||||
members?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}>;
|
||||
defaults?: {
|
||||
default_view?: string;
|
||||
date_format?: string;
|
||||
naming_convention?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse workspace_context.yaml with basic YAML extraction.
|
||||
* Handles the specific structure defined by the workspace skill.
|
||||
*/
|
||||
function parseWorkspaceContext(content: string): WorkspaceContext {
|
||||
const ctx: WorkspaceContext = { exists: true };
|
||||
|
||||
// Extract organization block
|
||||
const orgMatch = content.match(
|
||||
/organization:\s*\n((?:\s{2,}.+\n)*)/,
|
||||
);
|
||||
if (orgMatch) {
|
||||
const orgBlock = orgMatch[1];
|
||||
const org: Record<string, string> = {};
|
||||
for (const line of orgBlock.split("\n")) {
|
||||
const kv = line.match(/^\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
|
||||
if (kv) {org[kv[1]] = kv[2].trim();}
|
||||
}
|
||||
ctx.organization = {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract members list
|
||||
const membersMatch = content.match(
|
||||
/members:\s*\n((?:\s{2,}.+\n)*)/,
|
||||
);
|
||||
if (membersMatch) {
|
||||
const membersBlock = membersMatch[1];
|
||||
const members: WorkspaceContext["members"] = [];
|
||||
let current: Record<string, string> = {};
|
||||
|
||||
for (const line of membersBlock.split("\n")) {
|
||||
const itemStart = line.match(/^\s+-\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
|
||||
const propLine = line.match(/^\s+(\w+)\s*:\s*"?([^"\n]+)"?/);
|
||||
|
||||
if (itemStart) {
|
||||
if (current.id) {members.push(current as never);}
|
||||
current = { [itemStart[1]]: itemStart[2].trim() };
|
||||
} else if (propLine && !line.trim().startsWith("-")) {
|
||||
current[propLine[1]] = propLine[2].trim();
|
||||
}
|
||||
}
|
||||
if (current.id) {members.push(current as never);}
|
||||
ctx.members = members;
|
||||
}
|
||||
|
||||
// Extract defaults block
|
||||
const defaultsMatch = content.match(
|
||||
/defaults:\s*\n((?:\s{2,}.+\n)*)/,
|
||||
);
|
||||
if (defaultsMatch) {
|
||||
const defaultsBlock = defaultsMatch[1];
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const line of defaultsBlock.split("\n")) {
|
||||
const kv = line.match(/^\s+(\w[\w_]*)\s*:\s*(.+)/);
|
||||
if (kv) {defaults[kv[1]] = kv[2].trim();}
|
||||
}
|
||||
ctx.defaults = {
|
||||
default_view: defaults.default_view,
|
||||
date_format: defaults.date_format,
|
||||
naming_convention: defaults.naming_convention,
|
||||
};
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const root = resolveWorkspaceRoot();
|
||||
if (!root) {
|
||||
return Response.json({ exists: false } satisfies WorkspaceContext);
|
||||
}
|
||||
|
||||
const ctxPath = join(root, "workspace_context.yaml");
|
||||
if (!existsSync(ctxPath)) {
|
||||
return Response.json({ exists: true } satisfies WorkspaceContext);
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(ctxPath, "utf-8");
|
||||
const parsed = parseWorkspaceContext(content);
|
||||
return Response.json(parsed);
|
||||
} catch {
|
||||
return Response.json({ exists: true } satisfies WorkspaceContext);
|
||||
}
|
||||
}
|
||||
77
apps/web/app/api/workspace/copy/route.ts
Normal file
77
apps/web/app/api/workspace/copy/route.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { cpSync, existsSync, statSync } from "node:fs";
|
||||
import { dirname, basename, extname } from "node:path";
|
||||
import { safeResolvePath, safeResolveNewPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/workspace/copy
|
||||
* Body: { path: string, destinationPath?: string }
|
||||
*
|
||||
* Duplicates a file or folder. If no destinationPath is provided,
|
||||
* creates a copy next to the original with " copy" appended.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
let body: { path?: string; destinationPath?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: relPath, destinationPath } = body;
|
||||
if (!relPath || typeof relPath !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const srcAbs = safeResolvePath(relPath);
|
||||
if (!srcAbs) {
|
||||
return Response.json(
|
||||
{ error: "Source not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
let destRelPath: string;
|
||||
if (destinationPath && typeof destinationPath === "string") {
|
||||
destRelPath = destinationPath;
|
||||
} else {
|
||||
// Auto-generate "name copy.ext" or "name copy" for folders
|
||||
const name = basename(relPath);
|
||||
const dir = dirname(relPath);
|
||||
const ext = extname(name);
|
||||
const stem = ext ? name.slice(0, -ext.length) : name;
|
||||
const copyName = ext ? `${stem} copy${ext}` : `${stem} copy`;
|
||||
destRelPath = dir === "." ? copyName : `${dir}/${copyName}`;
|
||||
}
|
||||
|
||||
const destAbs = safeResolveNewPath(destRelPath);
|
||||
if (!destAbs) {
|
||||
return Response.json(
|
||||
{ error: "Invalid destination path" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(destAbs)) {
|
||||
return Response.json(
|
||||
{ error: "Destination already exists" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const isDir = statSync(srcAbs).isDirectory();
|
||||
cpSync(srcAbs, destAbs, { recursive: isDir });
|
||||
return Response.json({ ok: true, sourcePath: relPath, newPath: destRelPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Copy failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
273
apps/web/app/api/workspace/db.test.ts
Normal file
273
apps/web/app/api/workspace/db.test.ts
Normal file
@ -0,0 +1,273 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock workspace (include ALL exports used by the routes)
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
safeResolvePath: vi.fn(() => null),
|
||||
resolveWorkspaceRoot: vi.fn(() => null),
|
||||
resolveDuckdbBin: vi.fn(() => null),
|
||||
duckdbPath: vi.fn(() => null),
|
||||
duckdbQuery: vi.fn(() => []),
|
||||
duckdbQueryAsync: vi.fn(async () => []),
|
||||
duckdbQueryOnFile: vi.fn(() => []),
|
||||
duckdbQueryOnFileAsync: vi.fn(async () => []),
|
||||
duckdbExecOnFile: vi.fn(() => true),
|
||||
discoverDuckDBPaths: vi.fn(() => []),
|
||||
isDatabaseFile: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Mock report-filters
|
||||
vi.mock("@/lib/report-filters", () => ({
|
||||
buildFilterClauses: vi.fn(() => []),
|
||||
injectFilters: vi.fn((sql: string) => sql),
|
||||
checkSqlSafety: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
describe("Workspace DB & Reports API", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
safeResolvePath: vi.fn(() => null),
|
||||
resolveWorkspaceRoot: vi.fn(() => null),
|
||||
resolveDuckdbBin: vi.fn(() => null),
|
||||
duckdbPath: vi.fn(() => null),
|
||||
duckdbQuery: vi.fn(() => []),
|
||||
duckdbQueryAsync: vi.fn(async () => []),
|
||||
duckdbQueryOnFile: vi.fn(() => []),
|
||||
duckdbQueryOnFileAsync: vi.fn(async () => []),
|
||||
duckdbExecOnFile: vi.fn(() => true),
|
||||
discoverDuckDBPaths: vi.fn(() => []),
|
||||
isDatabaseFile: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("@/lib/report-filters", () => ({
|
||||
buildFilterClauses: vi.fn(() => []),
|
||||
injectFilters: vi.fn((sql: string) => sql),
|
||||
checkSqlSafety: vi.fn(() => null),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/db/query ───────────────────────────────
|
||||
|
||||
describe("POST /api/workspace/db/query", () => {
|
||||
it("returns 400 for missing sql", async () => {
|
||||
const { POST } = await import("./db/query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "test.duckdb" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for missing path", async () => {
|
||||
const { POST } = await import("./db/query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: "SELECT 1" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects mutation queries with 403", async () => {
|
||||
const { safeResolvePath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
|
||||
|
||||
const { POST } = await import("./db/query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "test.duckdb", sql: "DROP TABLE users" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("executes query and returns rows", async () => {
|
||||
const { safeResolvePath, duckdbQueryOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
|
||||
vi.mocked(duckdbQueryOnFile).mockReturnValue([{ id: 1, name: "test" }]);
|
||||
|
||||
const { POST } = await import("./db/query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "test.duckdb", sql: "SELECT * FROM t" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.rows).toEqual([{ id: 1, name: "test" }]);
|
||||
});
|
||||
|
||||
it("returns empty rows for empty result", async () => {
|
||||
const { safeResolvePath, duckdbQueryOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
|
||||
vi.mocked(duckdbQueryOnFile).mockReturnValue([]);
|
||||
|
||||
const { POST } = await import("./db/query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "test.duckdb", sql: "SELECT * FROM empty" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
const json = await res.json();
|
||||
expect(json.rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/db/introspect ───────────────────────────
|
||||
|
||||
describe("GET /api/workspace/db/introspect", () => {
|
||||
it("returns 400 for missing path", async () => {
|
||||
const { GET } = await import("./db/introspect/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/introspect");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when file not found", async () => {
|
||||
const { safeResolvePath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./db/introspect/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/introspect?path=missing.duckdb");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns schema when database exists", async () => {
|
||||
const { safeResolvePath, resolveDuckdbBin, duckdbQueryOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValue("/ws/test.duckdb");
|
||||
vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
|
||||
vi.mocked(duckdbQueryOnFile).mockReturnValue([
|
||||
{ table_name: "users", column_name: "id", data_type: "INTEGER", is_nullable: "NO" },
|
||||
]);
|
||||
|
||||
const { GET } = await import("./db/introspect/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/db/introspect?path=test.duckdb");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.tables).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/reports/execute ────────────────────────
|
||||
|
||||
describe("POST /api/workspace/reports/execute", () => {
|
||||
it("returns 400 for missing sql", async () => {
|
||||
const { POST } = await import("./reports/execute/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects mutation SQL with 403", async () => {
|
||||
const { checkSqlSafety } = await import("@/lib/report-filters");
|
||||
vi.mocked(checkSqlSafety).mockReturnValue("Only SELECT queries allowed");
|
||||
|
||||
const { POST } = await import("./reports/execute/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: "DROP TABLE users" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("executes report query successfully", async () => {
|
||||
const { checkSqlSafety } = await import("@/lib/report-filters");
|
||||
vi.mocked(checkSqlSafety).mockReturnValue(null);
|
||||
const { duckdbQuery } = await import("@/lib/workspace");
|
||||
vi.mocked(duckdbQuery).mockReturnValue([{ count: 42 }]);
|
||||
|
||||
const { POST } = await import("./reports/execute/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: "SELECT COUNT(*) as count FROM v_deals" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.rows).toEqual([{ count: 42 }]);
|
||||
});
|
||||
|
||||
it("applies filters to SQL", async () => {
|
||||
const { checkSqlSafety, buildFilterClauses, injectFilters } = await import("@/lib/report-filters");
|
||||
vi.mocked(checkSqlSafety).mockReturnValue(null);
|
||||
vi.mocked(buildFilterClauses).mockReturnValue(['"Status" = \'Active\'']);
|
||||
vi.mocked(injectFilters).mockReturnValue("SELECT * FROM filtered");
|
||||
const { duckdbQuery } = await import("@/lib/workspace");
|
||||
vi.mocked(duckdbQuery).mockReturnValue([{ count: 10 }]);
|
||||
|
||||
const { POST } = await import("./reports/execute/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sql: "SELECT * FROM v_deals",
|
||||
filters: [{ id: "s", column: "Status", value: { type: "select", value: "Active" } }],
|
||||
}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
expect(buildFilterClauses).toHaveBeenCalled();
|
||||
expect(injectFilters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/query ─────────────────────────────────
|
||||
|
||||
describe("POST /api/workspace/query", () => {
|
||||
it("returns 400 for missing sql", async () => {
|
||||
const { POST } = await import("./query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("executes query and returns rows", async () => {
|
||||
const { duckdbQuery } = await import("@/lib/workspace");
|
||||
vi.mocked(duckdbQuery).mockReturnValue([{ id: 1 }]);
|
||||
|
||||
const { POST } = await import("./query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: "SELECT 1 as id" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.rows).toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
it("rejects mutation SQL with 403", async () => {
|
||||
const { POST } = await import("./query/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: "DELETE FROM users" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
apps/web/app/api/workspace/db/introspect/route.ts
Normal file
98
apps/web/app/api/workspace/db/introspect/route.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { safeResolvePath, duckdbQueryOnFile, resolveDuckdbBin } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type TableInfo = {
|
||||
table_name: string;
|
||||
column_count: number;
|
||||
estimated_row_count: number;
|
||||
columns: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
is_nullable: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/workspace/db/introspect?path=<relative-path>
|
||||
*
|
||||
* Introspects a DuckDB / SQLite / generic DB file using the duckdb CLI.
|
||||
* Returns the list of tables with their columns and approximate row counts.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const relPath = searchParams.get("path");
|
||||
|
||||
if (!relPath) {
|
||||
return Response.json(
|
||||
{ error: "Missing required `path` query parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = safeResolvePath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "File not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if DuckDB CLI binary is available
|
||||
if (!resolveDuckdbBin()) {
|
||||
return Response.json({ tables: [], path: relPath, duckdb_available: false });
|
||||
}
|
||||
|
||||
// Get all user tables (skip internal DuckDB catalogs)
|
||||
const rawTables = duckdbQueryOnFile<{
|
||||
table_name: string;
|
||||
table_type: string;
|
||||
}>(
|
||||
absPath,
|
||||
"SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name",
|
||||
);
|
||||
|
||||
if (rawTables.length === 0) {
|
||||
return Response.json({ tables: [], path: relPath });
|
||||
}
|
||||
|
||||
const tables: TableInfo[] = [];
|
||||
|
||||
for (const t of rawTables) {
|
||||
// Fetch columns for this table
|
||||
const cols = duckdbQueryOnFile<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}>(
|
||||
absPath,
|
||||
`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = 'main' AND table_name = '${t.table_name.replace(/'/g, "''")}' ORDER BY ordinal_position`,
|
||||
);
|
||||
|
||||
// Get approximate row count
|
||||
let rowCount = 0;
|
||||
try {
|
||||
const countResult = duckdbQueryOnFile<{ cnt: number }>(
|
||||
absPath,
|
||||
`SELECT count(*) as cnt FROM "${t.table_name.replace(/"/g, '""')}"`,
|
||||
);
|
||||
rowCount = countResult[0]?.cnt ?? 0;
|
||||
} catch {
|
||||
// skip if we can't count
|
||||
}
|
||||
|
||||
tables.push({
|
||||
table_name: t.table_name,
|
||||
column_count: cols.length,
|
||||
estimated_row_count: rowCount,
|
||||
columns: cols.map((c) => ({
|
||||
name: c.column_name,
|
||||
type: c.data_type,
|
||||
is_nullable: c.is_nullable === "YES",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({ tables, path: relPath });
|
||||
}
|
||||
56
apps/web/app/api/workspace/db/query/route.ts
Normal file
56
apps/web/app/api/workspace/db/query/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { safeResolvePath, duckdbQueryOnFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/workspace/db/query
|
||||
* Body: { path: string, sql: string }
|
||||
*
|
||||
* Executes a read-only SQL query against a database file and returns JSON rows.
|
||||
* Only SELECT statements are allowed for safety.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
let body: { path?: string; sql?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: relPath, sql } = body;
|
||||
|
||||
if (!relPath || !sql) {
|
||||
return Response.json(
|
||||
{ error: "Missing required `path` and `sql` fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Basic safety: only allow SELECT-like statements
|
||||
const trimmedSql = sql.trim().toUpperCase();
|
||||
if (
|
||||
!trimmedSql.startsWith("SELECT") &&
|
||||
!trimmedSql.startsWith("PRAGMA") &&
|
||||
!trimmedSql.startsWith("DESCRIBE") &&
|
||||
!trimmedSql.startsWith("SHOW") &&
|
||||
!trimmedSql.startsWith("EXPLAIN") &&
|
||||
!trimmedSql.startsWith("WITH")
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "Only read-only queries (SELECT, DESCRIBE, SHOW, EXPLAIN, WITH) are allowed" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = safeResolvePath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "File not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = duckdbQueryOnFile(absPath, sql);
|
||||
return Response.json({ rows, sql });
|
||||
}
|
||||
292
apps/web/app/api/workspace/file-ops.test.ts
Normal file
292
apps/web/app/api/workspace/file-ops.test.ts
Normal file
@ -0,0 +1,292 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock("node:fs", () => ({
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => false })),
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
renameSync: vi.fn(),
|
||||
cpSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock workspace utilities
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
readWorkspaceFile: vi.fn(),
|
||||
safeResolvePath: vi.fn(),
|
||||
safeResolveNewPath: vi.fn(),
|
||||
isSystemFile: vi.fn(() => false),
|
||||
resolveWorkspaceRoot: vi.fn(() => "/ws"),
|
||||
}));
|
||||
|
||||
describe("Workspace File Operations API", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("node:fs", () => ({
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => false })),
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
renameSync: vi.fn(),
|
||||
cpSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
readWorkspaceFile: vi.fn(),
|
||||
safeResolvePath: vi.fn(),
|
||||
safeResolveNewPath: vi.fn(),
|
||||
isSystemFile: vi.fn(() => false),
|
||||
resolveWorkspaceRoot: vi.fn(() => "/ws"),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/file ────────────────────────────────────
|
||||
|
||||
describe("GET /api/workspace/file", () => {
|
||||
it("returns 400 when path param is missing", async () => {
|
||||
const { GET } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(400);
|
||||
const json = await res.json();
|
||||
expect(json.error).toContain("path");
|
||||
});
|
||||
|
||||
it("returns file content when found", async () => {
|
||||
const { readWorkspaceFile } = await import("@/lib/workspace");
|
||||
vi.mocked(readWorkspaceFile).mockReturnValue({ content: "# Hello", type: "markdown" });
|
||||
|
||||
const { GET } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file?path=doc.md");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.content).toBe("# Hello");
|
||||
expect(json.type).toBe("markdown");
|
||||
});
|
||||
|
||||
it("returns 404 when file not found", async () => {
|
||||
const { readWorkspaceFile } = await import("@/lib/workspace");
|
||||
vi.mocked(readWorkspaceFile).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file?path=missing.md");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/file ───────────────────────────────────
|
||||
|
||||
describe("POST /api/workspace/file", () => {
|
||||
it("writes file content successfully", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue("/ws/doc.md");
|
||||
const { writeFileSync: mockWrite, mkdirSync: mockMkdir } = await import("node:fs");
|
||||
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "doc.md", content: "# Hello" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
expect(mockMkdir).toHaveBeenCalled();
|
||||
expect(mockWrite).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for missing path", async () => {
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "text" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for missing content", async () => {
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "doc.md" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for path traversal", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue(null);
|
||||
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "../etc/passwd", content: "hack" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid JSON body", async () => {
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 500 on write error", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue("/ws/doc.md");
|
||||
const { writeFileSync: mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockWrite).mockImplementation(() => { throw new Error("EACCES"); });
|
||||
|
||||
const { POST } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "doc.md", content: "text" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /api/workspace/file ─────────────────────────────────
|
||||
|
||||
describe("DELETE /api/workspace/file", () => {
|
||||
it("deletes file successfully", async () => {
|
||||
const { safeResolvePath, isSystemFile } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolvePath).mockReturnValue("/ws/file.txt");
|
||||
vi.mocked(isSystemFile).mockReturnValue(false);
|
||||
|
||||
const { DELETE } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "file.txt" }),
|
||||
});
|
||||
const res = await DELETE(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 403 for system file", async () => {
|
||||
const { isSystemFile } = await import("@/lib/workspace");
|
||||
vi.mocked(isSystemFile).mockReturnValue(true);
|
||||
|
||||
const { DELETE } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: ".object.yaml" }),
|
||||
});
|
||||
const res = await DELETE(req);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 404 when file not found", async () => {
|
||||
const { safeResolvePath, isSystemFile } = await import("@/lib/workspace");
|
||||
vi.mocked(isSystemFile).mockReturnValue(false);
|
||||
vi.mocked(safeResolvePath).mockReturnValue(null);
|
||||
|
||||
const { DELETE } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "nonexistent.txt" }),
|
||||
});
|
||||
const res = await DELETE(req);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 for missing path", async () => {
|
||||
const { DELETE } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await DELETE(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid JSON body", async () => {
|
||||
const { DELETE } = await import("./file/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/file", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
const res = await DELETE(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/mkdir ──────────────────────────────────
|
||||
|
||||
describe("POST /api/workspace/mkdir", () => {
|
||||
it("creates directory successfully", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue("/ws/new-folder");
|
||||
|
||||
const { POST } = await import("./mkdir/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/mkdir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "new-folder" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 for missing path", async () => {
|
||||
const { POST } = await import("./mkdir/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/mkdir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for traversal attempt", async () => {
|
||||
const { safeResolveNewPath } = await import("@/lib/workspace");
|
||||
vi.mocked(safeResolveNewPath).mockReturnValue(null);
|
||||
|
||||
const { POST } = await import("./mkdir/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/mkdir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "../../etc" }),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
121
apps/web/app/api/workspace/file/route.ts
Normal file
121
apps/web/app/api/workspace/file/route.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { writeFileSync, mkdirSync, rmSync, statSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { readWorkspaceFile, safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.searchParams.get("path");
|
||||
|
||||
if (!path) {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' query parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const file = readWorkspaceFile(path);
|
||||
if (!file) {
|
||||
return Response.json(
|
||||
{ error: "File not found or access denied" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/workspace/file
|
||||
* Body: { path: string, content: string }
|
||||
*
|
||||
* Writes a file to the workspace. Creates parent directories as needed.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
let body: { path?: string; content?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: relPath, content } = body;
|
||||
if (!relPath || typeof relPath !== "string" || typeof content !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' and 'content' fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Use safeResolveNewPath (not safeResolvePath) because the file may not exist yet
|
||||
const absPath = safeResolveNewPath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "Invalid path or path traversal rejected" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(absPath), { recursive: true });
|
||||
writeFileSync(absPath, content, "utf-8");
|
||||
return Response.json({ ok: true, path: relPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Write failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/workspace/file
|
||||
* Body: { path: string }
|
||||
*
|
||||
* Deletes a file or folder from the workspace.
|
||||
* System files (.object.yaml, workspace.duckdb, etc.) are protected.
|
||||
*/
|
||||
export async function DELETE(req: Request) {
|
||||
let body: { path?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: relPath } = body;
|
||||
if (!relPath || typeof relPath !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (isSystemFile(relPath)) {
|
||||
return Response.json(
|
||||
{ error: "Cannot delete system file" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = safeResolvePath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "File not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(absPath);
|
||||
rmSync(absPath, { recursive: stat.isDirectory() });
|
||||
return Response.json({ ok: true, path: relPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Delete failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
219
apps/web/app/api/workspace/init/route.test.ts
Normal file
219
apps/web/app/api/workspace/init/route.test.ts
Normal file
@ -0,0 +1,219 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => ""),
|
||||
exec: vi.fn(
|
||||
(
|
||||
_cmd: string,
|
||||
_opts: unknown,
|
||||
cb: (err: Error | null, result: { stdout: string }) => void,
|
||||
) => {
|
||||
cb(null, { stdout: "" });
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
describe("POST /api/workspace/init", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const STATE_DIR = join("/home/testuser", ".openclaw");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.OPENCLAW_PROFILE;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
delete process.env.OPENCLAW_WORKSPACE;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
readdirSync: vi.fn(() => []),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => ""),
|
||||
exec: vi.fn(
|
||||
(
|
||||
_cmd: string,
|
||||
_opts: unknown,
|
||||
cb: (err: Error | null, result: { stdout: string }) => void,
|
||||
) => {
|
||||
cb(null, { stdout: "" });
|
||||
},
|
||||
),
|
||||
}));
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
async function callInit(body: Record<string, unknown>) {
|
||||
const { POST } = await import("./route.js");
|
||||
const req = new Request("http://localhost/api/workspace/init", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return POST(req);
|
||||
}
|
||||
|
||||
it("creates default workspace directory", async () => {
|
||||
const mockMkdir = vi.mocked(mkdirSync);
|
||||
const response = await callInit({});
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockMkdir).toHaveBeenCalledWith(
|
||||
join(STATE_DIR, "workspace"),
|
||||
{ recursive: true },
|
||||
);
|
||||
const json = await response.json();
|
||||
expect(json.profile).toBe("default");
|
||||
expect(json.workspaceDir).toBe(join(STATE_DIR, "workspace"));
|
||||
});
|
||||
|
||||
it("creates profile-specific workspace directory", async () => {
|
||||
const mockMkdir = vi.mocked(mkdirSync);
|
||||
const response = await callInit({ profile: "work" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockMkdir).toHaveBeenCalledWith(
|
||||
join(STATE_DIR, "workspace-work"),
|
||||
{ recursive: true },
|
||||
);
|
||||
const json = await response.json();
|
||||
expect(json.profile).toBe("work");
|
||||
});
|
||||
|
||||
it("rejects invalid profile names", async () => {
|
||||
const response = await callInit({ profile: "invalid profile!" });
|
||||
expect(response.status).toBe(400);
|
||||
const json = await response.json();
|
||||
expect(json.error).toContain("Invalid profile name");
|
||||
});
|
||||
|
||||
it("allows alphanumeric, hyphens, and underscores in profile names", async () => {
|
||||
const response = await callInit({ profile: "my-work_1" });
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.profile).toBe("my-work_1");
|
||||
});
|
||||
|
||||
it("accepts 'default' as profile name", async () => {
|
||||
const response = await callInit({ profile: "default" });
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.workspaceDir).toBe(join(STATE_DIR, "workspace"));
|
||||
});
|
||||
|
||||
it("seeds bootstrap files when seedBootstrap is not false", async () => {
|
||||
const mockWrite = vi.mocked(writeFileSync);
|
||||
await callInit({});
|
||||
const writtenPaths = mockWrite.mock.calls.map((c) => c[0] as string);
|
||||
const bootstrapFiles = writtenPaths.filter(
|
||||
(p) =>
|
||||
p.endsWith("AGENTS.md") ||
|
||||
p.endsWith("SOUL.md") ||
|
||||
p.endsWith("TOOLS.md") ||
|
||||
p.endsWith("IDENTITY.md") ||
|
||||
p.endsWith("USER.md") ||
|
||||
p.endsWith("HEARTBEAT.md") ||
|
||||
p.endsWith("BOOTSTRAP.md"),
|
||||
);
|
||||
expect(bootstrapFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns seeded files list", async () => {
|
||||
const response = await callInit({});
|
||||
const json = await response.json();
|
||||
expect(Array.isArray(json.seededFiles)).toBe(true);
|
||||
});
|
||||
|
||||
it("skips bootstrap seeding when seedBootstrap is false", async () => {
|
||||
const mockWrite = vi.mocked(writeFileSync);
|
||||
const callsBefore = mockWrite.mock.calls.length;
|
||||
await callInit({ seedBootstrap: false });
|
||||
const bootstrapWrites = mockWrite.mock.calls
|
||||
.slice(callsBefore)
|
||||
.filter((c) => {
|
||||
const p = c[0] as string;
|
||||
return p.endsWith(".md") && !p.endsWith("workspace-state.json");
|
||||
});
|
||||
expect(bootstrapWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not overwrite existing bootstrap files (idempotent)", async () => {
|
||||
const mockExist = vi.mocked(existsSync);
|
||||
const wsDir = join(STATE_DIR, "workspace");
|
||||
mockExist.mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return s === join(wsDir, "AGENTS.md") || s === join(wsDir, "SOUL.md");
|
||||
});
|
||||
|
||||
const response = await callInit({});
|
||||
const json = await response.json();
|
||||
expect(json.seededFiles).not.toContain("AGENTS.md");
|
||||
expect(json.seededFiles).not.toContain("SOUL.md");
|
||||
});
|
||||
|
||||
it("handles custom workspace path", async () => {
|
||||
const mockMkdir = vi.mocked(mkdirSync);
|
||||
const response = await callInit({
|
||||
profile: "custom",
|
||||
path: "/my/custom/workspace",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockMkdir).toHaveBeenCalledWith("/my/custom/workspace", {
|
||||
recursive: true,
|
||||
});
|
||||
const json = await response.json();
|
||||
expect(json.workspaceDir).toBe("/my/custom/workspace");
|
||||
});
|
||||
|
||||
it("resolves tilde in custom path", async () => {
|
||||
const mockMkdir = vi.mocked(mkdirSync);
|
||||
await callInit({ profile: "tilde", path: "~/my-workspace" });
|
||||
expect(mockMkdir).toHaveBeenCalledWith(
|
||||
join("/home/testuser", "my-workspace"),
|
||||
{ recursive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-switches to new profile after creation", async () => {
|
||||
const response = await callInit({ profile: "newprofile" });
|
||||
const json = await response.json();
|
||||
expect(json.activeProfile).toBe("newprofile");
|
||||
});
|
||||
|
||||
it("handles mkdir failure with 500", async () => {
|
||||
const mockMkdir = vi.mocked(mkdirSync);
|
||||
mockMkdir.mockImplementation(() => {
|
||||
throw new Error("EACCES: permission denied");
|
||||
});
|
||||
const response = await callInit({ profile: "fail" });
|
||||
expect(response.status).toBe(500);
|
||||
const json = await response.json();
|
||||
expect(json.error).toContain("Failed to create workspace directory");
|
||||
});
|
||||
});
|
||||
332
apps/web/app/api/workspace/init/route.ts
Normal file
332
apps/web/app/api/workspace/init/route.ts
Normal file
@ -0,0 +1,332 @@
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveOpenClawStateDir, setUIActiveProfile, getEffectiveProfile, resolveWorkspaceRoot, registerWorkspacePath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap file names (must match src/agents/workspace.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOOTSTRAP_FILENAMES = [
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"TOOLS.md",
|
||||
"IDENTITY.md",
|
||||
"USER.md",
|
||||
"HEARTBEAT.md",
|
||||
"BOOTSTRAP.md",
|
||||
] as const;
|
||||
|
||||
// Minimal fallback content used when templates can't be loaded from disk
|
||||
const FALLBACK_CONTENT: Record<string, string> = {
|
||||
"AGENTS.md": "# AGENTS.md - Your Workspace\n\nThis folder is home. Treat it that way.\n",
|
||||
"SOUL.md": "# SOUL.md - Who You Are\n\nDescribe the personality and behavior of your agent here.\n",
|
||||
"TOOLS.md": "# TOOLS.md - Local Notes\n\nSkills define how tools work. This file is for your specifics.\n",
|
||||
"IDENTITY.md": "# IDENTITY.md - Who Am I?\n\nFill this in during your first conversation.\n",
|
||||
"USER.md": "# USER.md - About Your Human\n\nDescribe yourself and how you'd like the agent to interact with you.\n",
|
||||
"HEARTBEAT.md": "# HEARTBEAT.md\n\n# Keep this file empty (or with only comments) to skip heartbeat API calls.\n",
|
||||
"BOOTSTRAP.md": "# BOOTSTRAP.md - Hello, World\n\nYou just woke up. Time to figure out who you are.\n",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRM seed objects (mirrors src/agents/workspace-seed.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SeedField = {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
enumValues?: string[];
|
||||
};
|
||||
|
||||
type SeedObject = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
defaultView: string;
|
||||
entryCount: number;
|
||||
fields: SeedField[];
|
||||
};
|
||||
|
||||
const SEED_OBJECTS: SeedObject[] = [
|
||||
{
|
||||
id: "seed_obj_people_00000000000000",
|
||||
name: "people",
|
||||
description: "Contact management",
|
||||
icon: "users",
|
||||
defaultView: "table",
|
||||
entryCount: 5,
|
||||
fields: [
|
||||
{ name: "Full Name", type: "text", required: true },
|
||||
{ name: "Email Address", type: "email", required: true },
|
||||
{ name: "Phone Number", type: "phone" },
|
||||
{ name: "Company", type: "text" },
|
||||
{ name: "Status", type: "enum", enumValues: ["Active", "Inactive", "Lead"] },
|
||||
{ name: "Notes", type: "richtext" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "seed_obj_company_0000000000000",
|
||||
name: "company",
|
||||
description: "Company tracking",
|
||||
icon: "building-2",
|
||||
defaultView: "table",
|
||||
entryCount: 3,
|
||||
fields: [
|
||||
{ name: "Company Name", type: "text", required: true },
|
||||
{
|
||||
name: "Industry",
|
||||
type: "enum",
|
||||
enumValues: ["Technology", "Finance", "Healthcare", "Education", "Retail", "Other"],
|
||||
},
|
||||
{ name: "Website", type: "text" },
|
||||
{ name: "Type", type: "enum", enumValues: ["Client", "Partner", "Vendor", "Prospect"] },
|
||||
{ name: "Notes", type: "richtext" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "seed_obj_task_000000000000000",
|
||||
name: "task",
|
||||
description: "Task tracking board",
|
||||
icon: "check-square",
|
||||
defaultView: "kanban",
|
||||
entryCount: 5,
|
||||
fields: [
|
||||
{ name: "Title", type: "text", required: true },
|
||||
{ name: "Description", type: "text" },
|
||||
{ name: "Status", type: "enum", enumValues: ["In Queue", "In Progress", "Done"] },
|
||||
{ name: "Priority", type: "enum", enumValues: ["Low", "Medium", "High"] },
|
||||
{ name: "Due Date", type: "date" },
|
||||
{ name: "Notes", type: "richtext" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stripFrontMatter(content: string): string {
|
||||
if (!content.startsWith("---")) {return content;}
|
||||
const endIndex = content.indexOf("\n---", 3);
|
||||
if (endIndex === -1) {return content;}
|
||||
return content.slice(endIndex + "\n---".length).replace(/^\s+/, "");
|
||||
}
|
||||
|
||||
/** Try multiple candidate paths to find the monorepo root. */
|
||||
function resolveProjectRoot(): string | null {
|
||||
const marker = join("docs", "reference", "templates", "AGENTS.md");
|
||||
const cwd = process.cwd();
|
||||
|
||||
// CWD is the repo root (standalone builds)
|
||||
if (existsSync(join(cwd, marker))) {return cwd;}
|
||||
|
||||
// CWD is apps/web/ (dev mode)
|
||||
const fromApps = resolve(cwd, "..", "..");
|
||||
if (existsSync(join(fromApps, marker))) {return fromApps;}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadTemplateContent(filename: string, projectRoot: string | null): string {
|
||||
if (projectRoot) {
|
||||
const templatePath = join(projectRoot, "docs", "reference", "templates", filename);
|
||||
try {
|
||||
const raw = readFileSync(templatePath, "utf-8");
|
||||
return stripFrontMatter(raw);
|
||||
} catch {
|
||||
// fall through to fallback
|
||||
}
|
||||
}
|
||||
return FALLBACK_CONTENT[filename] ?? "";
|
||||
}
|
||||
|
||||
function generateObjectYaml(obj: SeedObject): string {
|
||||
const lines: string[] = [
|
||||
`id: "${obj.id}"`,
|
||||
`name: "${obj.name}"`,
|
||||
`description: "${obj.description}"`,
|
||||
`icon: "${obj.icon}"`,
|
||||
`default_view: "${obj.defaultView}"`,
|
||||
`entry_count: ${obj.entryCount}`,
|
||||
"fields:",
|
||||
];
|
||||
|
||||
for (const field of obj.fields) {
|
||||
lines.push(` - name: "${field.name}"`);
|
||||
lines.push(` type: ${field.type}`);
|
||||
if (field.required) {lines.push(" required: true");}
|
||||
if (field.enumValues) {lines.push(` values: ${JSON.stringify(field.enumValues)}`);}
|
||||
}
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function generateWorkspaceMd(objects: SeedObject[]): string {
|
||||
const lines: string[] = ["# Workspace Schema", "", "Auto-generated summary of the workspace database.", ""];
|
||||
for (const obj of objects) {
|
||||
lines.push(`## ${obj.name}`, "");
|
||||
lines.push(`- **Description**: ${obj.description}`);
|
||||
lines.push(`- **View**: \`${obj.defaultView}\``);
|
||||
lines.push(`- **Entries**: ${obj.entryCount}`);
|
||||
lines.push("- **Fields**:");
|
||||
for (const field of obj.fields) {
|
||||
const req = field.required ? " (required)" : "";
|
||||
const vals = field.enumValues ? ` — ${field.enumValues.join(", ")}` : "";
|
||||
lines.push(` - ${field.name} (\`${field.type}\`)${req}${vals}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function writeIfMissing(filePath: string, content: string): boolean {
|
||||
if (existsSync(filePath)) {return false;}
|
||||
try {
|
||||
writeFileSync(filePath, content, { encoding: "utf-8", flag: "wx" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function seedDuckDB(workspaceDir: string, projectRoot: string | null): boolean {
|
||||
const destPath = join(workspaceDir, "workspace.duckdb");
|
||||
if (existsSync(destPath)) {return false;}
|
||||
|
||||
if (!projectRoot) {return false;}
|
||||
|
||||
const seedDb = join(projectRoot, "assets", "seed", "workspace.duckdb");
|
||||
if (!existsSync(seedDb)) {return false;}
|
||||
|
||||
try {
|
||||
copyFileSync(seedDb, destPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create filesystem projections for CRM objects
|
||||
for (const obj of SEED_OBJECTS) {
|
||||
const objDir = join(workspaceDir, obj.name);
|
||||
mkdirSync(objDir, { recursive: true });
|
||||
writeIfMissing(join(objDir, ".object.yaml"), generateObjectYaml(obj));
|
||||
}
|
||||
|
||||
writeIfMissing(join(workspaceDir, "WORKSPACE.md"), generateWorkspaceMd(SEED_OBJECTS));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = (await req.json()) as {
|
||||
profile?: string;
|
||||
path?: string;
|
||||
seedBootstrap?: boolean;
|
||||
};
|
||||
|
||||
const profileName = body.profile?.trim() || null;
|
||||
|
||||
if (profileName && profileName !== "default" && !/^[a-zA-Z0-9_-]+$/.test(profileName)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid profile name. Use letters, numbers, hyphens, or underscores." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Determine workspace directory
|
||||
let workspaceDir: string;
|
||||
if (body.path?.trim()) {
|
||||
workspaceDir = body.path.trim();
|
||||
if (workspaceDir.startsWith("~")) {
|
||||
workspaceDir = join(homedir(), workspaceDir.slice(1));
|
||||
}
|
||||
workspaceDir = resolve(workspaceDir);
|
||||
} else {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
if (profileName && profileName !== "default") {
|
||||
workspaceDir = join(stateDir, `workspace-${profileName}`);
|
||||
} else {
|
||||
workspaceDir = join(stateDir, "workspace");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(workspaceDir, { recursive: true });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: `Failed to create workspace directory: ${(err as Error).message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const seedBootstrap = body.seedBootstrap !== false;
|
||||
const seeded: string[] = [];
|
||||
|
||||
if (seedBootstrap) {
|
||||
const projectRoot = resolveProjectRoot();
|
||||
|
||||
// Seed all bootstrap files from templates
|
||||
for (const filename of BOOTSTRAP_FILENAMES) {
|
||||
const filePath = join(workspaceDir, filename);
|
||||
if (!existsSync(filePath)) {
|
||||
const content = loadTemplateContent(filename, projectRoot);
|
||||
if (writeIfMissing(filePath, content)) {
|
||||
seeded.push(filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed DuckDB + CRM object projections
|
||||
if (seedDuckDB(workspaceDir, projectRoot)) {
|
||||
seeded.push("workspace.duckdb");
|
||||
for (const obj of SEED_OBJECTS) {
|
||||
seeded.push(`${obj.name}/.object.yaml`);
|
||||
}
|
||||
}
|
||||
|
||||
// Write workspace state so the gateway knows seeding was done
|
||||
const stateDir = join(workspaceDir, ".openclaw");
|
||||
const statePath = join(stateDir, "workspace-state.json");
|
||||
if (!existsSync(statePath)) {
|
||||
try {
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
const state = {
|
||||
version: 1,
|
||||
bootstrapSeededAt: new Date().toISOString(),
|
||||
duckdbSeededAt: existsSync(join(workspaceDir, "workspace.duckdb"))
|
||||
? new Date().toISOString()
|
||||
: undefined,
|
||||
};
|
||||
writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
||||
} catch {
|
||||
// Best-effort state tracking
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remember custom-path workspaces in the registry
|
||||
if (body.path?.trim() && profileName) {
|
||||
registerWorkspacePath(profileName, workspaceDir);
|
||||
}
|
||||
|
||||
// Switch to the new profile
|
||||
if (profileName) {
|
||||
setUIActiveProfile(profileName === "default" ? null : profileName);
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
workspaceDir,
|
||||
profile: profileName || "default",
|
||||
activeProfile: getEffectiveProfile() || "default",
|
||||
seededFiles: seeded,
|
||||
workspaceRoot: resolveWorkspaceRoot(),
|
||||
});
|
||||
}
|
||||
71
apps/web/app/api/workspace/mkdir/route.ts
Normal file
71
apps/web/app/api/workspace/mkdir/route.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { mkdirSync, existsSync } from "node:fs";
|
||||
import { resolve, normalize } from "node:path";
|
||||
import { safeResolveNewPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/workspace/mkdir
|
||||
* Body: { path: string; absolute?: boolean }
|
||||
*
|
||||
* Creates a new directory. By default paths are resolved relative to the
|
||||
* workspace root. When `absolute` is true the path is treated as a
|
||||
* filesystem-absolute path (used by the directory picker for workspace
|
||||
* creation outside the current workspace).
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
let body: { path?: string; absolute?: boolean };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: rawPath, absolute: useAbsolute } = body;
|
||||
if (!rawPath || typeof rawPath !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let absPath: string | null;
|
||||
|
||||
if (useAbsolute) {
|
||||
const normalized = normalize(rawPath);
|
||||
if (normalized.includes("/../") || normalized.includes("/..")) {
|
||||
return Response.json(
|
||||
{ error: "Path traversal rejected" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
absPath = resolve(normalized);
|
||||
} else {
|
||||
absPath = safeResolveNewPath(rawPath);
|
||||
}
|
||||
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "Invalid path or path traversal rejected" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(absPath)) {
|
||||
return Response.json(
|
||||
{ error: "Directory already exists" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(absPath, { recursive: true });
|
||||
return Response.json({ ok: true, path: absPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "mkdir failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
93
apps/web/app/api/workspace/move/route.ts
Normal file
93
apps/web/app/api/workspace/move/route.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { renameSync, existsSync, statSync } from "node:fs";
|
||||
import { join, basename } from "node:path";
|
||||
import { safeResolvePath, isSystemFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/workspace/move
|
||||
* Body: { sourcePath: string, destinationDir: string }
|
||||
*
|
||||
* Moves a file or folder into a different directory.
|
||||
* System files are protected from moving.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
let body: { sourcePath?: string; destinationDir?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { sourcePath, destinationDir } = body;
|
||||
if (!sourcePath || typeof sourcePath !== "string" || !destinationDir || typeof destinationDir !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'sourcePath' and 'destinationDir' fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (isSystemFile(sourcePath)) {
|
||||
return Response.json(
|
||||
{ error: "Cannot move system file" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const srcAbs = safeResolvePath(sourcePath);
|
||||
if (!srcAbs) {
|
||||
return Response.json(
|
||||
{ error: "Source not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const destDirAbs = safeResolvePath(destinationDir);
|
||||
if (!destDirAbs) {
|
||||
return Response.json(
|
||||
{ error: "Destination not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Destination must be a directory
|
||||
if (!statSync(destDirAbs).isDirectory()) {
|
||||
return Response.json(
|
||||
{ error: "Destination is not a directory" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent moving a folder into itself or its children
|
||||
const srcAbsNorm = srcAbs + "/";
|
||||
if (destDirAbs.startsWith(srcAbsNorm) || destDirAbs === srcAbs) {
|
||||
return Response.json(
|
||||
{ error: "Cannot move a folder into itself" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const itemName = basename(srcAbs);
|
||||
const destAbs = join(destDirAbs, itemName);
|
||||
|
||||
if (existsSync(destAbs)) {
|
||||
return Response.json(
|
||||
{ error: `'${itemName}' already exists in destination` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Build new relative path
|
||||
const newRelPath = destinationDir === "." ? itemName : `${destinationDir}/${itemName}`;
|
||||
|
||||
try {
|
||||
renameSync(srcAbs, destAbs);
|
||||
return Response.json({ ok: true, oldPath: sourcePath, newPath: newRelPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Move failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
392
apps/web/app/api/workspace/objects.test.ts
Normal file
392
apps/web/app/api/workspace/objects.test.ts
Normal file
@ -0,0 +1,392 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock node:child_process
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => ""),
|
||||
}));
|
||||
|
||||
// Mock workspace
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
duckdbPath: vi.fn(() => null),
|
||||
duckdbQueryOnFile: vi.fn(() => []),
|
||||
duckdbExecOnFile: vi.fn(() => true),
|
||||
findDuckDBForObject: vi.fn(() => null),
|
||||
parseRelationValue: vi.fn((v: string | null) => (v ? [v] : [])),
|
||||
resolveDuckdbBin: vi.fn(() => null),
|
||||
discoverDuckDBPaths: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
describe("Workspace Objects API", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
duckdbPath: vi.fn(() => null),
|
||||
duckdbQueryOnFile: vi.fn(() => []),
|
||||
duckdbExecOnFile: vi.fn(() => true),
|
||||
findDuckDBForObject: vi.fn(() => null),
|
||||
parseRelationValue: vi.fn((v: string | null) => (v ? [v] : [])),
|
||||
resolveDuckdbBin: vi.fn(() => null),
|
||||
discoverDuckDBPaths: vi.fn(() => []),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/objects/[name] ──────────────────────────
|
||||
|
||||
describe("GET /api/workspace/objects/[name]", () => {
|
||||
it("returns 503 when DuckDB CLI not installed", async () => {
|
||||
const { resolveDuckdbBin } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveDuckdbBin).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./objects/[name]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/workspace/objects/bad-name!"),
|
||||
{ params: Promise.resolve({ name: "bad-name!" }) },
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid object name (when duckdb available)", async () => {
|
||||
const { resolveDuckdbBin } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
|
||||
|
||||
const { GET } = await import("./objects/[name]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/workspace/objects/bad!name"),
|
||||
{ params: Promise.resolve({ name: "bad!name" }) },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when object not found", async () => {
|
||||
const { findDuckDBForObject, resolveDuckdbBin, duckdbPath: mockDuckdbPath } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue(null);
|
||||
vi.mocked(mockDuckdbPath).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./objects/[name]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/workspace/objects/nonexistent"),
|
||||
{ params: Promise.resolve({ name: "nonexistent" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns object schema and entries when found", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile, resolveDuckdbBin, discoverDuckDBPaths } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
vi.mocked(resolveDuckdbBin).mockReturnValue("/opt/homebrew/bin/duckdb");
|
||||
vi.mocked(discoverDuckDBPaths).mockReturnValue(["/ws/workspace.duckdb"]);
|
||||
|
||||
// Mock different queries with a call counter
|
||||
let queryCall = 0;
|
||||
vi.mocked(duckdbQueryOnFile).mockImplementation(() => {
|
||||
queryCall++;
|
||||
if (queryCall === 1) {
|
||||
// Object row
|
||||
return [{ id: "obj1", name: "leads", description: "Leads object", icon: "star" }];
|
||||
}
|
||||
if (queryCall === 2) {
|
||||
// Fields
|
||||
return [
|
||||
{ id: "f1", name: "name", type: "text", sort_order: 0 },
|
||||
{ id: "f2", name: "status", type: "enum", sort_order: 1, enum_values: '["New","Active"]' },
|
||||
];
|
||||
}
|
||||
if (queryCall === 3) {
|
||||
// Statuses
|
||||
return [];
|
||||
}
|
||||
// Entries and subsequent queries
|
||||
return [];
|
||||
});
|
||||
|
||||
const { GET } = await import("./objects/[name]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/workspace/objects/leads"),
|
||||
{ params: Promise.resolve({ name: "leads" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.object).toBeDefined();
|
||||
expect(json.fields).toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts underscored names", async () => {
|
||||
const { findDuckDBForObject } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./objects/[name]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/workspace/objects/my_object"),
|
||||
{ params: Promise.resolve({ name: "my_object" }) },
|
||||
);
|
||||
// 404 because findDuckDBForObject returns null, but name validation passes
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/objects/[name]/entries ─────────────────
|
||||
|
||||
describe("POST /api/workspace/objects/[name]/entries", () => {
|
||||
it("returns 400 for invalid object name", async () => {
|
||||
const { POST } = await import("./objects/[name]/entries/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/objects/bad!/entries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "bad!" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when DuckDB not found", async () => {
|
||||
const { findDuckDBForObject } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue(null);
|
||||
|
||||
const { POST } = await import("./objects/[name]/entries/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/objects/leads/entries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "leads" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("creates entry successfully", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
|
||||
let queryCall = 0;
|
||||
vi.mocked(duckdbQueryOnFile).mockImplementation(() => {
|
||||
queryCall++;
|
||||
if (queryCall === 1) {return [{ id: "obj1" }];} // object lookup
|
||||
if (queryCall === 2) {return [{ id: "new-entry-uuid" }];} // uuid generation
|
||||
return [];
|
||||
});
|
||||
vi.mocked(duckdbExecOnFile).mockReturnValue(true);
|
||||
|
||||
const { POST } = await import("./objects/[name]/entries/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/objects/leads/entries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: { name: "Acme Corp" } }),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "leads" }) });
|
||||
expect(res.status).toBe(201);
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
expect(json.entryId).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns 404 when object not found in DB", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
vi.mocked(duckdbQueryOnFile).mockReturnValue([]); // object not found
|
||||
|
||||
const { POST } = await import("./objects/[name]/entries/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/objects/missing/entries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "missing" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/objects/[name]/entries/[id] ─────────────
|
||||
|
||||
describe("GET /api/workspace/objects/[name]/entries/[id]", () => {
|
||||
it("returns 400 for invalid object name", async () => {
|
||||
const { GET } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost"),
|
||||
{ params: Promise.resolve({ name: "bad!", id: "123" }) },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when DuckDB not found", async () => {
|
||||
const { findDuckDBForObject } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost"),
|
||||
{ params: Promise.resolve({ name: "leads", id: "123" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns entry details when found", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
|
||||
let queryCall = 0;
|
||||
vi.mocked(duckdbQueryOnFile).mockImplementation(() => {
|
||||
queryCall++;
|
||||
if (queryCall === 1) {return [{ id: "obj1" }];} // object
|
||||
if (queryCall === 2) {return [{ id: "f1", name: "name", type: "text" }];} // fields
|
||||
if (queryCall === 3) {return [{ entry_id: "e1", field_name: "name", value: "Acme", created_at: "2025-01-01", updated_at: "2025-01-01" }];} // EAV
|
||||
return [];
|
||||
});
|
||||
|
||||
const { GET } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const res = await GET(
|
||||
new Request("http://localhost"),
|
||||
{ params: Promise.resolve({ name: "leads", id: "e1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.entry).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PATCH /api/workspace/objects/[name]/entries/[id] ───────────
|
||||
|
||||
describe("PATCH /api/workspace/objects/[name]/entries/[id]", () => {
|
||||
it("returns 400 for invalid object name", async () => {
|
||||
const { PATCH } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const req = new Request("http://localhost", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: {} }),
|
||||
});
|
||||
const res = await PATCH(req, { params: Promise.resolve({ name: "bad!", id: "123" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when DuckDB not found", async () => {
|
||||
const { findDuckDBForObject } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue(null);
|
||||
|
||||
const { PATCH } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const req = new Request("http://localhost", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: { name: "Updated" } }),
|
||||
});
|
||||
const res = await PATCH(req, { params: Promise.resolve({ name: "leads", id: "e1" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("updates entry fields", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
|
||||
let queryCall = 0;
|
||||
vi.mocked(duckdbQueryOnFile).mockImplementation(() => {
|
||||
queryCall++;
|
||||
if (queryCall === 1) {return [{ id: "obj1" }];} // object
|
||||
if (queryCall === 2) {return [{ id: "f1", name: "name", type: "text" }];} // fields
|
||||
return [];
|
||||
});
|
||||
vi.mocked(duckdbExecOnFile).mockReturnValue(true);
|
||||
|
||||
const { PATCH } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const req = new Request("http://localhost", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: { name: "Updated Corp" } }),
|
||||
});
|
||||
const res = await PATCH(req, { params: Promise.resolve({ name: "leads", id: "e1" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /api/workspace/objects/[name]/entries/[id] ──────────
|
||||
|
||||
describe("DELETE /api/workspace/objects/[name]/entries/[id]", () => {
|
||||
it("returns 400 for invalid object name", async () => {
|
||||
const { DELETE } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost", { method: "DELETE" }),
|
||||
{ params: Promise.resolve({ name: "bad!", id: "123" }) },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 404 when DuckDB not found", async () => {
|
||||
const { findDuckDBForObject } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue(null);
|
||||
|
||||
const { DELETE } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost", { method: "DELETE" }),
|
||||
{ params: Promise.resolve({ name: "leads", id: "e1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("deletes entry successfully", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
vi.mocked(duckdbQueryOnFile).mockReturnValue([{ id: "obj1" }]);
|
||||
vi.mocked(duckdbExecOnFile).mockReturnValue(true);
|
||||
|
||||
const { DELETE } = await import("./objects/[name]/entries/[id]/route.js");
|
||||
const res = await DELETE(
|
||||
new Request("http://localhost", { method: "DELETE" }),
|
||||
{ params: Promise.resolve({ name: "leads", id: "e1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /api/workspace/objects/[name]/entries/bulk-delete ─────
|
||||
|
||||
describe("POST /api/workspace/objects/[name]/entries/bulk-delete", () => {
|
||||
it("returns 400 for invalid object name", async () => {
|
||||
const { POST } = await import("./objects/[name]/entries/bulk-delete/route.js");
|
||||
const req = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: ["e1"] }),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "bad!" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for empty entryIds", async () => {
|
||||
const { findDuckDBForObject } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
|
||||
const { POST } = await import("./objects/[name]/entries/bulk-delete/route.js");
|
||||
const req = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ entryIds: [] }),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "leads" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("deletes multiple entries", async () => {
|
||||
const { findDuckDBForObject, duckdbQueryOnFile, duckdbExecOnFile } = await import("@/lib/workspace");
|
||||
vi.mocked(findDuckDBForObject).mockReturnValue("/ws/workspace.duckdb");
|
||||
vi.mocked(duckdbQueryOnFile).mockReturnValue([{ id: "obj1" }]);
|
||||
vi.mocked(duckdbExecOnFile).mockReturnValue(true);
|
||||
|
||||
const { POST } = await import("./objects/[name]/entries/bulk-delete/route.js");
|
||||
const req = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ entryIds: ["e1", "e2", "e3"] }),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ name: "leads" }) });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,83 @@
|
||||
import { duckdbQueryOnFile, duckdbExecOnFile, findDuckDBForObject } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* PATCH /api/workspace/objects/[name]/display-field
|
||||
* Set which field is used as the display label for entries of this object.
|
||||
* Body: { displayField: string }
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const dbFile = findDuckDBForObject(name);
|
||||
if (!dbFile) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB database not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { displayField } = body;
|
||||
|
||||
if (typeof displayField !== "string" || !displayField.trim()) {
|
||||
return Response.json(
|
||||
{ error: "displayField must be a non-empty string" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure display_field column exists
|
||||
duckdbExecOnFile(dbFile,
|
||||
"ALTER TABLE objects ADD COLUMN IF NOT EXISTS display_field VARCHAR",
|
||||
);
|
||||
|
||||
// Verify the object exists
|
||||
const objects = duckdbQueryOnFile<{ id: string }>(dbFile,
|
||||
`SELECT id FROM objects WHERE name = '${name}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the field exists on this object
|
||||
const escapedField = displayField.replace(/'/g, "''");
|
||||
const fieldCheck = duckdbQueryOnFile<{ id: string }>(dbFile,
|
||||
`SELECT id FROM fields WHERE object_id = '${objects[0].id}' AND name = '${escapedField}' LIMIT 1`,
|
||||
);
|
||||
if (fieldCheck.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Field '${displayField}' not found on object '${name}'` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Update the display_field
|
||||
const success = duckdbExecOnFile(dbFile,
|
||||
`UPDATE objects SET display_field = '${escapedField}', updated_at = now() WHERE name = '${name}'`,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return Response.json(
|
||||
{ error: "Failed to update display field" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, displayField });
|
||||
}
|
||||
515
apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts
Normal file
515
apps/web/app/api/workspace/objects/[name]/entries/[id]/route.ts
Normal file
@ -0,0 +1,515 @@
|
||||
import {
|
||||
duckdbQueryOnFile,
|
||||
duckdbExecOnFile,
|
||||
findDuckDBForObject,
|
||||
discoverDuckDBPaths,
|
||||
parseRelationValue,
|
||||
} from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type ObjectRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
default_view?: string;
|
||||
display_field?: string;
|
||||
};
|
||||
|
||||
type FieldRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
enum_values?: string;
|
||||
enum_colors?: string;
|
||||
enum_multiple?: boolean;
|
||||
related_object_id?: string;
|
||||
relationship_type?: string;
|
||||
sort_order?: number;
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
function tryParseJson(value: unknown): unknown {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDisplayField(
|
||||
obj: ObjectRow,
|
||||
fields: FieldRow[],
|
||||
): string {
|
||||
if (obj.display_field) {
|
||||
return obj.display_field;
|
||||
}
|
||||
const nameField = fields.find(
|
||||
(f) =>
|
||||
/\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
|
||||
);
|
||||
if (nameField) {
|
||||
return nameField.name;
|
||||
}
|
||||
const textField = fields.find((f) => f.type === "text");
|
||||
if (textField) {
|
||||
return textField.name;
|
||||
}
|
||||
return fields[0]?.name ?? "id";
|
||||
}
|
||||
|
||||
/** Scoped query shorthand. */
|
||||
function q<T = Record<string, unknown>>(db: string, sql: string): T[] {
|
||||
return duckdbQueryOnFile<T>(db, sql);
|
||||
}
|
||||
|
||||
// --- Route handlers ---
|
||||
|
||||
/**
|
||||
* GET /api/workspace/objects/[name]/entries/[id]
|
||||
* Returns a single entry with all field values, relation labels, and reverse relations.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ name: string; id: string }> },
|
||||
) {
|
||||
const { name, id } = await params;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!id || id.length > 64) {
|
||||
return Response.json(
|
||||
{ error: "Invalid entry ID" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const dbFile = findDuckDBForObject(name);
|
||||
if (!dbFile) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch object
|
||||
const objects = q<ObjectRow>(dbFile,
|
||||
`SELECT * FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const obj = objects[0];
|
||||
|
||||
// Fetch fields
|
||||
const fields = q<FieldRow>(dbFile,
|
||||
`SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`,
|
||||
);
|
||||
|
||||
// Fetch entry field values
|
||||
const entryRows = q<{
|
||||
entry_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
field_name: string;
|
||||
value: string | null;
|
||||
}>(dbFile,
|
||||
`SELECT e.id as entry_id, e.created_at, e.updated_at,
|
||||
f.name as field_name, ef.value
|
||||
FROM entries e
|
||||
JOIN entry_fields ef ON ef.entry_id = e.id
|
||||
JOIN fields f ON f.id = ef.field_id
|
||||
WHERE e.id = '${sqlEscape(id)}'
|
||||
AND e.object_id = '${sqlEscape(obj.id)}'`,
|
||||
);
|
||||
|
||||
if (entryRows.length === 0) {
|
||||
const exists = q<{ cnt: number }>(dbFile,
|
||||
`SELECT COUNT(*) as cnt FROM entries WHERE id = '${sqlEscape(id)}' AND object_id = '${sqlEscape(obj.id)}'`,
|
||||
);
|
||||
if (!exists[0] || exists[0].cnt === 0) {
|
||||
return Response.json(
|
||||
{ error: "Entry not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pivot into a single record
|
||||
const entry: Record<string, unknown> = { entry_id: id };
|
||||
for (const row of entryRows) {
|
||||
entry.created_at ??= row.created_at;
|
||||
entry.updated_at ??= row.updated_at;
|
||||
if (row.field_name) {
|
||||
entry[row.field_name] = row.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse enum JSON strings in fields
|
||||
const parsedFields = fields.map((f) => ({
|
||||
...f,
|
||||
enum_values: f.enum_values
|
||||
? tryParseJson(f.enum_values)
|
||||
: undefined,
|
||||
enum_colors: f.enum_colors
|
||||
? tryParseJson(f.enum_colors)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
// Resolve relation labels for this entry
|
||||
const relationLabels: Record<string, Record<string, string>> = {};
|
||||
const relatedObjectNames: Record<string, string> = {};
|
||||
|
||||
const relationFields = fields.filter(
|
||||
(f) => f.type === "relation" && f.related_object_id,
|
||||
);
|
||||
|
||||
for (const rf of relationFields) {
|
||||
const relatedObjs = q<ObjectRow>(dbFile,
|
||||
`SELECT * FROM objects WHERE id = '${sqlEscape(rf.related_object_id!)}' LIMIT 1`,
|
||||
);
|
||||
if (relatedObjs.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const relObj = relatedObjs[0];
|
||||
relatedObjectNames[rf.name] = relObj.name;
|
||||
|
||||
const val = entry[rf.name];
|
||||
if (val == null || val === "") {
|
||||
relationLabels[rf.name] = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
const valStr =
|
||||
typeof val === "object" && val !== null
|
||||
? JSON.stringify(val)
|
||||
: typeof val === "string"
|
||||
? val
|
||||
: typeof val === "number" || typeof val === "boolean"
|
||||
? String(val)
|
||||
: "";
|
||||
const ids = parseRelationValue(valStr);
|
||||
if (ids.length === 0) {
|
||||
relationLabels[rf.name] = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
const relFields = q<FieldRow>(dbFile,
|
||||
`SELECT * FROM fields WHERE object_id = '${sqlEscape(relObj.id)}' ORDER BY sort_order`,
|
||||
);
|
||||
const displayFieldName = resolveDisplayField(relObj, relFields);
|
||||
|
||||
const idList = ids
|
||||
.map((i) => `'${sqlEscape(i)}'`)
|
||||
.join(",");
|
||||
const displayRows = q<{ entry_id: string; value: string }>(dbFile,
|
||||
`SELECT e.id as entry_id, ef.value
|
||||
FROM entries e
|
||||
JOIN entry_fields ef ON ef.entry_id = e.id
|
||||
JOIN fields f ON f.id = ef.field_id
|
||||
WHERE e.id IN (${idList})
|
||||
AND f.object_id = '${sqlEscape(relObj.id)}'
|
||||
AND f.name = '${sqlEscape(displayFieldName)}'`,
|
||||
);
|
||||
|
||||
const labelMap: Record<string, string> = {};
|
||||
for (const row of displayRows) {
|
||||
labelMap[row.entry_id] = row.value || row.entry_id;
|
||||
}
|
||||
for (const i of ids) {
|
||||
if (!labelMap[i]) {
|
||||
labelMap[i] = i;
|
||||
}
|
||||
}
|
||||
relationLabels[rf.name] = labelMap;
|
||||
}
|
||||
|
||||
// Enrich fields with related object names
|
||||
const enrichedFields = parsedFields.map((f) => ({
|
||||
...f,
|
||||
related_object_name:
|
||||
f.type === "relation"
|
||||
? relatedObjectNames[f.name]
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
// Find reverse relations for this entry (search across all DBs)
|
||||
const reverseRelations = findReverseRelationsForEntry(obj.id, id);
|
||||
|
||||
const effectiveDisplayField = resolveDisplayField(obj, fields);
|
||||
|
||||
return Response.json({
|
||||
object: obj,
|
||||
fields: enrichedFields,
|
||||
entry,
|
||||
relationLabels,
|
||||
reverseRelations,
|
||||
effectiveDisplayField,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/workspace/objects/[name]/entries/[id]
|
||||
* Update field values for an entry.
|
||||
* Body: { fields: { [fieldName]: newValue } }
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ name: string; id: string }> },
|
||||
) {
|
||||
const { name, id } = await params;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const dbFile = findDuckDBForObject(name);
|
||||
if (!dbFile) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find object
|
||||
const objects = q<{ id: string }>(dbFile,
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
// Verify entry exists
|
||||
const exists = q<{ cnt: number }>(dbFile,
|
||||
`SELECT COUNT(*) as cnt FROM entries WHERE id = '${sqlEscape(id)}' AND object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
if (!exists[0] || exists[0].cnt === 0) {
|
||||
return Response.json(
|
||||
{ error: "Entry not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const fieldUpdates: Record<string, string> = body.fields ?? {};
|
||||
|
||||
// Get field IDs by name
|
||||
const dbFields = q<{ id: string; name: string }>(dbFile,
|
||||
`SELECT id, name FROM fields WHERE object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
const fieldMap = new Map(dbFields.map((f) => [f.name, f.id]));
|
||||
|
||||
let updatedCount = 0;
|
||||
for (const [fieldName, value] of Object.entries(fieldUpdates)) {
|
||||
const fieldId = fieldMap.get(fieldName);
|
||||
if (!fieldId) {continue;}
|
||||
|
||||
const escapedValue =
|
||||
value == null ? "NULL" : `'${sqlEscape(String(value))}'`;
|
||||
|
||||
// Try update first, then insert if no rows affected
|
||||
const existingRows = q<{ cnt: number }>(dbFile,
|
||||
`SELECT COUNT(*) as cnt FROM entry_fields WHERE entry_id = '${sqlEscape(id)}' AND field_id = '${sqlEscape(fieldId)}'`,
|
||||
);
|
||||
|
||||
if (existingRows[0]?.cnt > 0) {
|
||||
duckdbExecOnFile(dbFile,
|
||||
`UPDATE entry_fields SET value = ${escapedValue} WHERE entry_id = '${sqlEscape(id)}' AND field_id = '${sqlEscape(fieldId)}'`,
|
||||
);
|
||||
} else {
|
||||
duckdbExecOnFile(dbFile,
|
||||
`INSERT INTO entry_fields (entry_id, field_id, value) VALUES ('${sqlEscape(id)}', '${sqlEscape(fieldId)}', ${escapedValue})`,
|
||||
);
|
||||
}
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
// Touch updated_at on the entry
|
||||
const now = new Date().toISOString();
|
||||
duckdbExecOnFile(dbFile,
|
||||
`UPDATE entries SET updated_at = '${now}' WHERE id = '${sqlEscape(id)}'`,
|
||||
);
|
||||
|
||||
return Response.json({ ok: true, updatedCount });
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/workspace/objects/[name]/entries/[id]
|
||||
* Delete a single entry and its field values.
|
||||
*/
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ name: string; id: string }> },
|
||||
) {
|
||||
const { name, id } = await params;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const dbFile = findDuckDBForObject(name);
|
||||
if (!dbFile) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find object
|
||||
const objects = q<{ id: string }>(dbFile,
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
// Delete field values first, then entry
|
||||
duckdbExecOnFile(dbFile,
|
||||
`DELETE FROM entry_fields WHERE entry_id = '${sqlEscape(id)}'`,
|
||||
);
|
||||
duckdbExecOnFile(dbFile,
|
||||
`DELETE FROM entries WHERE id = '${sqlEscape(id)}' AND object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
// --- Reverse relations for a single entry ---
|
||||
|
||||
type ReverseRelation = {
|
||||
fieldName: string;
|
||||
sourceObjectName: string;
|
||||
sourceObjectId: string;
|
||||
displayField: string;
|
||||
links: Array<{ id: string; label: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find reverse relations for a single entry, searching across ALL discovered databases.
|
||||
*/
|
||||
function findReverseRelationsForEntry(
|
||||
objectId: string,
|
||||
entryId: string,
|
||||
): ReverseRelation[] {
|
||||
const dbPaths = discoverDuckDBPaths();
|
||||
const result: ReverseRelation[] = [];
|
||||
|
||||
for (const db of dbPaths) {
|
||||
const reverseFields = q<{
|
||||
id: string;
|
||||
name: string;
|
||||
object_id: string;
|
||||
source_object_name: string;
|
||||
}>(db,
|
||||
`SELECT f.id, f.name, f.object_id, o.name as source_object_name
|
||||
FROM fields f
|
||||
JOIN objects o ON o.id = f.object_id
|
||||
WHERE f.type = 'relation'
|
||||
AND f.related_object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
|
||||
for (const rrf of reverseFields) {
|
||||
const refRows = q<{
|
||||
source_entry_id: string;
|
||||
target_value: string;
|
||||
}>(db,
|
||||
`SELECT ef.entry_id as source_entry_id, ef.value as target_value
|
||||
FROM entry_fields ef
|
||||
WHERE ef.field_id = '${sqlEscape(rrf.id)}'
|
||||
AND ef.value IS NOT NULL
|
||||
AND ef.value != ''`,
|
||||
);
|
||||
|
||||
const matchingSourceIds: string[] = [];
|
||||
for (const row of refRows) {
|
||||
const targetIds = parseRelationValue(row.target_value);
|
||||
if (targetIds.includes(entryId)) {
|
||||
matchingSourceIds.push(row.source_entry_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingSourceIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceObj = q<ObjectRow>(db,
|
||||
`SELECT * FROM objects WHERE id = '${sqlEscape(rrf.object_id)}' LIMIT 1`,
|
||||
);
|
||||
if (sourceObj.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceFields = q<FieldRow>(db,
|
||||
`SELECT * FROM fields WHERE object_id = '${sqlEscape(rrf.object_id)}' ORDER BY sort_order`,
|
||||
);
|
||||
const displayFieldName = resolveDisplayField(sourceObj[0], sourceFields);
|
||||
|
||||
const idList = matchingSourceIds
|
||||
.map((i) => `'${sqlEscape(i)}'`)
|
||||
.join(",");
|
||||
const displayRows = q<{ entry_id: string; value: string }>(db,
|
||||
`SELECT ef.entry_id, ef.value
|
||||
FROM entry_fields ef
|
||||
JOIN fields f ON f.id = ef.field_id
|
||||
WHERE ef.entry_id IN (${idList})
|
||||
AND f.name = '${sqlEscape(displayFieldName)}'
|
||||
AND f.object_id = '${sqlEscape(rrf.object_id)}'`,
|
||||
);
|
||||
|
||||
const displayMap: Record<string, string> = {};
|
||||
for (const row of displayRows) {
|
||||
displayMap[row.entry_id] = row.value || row.entry_id;
|
||||
}
|
||||
|
||||
const links = matchingSourceIds.map((sid) => ({
|
||||
id: sid,
|
||||
label: displayMap[sid] || sid,
|
||||
}));
|
||||
|
||||
result.push({
|
||||
fieldName: rrf.name,
|
||||
sourceObjectName: rrf.source_object_name,
|
||||
sourceObjectId: rrf.object_id,
|
||||
displayField: displayFieldName,
|
||||
links,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import { duckdbExecOnFile, duckdbQueryOnFile, findDuckDBForObject } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/workspace/objects/[name]/entries/bulk-delete
|
||||
* Delete multiple entries at once.
|
||||
* Body: { entryIds: string[] }
|
||||
*/
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const dbFile = findDuckDBForObject(name);
|
||||
if (!dbFile) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const entryIds: string[] = body.entryIds;
|
||||
|
||||
if (!Array.isArray(entryIds) || entryIds.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "entryIds must be a non-empty array" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate object exists
|
||||
const objects = duckdbQueryOnFile<{ id: string }>(dbFile,
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
const idList = entryIds
|
||||
.map((id) => `'${sqlEscape(id)}'`)
|
||||
.join(",");
|
||||
|
||||
// Delete field values first, then entries
|
||||
duckdbExecOnFile(dbFile,
|
||||
`DELETE FROM entry_fields WHERE entry_id IN (${idList})`,
|
||||
);
|
||||
duckdbExecOnFile(dbFile,
|
||||
`DELETE FROM entries WHERE id IN (${idList}) AND object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
deletedCount: entryIds.length,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
import { duckdbQueryOnFile, findDuckDBForObject } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type ObjectRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
display_field?: string;
|
||||
};
|
||||
|
||||
type FieldRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
function resolveDisplayField(
|
||||
obj: ObjectRow,
|
||||
fields: FieldRow[],
|
||||
): string {
|
||||
if (obj.display_field) {return obj.display_field;}
|
||||
const nameField = fields.find(
|
||||
(f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
|
||||
);
|
||||
if (nameField) {return nameField.name;}
|
||||
const textField = fields.find((f) => f.type === "text");
|
||||
if (textField) {return textField.name;}
|
||||
return fields[0]?.name ?? "id";
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/workspace/objects/[name]/entries/options
|
||||
* Returns lightweight { options: [{ id, label }] } for relation dropdowns.
|
||||
* Supports optional ?q= search parameter.
|
||||
*/
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json({ error: "Invalid object name" }, { status: 400 });
|
||||
}
|
||||
|
||||
const dbFile = findDuckDBForObject(name);
|
||||
if (!dbFile) {
|
||||
return Response.json({ error: "DuckDB not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const objects = duckdbQueryOnFile<ObjectRow>(dbFile,
|
||||
`SELECT * FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json({ error: `Object '${name}' not found` }, { status: 404 });
|
||||
}
|
||||
const obj = objects[0];
|
||||
|
||||
const fields = duckdbQueryOnFile<FieldRow>(dbFile,
|
||||
`SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`,
|
||||
);
|
||||
const displayFieldName = resolveDisplayField(obj, fields);
|
||||
|
||||
// Optional search filter
|
||||
const url = new URL(req.url);
|
||||
const query = url.searchParams.get("q")?.trim() ?? "";
|
||||
|
||||
// Fetch entries with their display field value
|
||||
const rows = duckdbQueryOnFile<{ entry_id: string; label: string | null }>(dbFile,
|
||||
`SELECT e.id as entry_id, ef.value as label
|
||||
FROM entries e
|
||||
LEFT JOIN entry_fields ef ON ef.entry_id = e.id
|
||||
LEFT JOIN fields f ON f.id = ef.field_id AND f.name = '${sqlEscape(displayFieldName)}'
|
||||
WHERE e.object_id = '${sqlEscape(obj.id)}'
|
||||
${query ? `AND (ef.value IS NOT NULL AND LOWER(ef.value) LIKE '%${sqlEscape(query.toLowerCase())}%')` : ""}
|
||||
ORDER BY ef.value ASC NULLS LAST
|
||||
LIMIT 200`,
|
||||
);
|
||||
|
||||
const options = rows.map((r) => ({
|
||||
id: r.entry_id,
|
||||
label: r.label || r.entry_id,
|
||||
}));
|
||||
|
||||
return Response.json({ options, displayField: displayFieldName });
|
||||
}
|
||||
97
apps/web/app/api/workspace/objects/[name]/entries/route.ts
Normal file
97
apps/web/app/api/workspace/objects/[name]/entries/route.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { duckdbExecOnFile, duckdbQueryOnFile, findDuckDBForObject } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/workspace/objects/[name]/entries
|
||||
* Create a new entry with optional field values.
|
||||
* Body: { fields?: Record<string, string> }
|
||||
*/
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const dbFile = findDuckDBForObject(name);
|
||||
if (!dbFile) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find object
|
||||
const objects = duckdbQueryOnFile<{ id: string }>(dbFile,
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
// Generate UUID for the new entry
|
||||
const idRows = duckdbQueryOnFile<{ id: string }>(dbFile,
|
||||
"SELECT uuid()::VARCHAR as id",
|
||||
);
|
||||
const entryId = idRows[0]?.id;
|
||||
if (!entryId) {
|
||||
return Response.json(
|
||||
{ error: "Failed to generate UUID" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create entry
|
||||
const now = new Date().toISOString();
|
||||
const ok = duckdbExecOnFile(dbFile,
|
||||
`INSERT INTO entries (id, object_id, created_at, updated_at) VALUES ('${sqlEscape(entryId)}', '${sqlEscape(objectId)}', '${now}', '${now}')`,
|
||||
);
|
||||
if (!ok) {
|
||||
return Response.json(
|
||||
{ error: "Failed to create entry" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Insert field values if provided
|
||||
let body: { fields?: Record<string, string> } = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
// no body is fine
|
||||
}
|
||||
|
||||
if (body.fields && typeof body.fields === "object") {
|
||||
// Get field IDs by name
|
||||
const dbFields = duckdbQueryOnFile<{ id: string; name: string }>(dbFile,
|
||||
`SELECT id, name FROM fields WHERE object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
const fieldMap = new Map(dbFields.map((f) => [f.name, f.id]));
|
||||
|
||||
for (const [fieldName, value] of Object.entries(body.fields)) {
|
||||
const fieldId = fieldMap.get(fieldName);
|
||||
if (!fieldId || value == null) {continue;}
|
||||
duckdbExecOnFile(dbFile,
|
||||
`INSERT INTO entry_fields (entry_id, field_id, value) VALUES ('${sqlEscape(entryId)}', '${sqlEscape(fieldId)}', '${sqlEscape(String(value))}')`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ entryId, ok: true }, { status: 201 });
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
import { duckdbExecOnFile, duckdbQueryOnFile, findDuckDBForObject } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/workspace/objects/[name]/fields/[fieldId]/enum-rename
|
||||
* Rename an enum value across the field definition and all entries.
|
||||
* Body: { oldValue: string, newValue: string }
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{
|
||||
params,
|
||||
}: { params: Promise<{ name: string; fieldId: string }> },
|
||||
) {
|
||||
const { name, fieldId } = await params;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const dbFile = findDuckDBForObject(name);
|
||||
if (!dbFile) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const oldValue: string = body.oldValue;
|
||||
const newValue: string = body.newValue;
|
||||
|
||||
if (!oldValue || !newValue || typeof oldValue !== "string" || typeof newValue !== "string") {
|
||||
return Response.json(
|
||||
{ error: "oldValue and newValue are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (oldValue.trim() === newValue.trim()) {
|
||||
return Response.json({ ok: true, changed: 0 });
|
||||
}
|
||||
|
||||
// Validate object exists
|
||||
const objects = duckdbQueryOnFile<{ id: string }>(dbFile,
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
// Validate field exists and is an enum
|
||||
const fields = duckdbQueryOnFile<{ id: string; enum_values: string | null; enum_colors: string | null }>(dbFile,
|
||||
`SELECT id, enum_values, enum_colors FROM fields WHERE id = '${sqlEscape(fieldId)}' AND object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
if (fields.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "Field not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const field = fields[0];
|
||||
let enumValues: string[];
|
||||
try {
|
||||
enumValues = field.enum_values ? JSON.parse(field.enum_values) : [];
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Invalid enum_values in field" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const idx = enumValues.indexOf(oldValue.trim());
|
||||
if (idx === -1) {
|
||||
return Response.json(
|
||||
{ error: `Enum value '${oldValue}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
if (enumValues.includes(newValue.trim())) {
|
||||
return Response.json(
|
||||
{ error: `Enum value '${newValue}' already exists` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Update enum_values array
|
||||
enumValues[idx] = newValue.trim();
|
||||
const newEnumJson = JSON.stringify(enumValues);
|
||||
|
||||
duckdbExecOnFile(dbFile,
|
||||
`UPDATE fields SET enum_values = '${sqlEscape(newEnumJson)}' WHERE id = '${sqlEscape(fieldId)}'`,
|
||||
);
|
||||
|
||||
// Update all entry_fields with the old value to the new value
|
||||
const updatedEntries = duckdbExecOnFile(dbFile,
|
||||
`UPDATE entry_fields SET value = '${sqlEscape(newValue.trim())}' WHERE field_id = '${sqlEscape(fieldId)}' AND value = '${sqlEscape(oldValue.trim())}'`,
|
||||
);
|
||||
|
||||
return Response.json({ ok: true, updated: updatedEntries });
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
import { duckdbExecOnFile, duckdbQueryOnFile, findDuckDBForObject } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/workspace/objects/[name]/fields/[fieldId]
|
||||
* Rename a field.
|
||||
* Body: { name: string }
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{
|
||||
params,
|
||||
}: { params: Promise<{ name: string; fieldId: string }> },
|
||||
) {
|
||||
const { name, fieldId } = await params;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const dbFile = findDuckDBForObject(name);
|
||||
if (!dbFile) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const newName: string = body.name;
|
||||
|
||||
if (
|
||||
!newName ||
|
||||
typeof newName !== "string" ||
|
||||
newName.trim().length === 0
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "Name is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate object exists
|
||||
const objects = duckdbQueryOnFile<{ id: string }>(dbFile,
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
// Validate field exists and belongs to this object
|
||||
const fieldExists = duckdbQueryOnFile<{ cnt: number }>(dbFile,
|
||||
`SELECT COUNT(*) as cnt FROM fields WHERE id = '${sqlEscape(fieldId)}' AND object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
if (!fieldExists[0] || fieldExists[0].cnt === 0) {
|
||||
return Response.json(
|
||||
{ error: "Field not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
const duplicateCheck = duckdbQueryOnFile<{ cnt: number }>(dbFile,
|
||||
`SELECT COUNT(*) as cnt FROM fields WHERE object_id = '${sqlEscape(objectId)}' AND name = '${sqlEscape(newName.trim())}' AND id != '${sqlEscape(fieldId)}'`,
|
||||
);
|
||||
if (duplicateCheck[0]?.cnt > 0) {
|
||||
return Response.json(
|
||||
{ error: "A field with that name already exists" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const ok = duckdbExecOnFile(dbFile,
|
||||
`UPDATE fields SET name = '${sqlEscape(newName.trim())}' WHERE id = '${sqlEscape(fieldId)}'`,
|
||||
);
|
||||
|
||||
if (!ok) {
|
||||
return Response.json(
|
||||
{ error: "Failed to rename field" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
import { duckdbExecOnFile, duckdbQueryOnFile, findDuckDBForObject } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/workspace/objects/[name]/fields/reorder
|
||||
* Reorder fields by updating sort_order.
|
||||
* Body: { fieldOrder: string[] } — array of field IDs in desired order
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const dbFile = findDuckDBForObject(name);
|
||||
if (!dbFile) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const fieldOrder: string[] = body.fieldOrder;
|
||||
|
||||
if (!Array.isArray(fieldOrder) || fieldOrder.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "fieldOrder must be a non-empty array" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate object exists
|
||||
const objects = duckdbQueryOnFile<{ id: string }>(dbFile,
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
// Update sort_order for each field
|
||||
for (let i = 0; i < fieldOrder.length; i++) {
|
||||
duckdbExecOnFile(dbFile,
|
||||
`UPDATE fields SET sort_order = ${i} WHERE id = '${sqlEscape(fieldOrder[i])}' AND object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
491
apps/web/app/api/workspace/objects/[name]/route.ts
Normal file
491
apps/web/app/api/workspace/objects/[name]/route.ts
Normal file
@ -0,0 +1,491 @@
|
||||
import { duckdbPath, parseRelationValue, resolveDuckdbBin, findDuckDBForObject, duckdbQueryOnFile, discoverDuckDBPaths, getObjectViews } from "@/lib/workspace";
|
||||
import { deserializeFilters, buildWhereClause, buildOrderByClause, type FieldMeta } from "@/lib/object-filters";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type ObjectRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
default_view?: string;
|
||||
display_field?: string;
|
||||
immutable?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
type FieldRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
enum_values?: string;
|
||||
enum_colors?: string;
|
||||
enum_multiple?: boolean;
|
||||
related_object_id?: string;
|
||||
relationship_type?: string;
|
||||
sort_order?: number;
|
||||
};
|
||||
|
||||
type StatusRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
sort_order?: number;
|
||||
is_default?: boolean;
|
||||
};
|
||||
|
||||
type EavRow = {
|
||||
entry_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
field_name: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
// --- Schema migration (idempotent, runs once per process) ---
|
||||
|
||||
const migratedDbs = new Set<string>();
|
||||
|
||||
/** Ensure the display_field column exists on a specific DB file. */
|
||||
function ensureDisplayFieldColumn(dbFile: string) {
|
||||
if (migratedDbs.has(dbFile)) {return;}
|
||||
const bin = resolveDuckdbBin();
|
||||
if (!bin) {return;}
|
||||
try {
|
||||
execSync(
|
||||
`'${bin}' '${dbFile}' 'ALTER TABLE objects ADD COLUMN IF NOT EXISTS display_field VARCHAR'`,
|
||||
{ encoding: "utf-8", timeout: 5_000, shell: "/bin/sh" },
|
||||
);
|
||||
} catch {
|
||||
// migration might fail on DBs that don't have the objects table — skip
|
||||
}
|
||||
migratedDbs.add(dbFile);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/** Scoped query helper: queries a specific DB file. */
|
||||
function q<T = Record<string, unknown>>(dbFile: string, sql: string): T[] {
|
||||
return duckdbQueryOnFile<T>(dbFile, sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pivot raw EAV rows into one object per entry with field names as keys.
|
||||
*/
|
||||
function pivotEavRows(rows: EavRow[]): Record<string, unknown>[] {
|
||||
const grouped = new Map<string, Record<string, unknown>>();
|
||||
|
||||
for (const row of rows) {
|
||||
let entry = grouped.get(row.entry_id);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
entry_id: row.entry_id,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
grouped.set(row.entry_id, entry);
|
||||
}
|
||||
if (row.field_name) {
|
||||
entry[row.field_name] = row.value;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(grouped.values());
|
||||
}
|
||||
|
||||
function tryParseJson(value: unknown): unknown {
|
||||
if (typeof value !== "string") {return value;}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** SQL-escape a string (double single-quotes). */
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the display field for an object.
|
||||
* Priority: explicit display_field > heuristic (name/title) > first text field > first field.
|
||||
*/
|
||||
function resolveDisplayField(
|
||||
obj: ObjectRow,
|
||||
objFields: FieldRow[],
|
||||
): string {
|
||||
if (obj.display_field) {return obj.display_field;}
|
||||
|
||||
// Heuristic: look for name/title fields
|
||||
const nameField = objFields.find(
|
||||
(f) =>
|
||||
/\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
|
||||
);
|
||||
if (nameField) {return nameField.name;}
|
||||
|
||||
// Fallback: first text field
|
||||
const textField = objFields.find((f) => f.type === "text");
|
||||
if (textField) {return textField.name;}
|
||||
|
||||
// Ultimate fallback: first field
|
||||
return objFields[0]?.name ?? "id";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve relation field values to human-readable display labels.
|
||||
* All queries target the same DB file where the object lives.
|
||||
*/
|
||||
function resolveRelationLabels(
|
||||
dbFile: string,
|
||||
fields: FieldRow[],
|
||||
entries: Record<string, unknown>[],
|
||||
): {
|
||||
labels: Record<string, Record<string, string>>;
|
||||
relatedObjectNames: Record<string, string>;
|
||||
} {
|
||||
const labels: Record<string, Record<string, string>> = {};
|
||||
const relatedObjectNames: Record<string, string> = {};
|
||||
|
||||
const relationFields = fields.filter(
|
||||
(f) => f.type === "relation" && f.related_object_id,
|
||||
);
|
||||
|
||||
for (const rf of relationFields) {
|
||||
const relatedObjs = q<ObjectRow>(dbFile,
|
||||
`SELECT * FROM objects WHERE id = '${sqlEscape(rf.related_object_id!)}' LIMIT 1`,
|
||||
);
|
||||
if (relatedObjs.length === 0) {continue;}
|
||||
const relObj = relatedObjs[0];
|
||||
relatedObjectNames[rf.name] = relObj.name;
|
||||
|
||||
const relFields = q<FieldRow>(dbFile,
|
||||
`SELECT * FROM fields WHERE object_id = '${sqlEscape(relObj.id)}' ORDER BY sort_order`,
|
||||
);
|
||||
const displayFieldName = resolveDisplayField(relObj, relFields);
|
||||
|
||||
const entryIds = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const val = entry[rf.name];
|
||||
if (val == null || val === "") {
|
||||
continue;
|
||||
}
|
||||
const valStr =
|
||||
typeof val === "object" && val !== null
|
||||
? JSON.stringify(val)
|
||||
: typeof val === "string"
|
||||
? val
|
||||
: typeof val === "number" || typeof val === "boolean"
|
||||
? String(val)
|
||||
: "";
|
||||
for (const id of parseRelationValue(valStr)) {
|
||||
entryIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (entryIds.size === 0) {
|
||||
labels[rf.name] = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
const idList = Array.from(entryIds)
|
||||
.map((id) => `'${sqlEscape(id)}'`)
|
||||
.join(",");
|
||||
const displayRows = q<{ entry_id: string; value: string }>(dbFile,
|
||||
`SELECT e.id as entry_id, ef.value
|
||||
FROM entries e
|
||||
JOIN entry_fields ef ON ef.entry_id = e.id
|
||||
JOIN fields f ON f.id = ef.field_id
|
||||
WHERE e.id IN (${idList})
|
||||
AND f.object_id = '${sqlEscape(relObj.id)}'
|
||||
AND f.name = '${sqlEscape(displayFieldName)}'`,
|
||||
);
|
||||
|
||||
const labelMap: Record<string, string> = {};
|
||||
for (const row of displayRows) {
|
||||
labelMap[row.entry_id] = row.value || row.entry_id;
|
||||
}
|
||||
for (const id of entryIds) {
|
||||
if (!labelMap[id]) {labelMap[id] = id;}
|
||||
}
|
||||
|
||||
labels[rf.name] = labelMap;
|
||||
}
|
||||
|
||||
return { labels, relatedObjectNames };
|
||||
}
|
||||
|
||||
type ReverseRelation = {
|
||||
fieldName: string;
|
||||
sourceObjectName: string;
|
||||
sourceObjectId: string;
|
||||
displayField: string;
|
||||
entries: Record<string, Array<{ id: string; label: string }>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find reverse relations: other objects with relation fields pointing TO this object.
|
||||
* Searches across ALL discovered databases to catch cross-DB relations.
|
||||
*/
|
||||
function findReverseRelations(objectId: string): ReverseRelation[] {
|
||||
const dbPaths = discoverDuckDBPaths();
|
||||
const result: ReverseRelation[] = [];
|
||||
|
||||
for (const db of dbPaths) {
|
||||
const reverseFields = q<
|
||||
FieldRow & { source_object_id: string; source_object_name: string }
|
||||
>(db,
|
||||
`SELECT f.*, f.object_id as source_object_id, o.name as source_object_name
|
||||
FROM fields f
|
||||
JOIN objects o ON o.id = f.object_id
|
||||
WHERE f.type = 'relation'
|
||||
AND f.related_object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
|
||||
for (const rrf of reverseFields) {
|
||||
const sourceObjs = q<ObjectRow>(db,
|
||||
`SELECT * FROM objects WHERE id = '${sqlEscape(rrf.source_object_id)}' LIMIT 1`,
|
||||
);
|
||||
if (sourceObjs.length === 0) {continue;}
|
||||
|
||||
const sourceFields = q<FieldRow>(db,
|
||||
`SELECT * FROM fields WHERE object_id = '${sqlEscape(rrf.source_object_id)}' ORDER BY sort_order`,
|
||||
);
|
||||
const displayFieldName = resolveDisplayField(sourceObjs[0], sourceFields);
|
||||
|
||||
const refRows = q<{ source_entry_id: string; target_value: string }>(db,
|
||||
`SELECT ef.entry_id as source_entry_id, ef.value as target_value
|
||||
FROM entry_fields ef
|
||||
WHERE ef.field_id = '${sqlEscape(rrf.id)}'
|
||||
AND ef.value IS NOT NULL
|
||||
AND ef.value != ''`,
|
||||
);
|
||||
|
||||
if (refRows.length === 0) {continue;}
|
||||
|
||||
const sourceEntryIds = [...new Set(refRows.map((r) => r.source_entry_id))];
|
||||
const idList = sourceEntryIds.map((id) => `'${sqlEscape(id)}'`).join(",");
|
||||
const displayRows = q<{ entry_id: string; value: string }>(db,
|
||||
`SELECT ef.entry_id, ef.value
|
||||
FROM entry_fields ef
|
||||
JOIN fields f ON f.id = ef.field_id
|
||||
WHERE ef.entry_id IN (${idList})
|
||||
AND f.name = '${sqlEscape(displayFieldName)}'
|
||||
AND f.object_id = '${sqlEscape(rrf.source_object_id)}'`,
|
||||
);
|
||||
|
||||
const displayMap: Record<string, string> = {};
|
||||
for (const row of displayRows) {
|
||||
displayMap[row.entry_id] = row.value || row.entry_id;
|
||||
}
|
||||
|
||||
const entriesMap: Record<string, Array<{ id: string; label: string }>> = {};
|
||||
for (const row of refRows) {
|
||||
const targetIds = parseRelationValue(row.target_value);
|
||||
for (const targetId of targetIds) {
|
||||
if (!entriesMap[targetId]) {entriesMap[targetId] = [];}
|
||||
entriesMap[targetId].push({
|
||||
id: row.source_entry_id,
|
||||
label: displayMap[row.source_entry_id] || row.source_entry_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
fieldName: rrf.name,
|
||||
sourceObjectName: rrf.source_object_name,
|
||||
sourceObjectId: rrf.source_object_id,
|
||||
displayField: displayFieldName,
|
||||
entries: entriesMap,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Route handler ---
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
|
||||
if (!resolveDuckdbBin()) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB CLI is not installed", code: "DUCKDB_NOT_INSTALLED" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize name to prevent injection (only allow alphanumeric + underscore + hyphen)
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find which DuckDB file contains this object (searches all discovered DBs)
|
||||
const dbFile = findDuckDBForObject(name);
|
||||
if (!dbFile) {
|
||||
// Fall back to primary DB check for a friendlier error message
|
||||
if (!duckdbPath()) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB database not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure display_field column exists on this specific DB
|
||||
ensureDisplayFieldColumn(dbFile);
|
||||
|
||||
// All queries below target the specific DB that owns this object
|
||||
const objects = q<ObjectRow>(dbFile,
|
||||
`SELECT * FROM objects WHERE name = '${name}' LIMIT 1`,
|
||||
);
|
||||
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const obj = objects[0];
|
||||
|
||||
const fields = q<FieldRow>(dbFile,
|
||||
`SELECT * FROM fields WHERE object_id = '${obj.id}' ORDER BY sort_order`,
|
||||
);
|
||||
|
||||
const statuses = q<StatusRow>(dbFile,
|
||||
`SELECT * FROM statuses WHERE object_id = '${obj.id}' ORDER BY sort_order`,
|
||||
);
|
||||
|
||||
// --- Parse filter/sort/pagination query params ---
|
||||
const url = new URL(_req.url);
|
||||
const filtersParam = url.searchParams.get("filters");
|
||||
const sortParam = url.searchParams.get("sort");
|
||||
const searchParam = url.searchParams.get("search");
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
|
||||
const filterGroup = filtersParam ? deserializeFilters(filtersParam) : undefined;
|
||||
const fieldsMeta: FieldMeta[] = fields.map((f) => ({ name: f.name, type: f.type }));
|
||||
|
||||
// Build WHERE clause from filters
|
||||
let whereClause = "";
|
||||
if (filterGroup) {
|
||||
const where = buildWhereClause(filterGroup, fieldsMeta);
|
||||
if (where) {whereClause = ` WHERE ${where}`;}
|
||||
}
|
||||
|
||||
// Build ORDER BY clause
|
||||
let orderByClause = " ORDER BY created_at DESC";
|
||||
if (sortParam) {
|
||||
try {
|
||||
const sortRules = JSON.parse(sortParam);
|
||||
const orderBy = buildOrderByClause(sortRules);
|
||||
if (orderBy) {orderByClause = ` ORDER BY ${orderBy}`;}
|
||||
} catch {
|
||||
// keep default sort
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const page = Math.max(1, Number(pageParam) || 1);
|
||||
const pageSize = Math.min(5000, Math.max(1, Number(pageSizeParam) || 100));
|
||||
const offset = (page - 1) * pageSize;
|
||||
const limitClause = ` LIMIT ${pageSize} OFFSET ${offset}`;
|
||||
|
||||
// Full-text search across text fields
|
||||
if (searchParam && searchParam.trim()) {
|
||||
const textFields = fields.filter((f) => ["text", "richtext", "email"].includes(f.type));
|
||||
if (textFields.length > 0) {
|
||||
const searchConditions = textFields
|
||||
.map((f) => `LOWER(CAST("${f.name.replace(/"/g, '""')}" AS VARCHAR)) LIKE '%${sqlEscape(searchParam.toLowerCase())}%'`)
|
||||
.join(" OR ");
|
||||
whereClause = whereClause
|
||||
? `${whereClause} AND (${searchConditions})`
|
||||
: ` WHERE (${searchConditions})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Try the PIVOT view first, then fall back to raw EAV query + client-side pivot
|
||||
let entries: Record<string, unknown>[] = [];
|
||||
let totalCount = 0;
|
||||
|
||||
try {
|
||||
// Get total count with same WHERE clause but no LIMIT/OFFSET
|
||||
const countResult = q<{ cnt: number }>(dbFile,
|
||||
`SELECT COUNT(*) as cnt FROM v_${name}${whereClause}`,
|
||||
);
|
||||
totalCount = countResult[0]?.cnt ?? 0;
|
||||
|
||||
const pivotEntries = q(dbFile,
|
||||
`SELECT * FROM v_${name}${whereClause}${orderByClause}${limitClause}`,
|
||||
);
|
||||
entries = pivotEntries;
|
||||
} catch {
|
||||
// Pivot view might not exist or filter SQL may not apply; fall back
|
||||
const rawRows = q<EavRow>(dbFile,
|
||||
`SELECT e.id as entry_id, e.created_at, e.updated_at,
|
||||
f.name as field_name, ef.value
|
||||
FROM entries e
|
||||
JOIN entry_fields ef ON ef.entry_id = e.id
|
||||
JOIN fields f ON f.id = ef.field_id
|
||||
WHERE e.object_id = '${obj.id}'
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 5000`,
|
||||
);
|
||||
entries = pivotEavRows(rawRows);
|
||||
}
|
||||
|
||||
const parsedFields = fields.map((f) => ({
|
||||
...f,
|
||||
enum_values: f.enum_values ? tryParseJson(f.enum_values) : undefined,
|
||||
enum_colors: f.enum_colors ? tryParseJson(f.enum_colors) : undefined,
|
||||
}));
|
||||
|
||||
const { labels: relationLabels, relatedObjectNames } =
|
||||
resolveRelationLabels(dbFile, fields, entries);
|
||||
|
||||
const enrichedFields = parsedFields.map((f) => ({
|
||||
...f,
|
||||
related_object_name:
|
||||
f.type === "relation" ? relatedObjectNames[f.name] : undefined,
|
||||
}));
|
||||
|
||||
const reverseRelations = findReverseRelations(obj.id);
|
||||
|
||||
const effectiveDisplayField = resolveDisplayField(obj, fields);
|
||||
|
||||
// Include saved views from .object.yaml
|
||||
const { views: savedViews, activeView } = getObjectViews(name);
|
||||
|
||||
return Response.json({
|
||||
object: obj,
|
||||
fields: enrichedFields,
|
||||
statuses,
|
||||
entries,
|
||||
relationLabels,
|
||||
reverseRelations,
|
||||
effectiveDisplayField,
|
||||
savedViews,
|
||||
activeView,
|
||||
totalCount,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
61
apps/web/app/api/workspace/objects/[name]/views/route.ts
Normal file
61
apps/web/app/api/workspace/objects/[name]/views/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getObjectViews, saveObjectViews } from "@/lib/workspace";
|
||||
import type { SavedView } from "@/lib/object-filters";
|
||||
|
||||
type Params = { params: Promise<{ name: string }> };
|
||||
|
||||
/**
|
||||
* GET /api/workspace/objects/[name]/views
|
||||
*
|
||||
* Returns saved views and active_view from the object's .object.yaml.
|
||||
*/
|
||||
export async function GET(_req: Request, ctx: Params) {
|
||||
const { name } = await ctx.params;
|
||||
const objectName = decodeURIComponent(name);
|
||||
|
||||
try {
|
||||
const { views, activeView } = getObjectViews(objectName);
|
||||
return NextResponse.json({ views, activeView });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to read views: ${err instanceof Error ? err.message : String(err)}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/workspace/objects/[name]/views
|
||||
*
|
||||
* Save views and active_view to the object's .object.yaml.
|
||||
* Body: { views: SavedView[], activeView?: string }
|
||||
*/
|
||||
export async function PUT(req: Request, ctx: Params) {
|
||||
const { name } = await ctx.params;
|
||||
const objectName = decodeURIComponent(name);
|
||||
|
||||
try {
|
||||
const body = (await req.json()) as {
|
||||
views?: SavedView[];
|
||||
activeView?: string;
|
||||
};
|
||||
|
||||
const views = body.views ?? [];
|
||||
const activeView = body.activeView;
|
||||
|
||||
const ok = saveObjectViews(objectName, views, activeView);
|
||||
if (!ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Object directory not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to save views: ${err instanceof Error ? err.message : String(err)}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
97
apps/web/app/api/workspace/open-file/route.ts
Normal file
97
apps/web/app/api/workspace/open-file/route.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve, normalize } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/workspace/open-file
|
||||
* Opens a file or directory using the system's default application.
|
||||
* On macOS this uses `open`, on Linux `xdg-open`.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
let body: { path?: string; reveal?: boolean };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Invalid JSON body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const rawPath = body.path;
|
||||
if (!rawPath || typeof rawPath !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' in request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Expand ~ to home directory
|
||||
const expanded = rawPath.startsWith("~/")
|
||||
? rawPath.replace(/^~/, homedir())
|
||||
: rawPath;
|
||||
|
||||
let resolved = resolve(normalize(expanded));
|
||||
|
||||
// If the file doesn't exist and looks like a bare filename, try to locate it
|
||||
// using macOS Spotlight (mdfind).
|
||||
if (!existsSync(resolved) && !rawPath.includes("/")) {
|
||||
const found = await new Promise<string | null>((res) => {
|
||||
exec(
|
||||
`mdfind -name ${JSON.stringify(rawPath)} | head -1`,
|
||||
(err, stdout) => {
|
||||
if (err || !stdout.trim()) {res(null);}
|
||||
else {res(stdout.trim().split("\n")[0]);}
|
||||
},
|
||||
);
|
||||
});
|
||||
if (found && existsSync(found)) {
|
||||
resolved = found;
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(resolved)) {
|
||||
return Response.json(
|
||||
{ error: "File not found", path: resolved },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const platform = process.platform;
|
||||
const reveal = body.reveal === true;
|
||||
|
||||
let cmd: string;
|
||||
if (platform === "darwin") {
|
||||
// macOS: use `open` — `-R` reveals in Finder instead of opening
|
||||
cmd = reveal
|
||||
? `open -R ${JSON.stringify(resolved)}`
|
||||
: `open ${JSON.stringify(resolved)}`;
|
||||
} else if (platform === "linux") {
|
||||
// Linux: xdg-open (no reveal equivalent)
|
||||
cmd = `xdg-open ${JSON.stringify(resolved)}`;
|
||||
} else {
|
||||
return Response.json(
|
||||
{ error: `Unsupported platform: ${platform}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<Response>((res) => {
|
||||
exec(cmd, (error) => {
|
||||
if (error) {
|
||||
res(
|
||||
Response.json(
|
||||
{ error: `Failed to open file: ${error.message}` },
|
||||
{ status: 500 },
|
||||
),
|
||||
);
|
||||
} else {
|
||||
res(Response.json({ ok: true, path: resolved }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
88
apps/web/app/api/workspace/path-info/route.ts
Normal file
88
apps/web/app/api/workspace/path-info/route.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, normalize, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* GET /api/workspace/path-info?path=...
|
||||
* Resolves and inspects a filesystem path for in-app preview routing.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const rawPath = url.searchParams.get("path");
|
||||
|
||||
if (!rawPath) {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' query parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let candidatePath = rawPath;
|
||||
|
||||
// Convert file:// URLs into local paths first.
|
||||
if (candidatePath.startsWith("file://")) {
|
||||
try {
|
||||
candidatePath = fileURLToPath(candidatePath);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Invalid file URL" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Expand "~/..." to the current user's home directory.
|
||||
const expandedPath = candidatePath.startsWith("~/")
|
||||
? candidatePath.replace(/^~/, homedir())
|
||||
: candidatePath;
|
||||
let resolvedPath = resolve(normalize(expandedPath));
|
||||
|
||||
// If the path doesn't exist and looks like a bare filename, try to locate it
|
||||
// using macOS Spotlight (mdfind).
|
||||
if (!existsSync(resolvedPath) && !rawPath.includes("/")) {
|
||||
const found = await new Promise<string | null>((res) => {
|
||||
exec(
|
||||
`mdfind -name ${JSON.stringify(rawPath)} | head -1`,
|
||||
(err, stdout) => {
|
||||
if (err || !stdout.trim()) {res(null);}
|
||||
else {res(stdout.trim().split("\n")[0]);}
|
||||
},
|
||||
);
|
||||
});
|
||||
if (found && existsSync(found)) {
|
||||
resolvedPath = found;
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return Response.json(
|
||||
{ error: "Path not found", path: resolvedPath },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(resolvedPath);
|
||||
const type = stat.isDirectory()
|
||||
? "directory"
|
||||
: stat.isFile()
|
||||
? "file"
|
||||
: "other";
|
||||
|
||||
return Response.json({
|
||||
path: resolvedPath,
|
||||
name: basename(resolvedPath) || resolvedPath,
|
||||
type,
|
||||
});
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Cannot stat path", path: resolvedPath },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
43
apps/web/app/api/workspace/query/route.ts
Normal file
43
apps/web/app/api/workspace/query/route.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { duckdbQuery } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { sql?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Invalid JSON body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { sql } = body;
|
||||
if (!sql || typeof sql !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'sql' field in request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Basic SQL safety: reject obviously dangerous statements
|
||||
const upper = sql.toUpperCase().trim();
|
||||
if (
|
||||
upper.startsWith("DROP") ||
|
||||
upper.startsWith("DELETE") ||
|
||||
upper.startsWith("INSERT") ||
|
||||
upper.startsWith("UPDATE") ||
|
||||
upper.startsWith("ALTER") ||
|
||||
upper.startsWith("CREATE")
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "Only SELECT queries are allowed" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = duckdbQuery(sql);
|
||||
return Response.json({ rows });
|
||||
}
|
||||
124
apps/web/app/api/workspace/raw-file/route.ts
Normal file
124
apps/web/app/api/workspace/raw-file/route.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { safeResolvePath, resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
// Images
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
ico: "image/x-icon",
|
||||
bmp: "image/bmp",
|
||||
tiff: "image/tiff",
|
||||
tif: "image/tiff",
|
||||
avif: "image/avif",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
// Video
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
mov: "video/quicktime",
|
||||
avi: "video/x-msvideo",
|
||||
mkv: "video/x-matroska",
|
||||
// Audio
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
m4a: "audio/mp4",
|
||||
// Documents
|
||||
pdf: "application/pdf",
|
||||
html: "text/html",
|
||||
htm: "text/html",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a file path, trying multiple strategies:
|
||||
* 1. Absolute path — the agent may read files from anywhere on the local machine
|
||||
* (Photos library, Downloads, etc.), so we serve any readable absolute path.
|
||||
* 2. Workspace-relative via safeResolvePath
|
||||
* 3. Bare filename — search common workspace subdirectories
|
||||
*
|
||||
* Security note: this is a local-only dev server; it never runs in production.
|
||||
*/
|
||||
function resolveFile(path: string): string | null {
|
||||
// 1. Absolute path — serve directly if it exists on disk
|
||||
if (path.startsWith("/")) {
|
||||
const abs = resolve(path);
|
||||
if (existsSync(abs)) {return abs;}
|
||||
// Fall through to workspace-relative in case the leading / is accidental
|
||||
}
|
||||
|
||||
// 2. Standard workspace-relative resolution
|
||||
const resolved = safeResolvePath(path);
|
||||
if (resolved) {return resolved;}
|
||||
|
||||
// 3. Try common subdirectories in case the path is a bare filename
|
||||
const root = resolveWorkspaceRoot();
|
||||
if (!root) {return null;}
|
||||
const rootAbs = resolve(root);
|
||||
const basename = path.split("/").pop() ?? path;
|
||||
if (basename === path) {
|
||||
const subdirs = [
|
||||
"assets",
|
||||
"knowledge",
|
||||
"manufacturing",
|
||||
"uploads",
|
||||
"files",
|
||||
"images",
|
||||
"media",
|
||||
"reports",
|
||||
"exports",
|
||||
];
|
||||
for (const sub of subdirs) {
|
||||
const candidate = resolve(root, sub, basename);
|
||||
if (
|
||||
candidate.startsWith(rootAbs) &&
|
||||
existsSync(candidate)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/workspace/raw-file?path=...
|
||||
* Serves a workspace file with the correct Content-Type for inline display.
|
||||
* Used by the chain-of-thought component to render images, videos, and PDFs.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.searchParams.get("path");
|
||||
|
||||
if (!path) {
|
||||
return new Response("Missing path", { status: 400 });
|
||||
}
|
||||
|
||||
const absolute = resolveFile(path);
|
||||
if (!absolute) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
const contentType = MIME_MAP[ext] ?? "application/octet-stream";
|
||||
|
||||
try {
|
||||
const buffer = readFileSync(absolute);
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response("Read error", { status: 500 });
|
||||
}
|
||||
}
|
||||
84
apps/web/app/api/workspace/rename/route.ts
Normal file
84
apps/web/app/api/workspace/rename/route.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { renameSync, existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { safeResolvePath, safeResolveNewPath, isSystemFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/workspace/rename
|
||||
* Body: { path: string, newName: string }
|
||||
*
|
||||
* Renames a file or folder within the same directory.
|
||||
* System files are protected from renaming.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
let body: { path?: string; newName?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: relPath, newName } = body;
|
||||
if (!relPath || typeof relPath !== "string" || !newName || typeof newName !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' and 'newName' fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (isSystemFile(relPath)) {
|
||||
return Response.json(
|
||||
{ error: "Cannot rename system file" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate newName: no slashes, no empty, no traversal
|
||||
if (newName.includes("/") || newName.includes("\\") || newName.trim() === "") {
|
||||
return Response.json(
|
||||
{ error: "Invalid file name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = safeResolvePath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "Source not found or path traversal rejected" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const parentDir = dirname(absPath);
|
||||
const newAbsPath = join(parentDir, newName);
|
||||
|
||||
// Ensure the new path stays within workspace
|
||||
const parentRel = dirname(relPath);
|
||||
const newRelPath = parentRel === "." ? newName : `${parentRel}/${newName}`;
|
||||
const validated = safeResolveNewPath(newRelPath);
|
||||
if (!validated) {
|
||||
return Response.json(
|
||||
{ error: "Invalid destination path" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(newAbsPath)) {
|
||||
return Response.json(
|
||||
{ error: `A file named '${newName}' already exists` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
renameSync(absPath, newAbsPath);
|
||||
return Response.json({ ok: true, oldPath: relPath, newPath: newRelPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Rename failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
54
apps/web/app/api/workspace/reports/execute/route.ts
Normal file
54
apps/web/app/api/workspace/reports/execute/route.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { duckdbQuery } from "@/lib/workspace";
|
||||
import { buildFilterClauses, injectFilters, checkSqlSafety } from "@/lib/report-filters";
|
||||
import type { FilterEntry } from "@/lib/report-filters";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/workspace/reports/execute
|
||||
*
|
||||
* Body: { sql: string, filters?: FilterEntry[] }
|
||||
*
|
||||
* Executes a report panel's SQL query with optional filter injection.
|
||||
* Only SELECT-compatible queries are allowed.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
let body: {
|
||||
sql?: string;
|
||||
filters?: FilterEntry[];
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { sql, filters } = body;
|
||||
if (!sql || typeof sql !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'sql' field in request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Basic SQL safety: reject mutation statements
|
||||
const safetyError = checkSqlSafety(sql);
|
||||
if (safetyError) {
|
||||
return Response.json({ error: safetyError }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build filter clauses and inject into SQL
|
||||
const filterClauses = buildFilterClauses(filters);
|
||||
const finalSql = injectFilters(sql, filterClauses);
|
||||
|
||||
try {
|
||||
const rows = duckdbQuery(finalSql);
|
||||
return Response.json({ rows, sql: finalSql });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Query execution failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
289
apps/web/app/api/workspace/search-index/route.ts
Normal file
289
apps/web/app/api/workspace/search-index/route.ts
Normal file
@ -0,0 +1,289 @@
|
||||
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
resolveWorkspaceRoot,
|
||||
parseSimpleYaml,
|
||||
duckdbQueryAllAsync,
|
||||
discoverDuckDBPaths,
|
||||
duckdbQueryOnFileAsync,
|
||||
isDatabaseFile,
|
||||
} from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/** Safely convert an unknown DB value to a display string. */
|
||||
function dbStr(val: unknown): string {
|
||||
if (val == null) {return "";}
|
||||
if (typeof val === "object") {return JSON.stringify(val);}
|
||||
return String(val as string | number | boolean);
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type SearchIndexItem = {
|
||||
/** Unique key: relative path for files, entryId for entries */
|
||||
id: string;
|
||||
/** Primary display text (filename or display-field value) */
|
||||
label: string;
|
||||
/** Secondary text (path for files, object name for entries) */
|
||||
sublabel?: string;
|
||||
/** Item kind for grouping and icons */
|
||||
kind: "file" | "object" | "entry";
|
||||
/** Icon hint */
|
||||
icon?: string;
|
||||
|
||||
// Entry-specific
|
||||
objectName?: string;
|
||||
entryId?: string;
|
||||
/** First few field key-value pairs for search and preview */
|
||||
fields?: Record<string, string>;
|
||||
|
||||
// File/object-specific
|
||||
path?: string;
|
||||
nodeType?: "document" | "folder" | "file" | "report" | "database";
|
||||
};
|
||||
|
||||
// --- DB types ---
|
||||
|
||||
type ObjectRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
default_view?: string;
|
||||
display_field?: string;
|
||||
};
|
||||
|
||||
type FieldRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
sort_order?: number;
|
||||
};
|
||||
|
||||
type EavRow = {
|
||||
entry_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
field_name: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/** Determine the display field (same heuristic as the objects route). */
|
||||
function resolveDisplayField(obj: ObjectRow, fields: FieldRow[]): string {
|
||||
if (obj.display_field) {return obj.display_field;}
|
||||
|
||||
const nameField = fields.find(
|
||||
(f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
|
||||
);
|
||||
if (nameField) {return nameField.name;}
|
||||
|
||||
const textField = fields.find((f) => f.type === "text");
|
||||
if (textField) {return textField.name;}
|
||||
|
||||
return fields[0]?.name ?? "id";
|
||||
}
|
||||
|
||||
/** Flatten a tree recursively to produce file/object search items. */
|
||||
function flattenTree(
|
||||
absDir: string,
|
||||
relBase: string,
|
||||
dbObjects: Map<string, ObjectRow>,
|
||||
items: SearchIndexItem[],
|
||||
) {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(absDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) {continue;}
|
||||
|
||||
const absPath = join(absDir, entry.name);
|
||||
const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const dbObj = dbObjects.get(entry.name);
|
||||
// Check for .object.yaml
|
||||
const yamlPath = join(absPath, ".object.yaml");
|
||||
const hasYaml = existsSync(yamlPath);
|
||||
|
||||
if (dbObj || hasYaml) {
|
||||
let icon: string | undefined;
|
||||
if (hasYaml) {
|
||||
try {
|
||||
const parsed = parseSimpleYaml(
|
||||
readFileSync(yamlPath, "utf-8"),
|
||||
);
|
||||
icon = parsed.icon as string | undefined;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: relPath,
|
||||
label: entry.name,
|
||||
sublabel: relPath,
|
||||
kind: "object",
|
||||
icon: icon ?? dbObj?.icon,
|
||||
path: relPath,
|
||||
nodeType: undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular folder -- don't add as item, but recurse
|
||||
}
|
||||
|
||||
flattenTree(absPath, relPath, dbObjects, items);
|
||||
} else if (entry.isFile()) {
|
||||
const isReport = entry.name.endsWith(".report.json");
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
const isDatabase = isDatabaseFile(entry.name);
|
||||
|
||||
items.push({
|
||||
id: relPath,
|
||||
label: entry.name.replace(/\.md$/, ""),
|
||||
sublabel: relPath,
|
||||
kind: "file",
|
||||
path: relPath,
|
||||
nodeType: isReport
|
||||
? "report"
|
||||
: isDatabase
|
||||
? "database"
|
||||
: isDocument
|
||||
? "document"
|
||||
: "file",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all entries from all objects across ALL discovered DuckDB files.
|
||||
* Deduplicates objects by name (shallower DBs win).
|
||||
*/
|
||||
async function buildEntryItems(): Promise<SearchIndexItem[]> {
|
||||
const items: SearchIndexItem[] = [];
|
||||
const dbPaths = discoverDuckDBPaths();
|
||||
if (dbPaths.length === 0) {return [];}
|
||||
|
||||
// Collect all objects across DBs, deduplicating by name (shallowest wins)
|
||||
const seenNames = new Set<string>();
|
||||
const objectsWithDb: Array<{ obj: ObjectRow; dbPath: string }> = [];
|
||||
|
||||
for (const dbPath of dbPaths) {
|
||||
const objs = await duckdbQueryOnFileAsync<ObjectRow>(dbPath,
|
||||
"SELECT * FROM objects ORDER BY name",
|
||||
);
|
||||
for (const obj of objs) {
|
||||
if (seenNames.has(obj.name)) {continue;}
|
||||
seenNames.add(obj.name);
|
||||
objectsWithDb.push({ obj, dbPath });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { obj, dbPath } of objectsWithDb) {
|
||||
const fields = await duckdbQueryOnFileAsync<FieldRow>(dbPath,
|
||||
`SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`,
|
||||
);
|
||||
const displayField = resolveDisplayField(obj, fields);
|
||||
const previewFields = fields
|
||||
.filter((f) => !["relation", "richtext"].includes(f.type))
|
||||
.slice(0, 4);
|
||||
|
||||
// Try PIVOT view first, then raw EAV (on the same DB)
|
||||
let entries: Record<string, unknown>[] = await duckdbQueryOnFileAsync(dbPath,
|
||||
`SELECT * FROM v_${obj.name} ORDER BY created_at DESC LIMIT 500`,
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
const rawRows = await duckdbQueryOnFileAsync<EavRow>(dbPath,
|
||||
`SELECT e.id as entry_id, e.created_at, e.updated_at,
|
||||
f.name as field_name, ef.value
|
||||
FROM entries e
|
||||
JOIN entry_fields ef ON ef.entry_id = e.id
|
||||
JOIN fields f ON f.id = ef.field_id
|
||||
WHERE e.object_id = '${sqlEscape(obj.id)}'
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 2500`,
|
||||
);
|
||||
|
||||
const grouped = new Map<string, Record<string, unknown>>();
|
||||
for (const row of rawRows) {
|
||||
let entry = grouped.get(row.entry_id);
|
||||
if (!entry) {
|
||||
entry = { entry_id: row.entry_id };
|
||||
grouped.set(row.entry_id, entry);
|
||||
}
|
||||
if (row.field_name) {entry[row.field_name] = row.value;}
|
||||
}
|
||||
entries = Array.from(grouped.values());
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryId = dbStr(entry.entry_id);
|
||||
if (!entryId) {continue;}
|
||||
|
||||
const displayValue = dbStr(entry[displayField]);
|
||||
const fieldPreview: Record<string, string> = {};
|
||||
for (const f of previewFields) {
|
||||
const val = entry[f.name];
|
||||
if (val != null && val !== "") {
|
||||
fieldPreview[f.name] = dbStr(val);
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: `entry:${obj.name}:${entryId}`,
|
||||
label: displayValue || `(${obj.name} entry)`,
|
||||
sublabel: obj.name,
|
||||
kind: "entry",
|
||||
icon: obj.icon,
|
||||
objectName: obj.name,
|
||||
entryId,
|
||||
fields: Object.keys(fieldPreview).length > 0 ? fieldPreview : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// --- Route handler ---
|
||||
|
||||
export async function GET() {
|
||||
const items: SearchIndexItem[] = [];
|
||||
|
||||
// 1. Files + objects from tree
|
||||
const root = resolveWorkspaceRoot();
|
||||
if (root) {
|
||||
// Aggregate objects from ALL discovered DuckDB files (shallower wins)
|
||||
const dbObjects = new Map<string, ObjectRow>();
|
||||
const objs = await duckdbQueryAllAsync<ObjectRow & { name: string }>(
|
||||
"SELECT * FROM objects",
|
||||
"name",
|
||||
);
|
||||
for (const o of objs) {dbObjects.set(o.name, o);}
|
||||
|
||||
// Scan workspace root (the workspace folder IS the knowledge base)
|
||||
flattenTree(root, "", dbObjects, items);
|
||||
}
|
||||
|
||||
// 2. Entries from all objects across all discovered DBs
|
||||
const dbPaths = discoverDuckDBPaths();
|
||||
if (dbPaths.length > 0) {
|
||||
items.push(...await buildEntryItems());
|
||||
}
|
||||
|
||||
return Response.json({ items });
|
||||
}
|
||||
434
apps/web/app/api/workspace/suggest-files/route.ts
Normal file
434
apps/web/app/api/workspace/suggest-files/route.ts
Normal file
@ -0,0 +1,434 @@
|
||||
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
|
||||
import { join, dirname, resolve, basename } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
resolveWorkspaceRoot,
|
||||
duckdbQueryAllAsync,
|
||||
discoverDuckDBPaths,
|
||||
duckdbQueryOnFileAsync,
|
||||
parseSimpleYaml,
|
||||
} from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type SuggestItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "folder" | "file" | "document" | "database" | "object" | "entry";
|
||||
/** Icon hint (emoji) for objects/entries */
|
||||
icon?: string;
|
||||
/** Object name that owns this entry */
|
||||
objectName?: string;
|
||||
/** DB entry ID */
|
||||
entryId?: string;
|
||||
};
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
"node_modules",
|
||||
".git",
|
||||
".Trash",
|
||||
"__pycache__",
|
||||
".cache",
|
||||
".DS_Store",
|
||||
]);
|
||||
|
||||
/** List entries in a directory, sorted folders-first then alphabetically. */
|
||||
function listDir(absDir: string, filter?: string): SuggestItem[] {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(absDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowerFilter = filter?.toLowerCase();
|
||||
|
||||
const sorted = entries
|
||||
.filter((e) => !e.name.startsWith("."))
|
||||
.filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name)))
|
||||
.filter((e) => !lowerFilter || e.name.toLowerCase().includes(lowerFilter))
|
||||
.toSorted((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) {return -1;}
|
||||
if (!a.isDirectory() && b.isDirectory()) {return 1;}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const items: SuggestItem[] = [];
|
||||
for (const entry of sorted) {
|
||||
if (items.length >= 30) {break;}
|
||||
const absPath = join(absDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
items.push({ name: entry.name, path: absPath, type: "folder" });
|
||||
} else if (entry.isFile()) {
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
const isDatabase =
|
||||
ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
|
||||
items.push({
|
||||
name: entry.name,
|
||||
path: absPath,
|
||||
type: isDatabase ? "database" : isDocument ? "document" : "file",
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Recursively search for files matching a query, up to a limit. */
|
||||
function searchFiles(
|
||||
absDir: string,
|
||||
query: string,
|
||||
results: SuggestItem[],
|
||||
maxResults: number,
|
||||
depth = 0,
|
||||
): void {
|
||||
if (depth > 6 || results.length >= maxResults) {return;}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(absDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (results.length >= maxResults) {return;}
|
||||
if (entry.name.startsWith(".")) {continue;}
|
||||
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) {continue;}
|
||||
|
||||
const absPath = join(absDir, entry.name);
|
||||
|
||||
if (entry.isFile() && entry.name.toLowerCase().includes(lowerQuery)) {
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
const isDatabase =
|
||||
ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
|
||||
results.push({
|
||||
name: entry.name,
|
||||
path: absPath,
|
||||
type: isDatabase ? "database" : isDocument ? "document" : "file",
|
||||
});
|
||||
} else if (
|
||||
entry.isDirectory() &&
|
||||
entry.name.toLowerCase().includes(lowerQuery)
|
||||
) {
|
||||
results.push({ name: entry.name, path: absPath, type: "folder" });
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
searchFiles(absPath, query, results, maxResults, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-typed path query into a directory to list and an optional filter.
|
||||
*
|
||||
* Examples:
|
||||
* "../" → list parent of workspace root
|
||||
* "/" → list filesystem root
|
||||
* "~/" → list home dir
|
||||
* "~/Doc" → list home dir, filter "Doc"
|
||||
* "src/utils" → list <workspace>/src, filter "utils"
|
||||
* "foo.ts" → search by filename
|
||||
*/
|
||||
function resolvePath(
|
||||
raw: string,
|
||||
workspaceRoot: string,
|
||||
): { dir: string; filter?: string } | null {
|
||||
const home = homedir();
|
||||
|
||||
if (raw.startsWith("~/")) {
|
||||
const rest = raw.slice(2);
|
||||
if (!rest || rest.endsWith("/")) {
|
||||
// List the directory
|
||||
const dir = rest ? resolve(home, rest) : home;
|
||||
return { dir };
|
||||
}
|
||||
// Has a trailing segment → list parent, filter by segment
|
||||
const dir = resolve(home, dirname(rest));
|
||||
return { dir, filter: basename(rest) };
|
||||
}
|
||||
|
||||
if (raw.startsWith("/")) {
|
||||
if (raw === "/") {return { dir: "/" };}
|
||||
if (raw.endsWith("/")) {
|
||||
return { dir: resolve(raw) };
|
||||
}
|
||||
const dir = dirname(resolve(raw));
|
||||
return { dir, filter: basename(raw) };
|
||||
}
|
||||
|
||||
if (raw.startsWith("../") || raw === "..") {
|
||||
const resolved = resolve(workspaceRoot, raw);
|
||||
if (raw.endsWith("/") || raw === "..") {
|
||||
return { dir: resolved };
|
||||
}
|
||||
return { dir: dirname(resolved), filter: basename(resolved) };
|
||||
}
|
||||
|
||||
if (raw.startsWith("./")) {
|
||||
const rest = raw.slice(2);
|
||||
if (!rest || rest.endsWith("/")) {
|
||||
const dir = rest ? resolve(workspaceRoot, rest) : workspaceRoot;
|
||||
return { dir };
|
||||
}
|
||||
const dir = resolve(workspaceRoot, dirname(rest));
|
||||
return { dir, filter: basename(rest) };
|
||||
}
|
||||
|
||||
// Contains a slash → treat as relative path from workspace
|
||||
if (raw.includes("/")) {
|
||||
if (raw.endsWith("/")) {
|
||||
return { dir: resolve(workspaceRoot, raw) };
|
||||
}
|
||||
const dir = resolve(workspaceRoot, dirname(raw));
|
||||
return { dir, filter: basename(raw) };
|
||||
}
|
||||
|
||||
// No path separator → this is a filename search
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DuckDB object & entry search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ObjectRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
display_field?: string;
|
||||
};
|
||||
|
||||
type FieldRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
sort_order?: number;
|
||||
};
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
function resolveDisplayField(obj: ObjectRow, fields: FieldRow[]): string {
|
||||
if (obj.display_field) {return obj.display_field;}
|
||||
const nameField = fields.find(
|
||||
(f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
|
||||
);
|
||||
if (nameField) {return nameField.name;}
|
||||
const textField = fields.find((f) => f.type === "text");
|
||||
if (textField) {return textField.name;}
|
||||
return fields[0]?.name ?? "id";
|
||||
}
|
||||
|
||||
/** Read icon from .object.yaml if present. */
|
||||
function readObjectIcon(workspaceRoot: string, objName: string): string | undefined {
|
||||
// Walk workspace to find a folder matching objName that has .object.yaml
|
||||
function walk(dir: string, depth: number): string | undefined {
|
||||
if (depth > 4) {return undefined;}
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith(".")) {continue;}
|
||||
if (entry.name === objName) {
|
||||
const yamlPath = join(dir, entry.name, ".object.yaml");
|
||||
if (existsSync(yamlPath)) {
|
||||
const parsed = parseSimpleYaml(readFileSync(yamlPath, "utf-8"));
|
||||
if (parsed.icon) {return dbStr(parsed.icon);}
|
||||
}
|
||||
}
|
||||
const found = walk(join(dir, entry.name), depth + 1);
|
||||
if (found) {return found;}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
return undefined;
|
||||
}
|
||||
return walk(workspaceRoot, 0);
|
||||
}
|
||||
|
||||
/** Search objects by name (case-insensitive substring). */
|
||||
async function searchObjects(
|
||||
query: string,
|
||||
workspaceRoot: string,
|
||||
max: number,
|
||||
): Promise<SuggestItem[]> {
|
||||
const sql = query
|
||||
? `SELECT * FROM objects WHERE LOWER(name) LIKE LOWER('%${sqlEscape(query)}%') ORDER BY name LIMIT ${max}`
|
||||
: `SELECT * FROM objects ORDER BY name LIMIT ${max}`;
|
||||
const objects = await duckdbQueryAllAsync<ObjectRow>(sql, "name");
|
||||
|
||||
const items: SuggestItem[] = [];
|
||||
for (const obj of objects) {
|
||||
const yamlIcon = readObjectIcon(workspaceRoot, obj.name);
|
||||
items.push({
|
||||
name: obj.name,
|
||||
path: `workspace:object:${obj.name}`,
|
||||
type: "object",
|
||||
icon: yamlIcon ?? obj.icon,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Safely convert an unknown DB value to a display string. */
|
||||
function dbStr(val: unknown): string {
|
||||
if (val == null) {return "";}
|
||||
if (typeof val === "object") {return JSON.stringify(val);}
|
||||
return String(val as string | number | boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search entries across all objects using a single UNION ALL query per DB.
|
||||
* Each object's pivot view (v_<name>) is searched by display field with ILIKE.
|
||||
* This avoids spawning N DuckDB CLI processes per object.
|
||||
*/
|
||||
async function searchEntries(
|
||||
query: string,
|
||||
max: number,
|
||||
): Promise<SuggestItem[]> {
|
||||
const dbPaths = discoverDuckDBPaths();
|
||||
if (dbPaths.length === 0 || !query) {return [];}
|
||||
|
||||
const items: SuggestItem[] = [];
|
||||
const seenObjects = new Set<string>();
|
||||
const likePattern = `%${sqlEscape(query)}%`;
|
||||
|
||||
for (const dbPath of dbPaths) {
|
||||
if (items.length >= max) {break;}
|
||||
|
||||
// Step 1: get objects + display fields in a single query
|
||||
type ObjFieldRow = ObjectRow & { field_name: string; field_type: string };
|
||||
const objFields = await duckdbQueryOnFileAsync<ObjFieldRow>(
|
||||
dbPath,
|
||||
`SELECT o.*, f.name as field_name, f.type as field_type
|
||||
FROM objects o
|
||||
LEFT JOIN fields f ON f.object_id = o.id
|
||||
ORDER BY o.name, f.sort_order`,
|
||||
);
|
||||
|
||||
// Group fields by object and resolve display fields
|
||||
const objectMap = new Map<string, { obj: ObjectRow; displayField: string }>();
|
||||
const fieldsByObj = new Map<string, FieldRow[]>();
|
||||
for (const row of objFields) {
|
||||
if (seenObjects.has(row.name)) {continue;}
|
||||
if (!fieldsByObj.has(row.id)) {fieldsByObj.set(row.id, []);}
|
||||
if (row.field_name) {
|
||||
fieldsByObj.get(row.id)!.push({
|
||||
id: row.id,
|
||||
name: row.field_name,
|
||||
type: row.field_type,
|
||||
});
|
||||
}
|
||||
if (!objectMap.has(row.name)) {
|
||||
const fields = fieldsByObj.get(row.id) ?? [];
|
||||
objectMap.set(row.name, {
|
||||
obj: row,
|
||||
displayField: resolveDisplayField(row, fields),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve display fields now that all fields are collected
|
||||
for (const [name, entry] of objectMap) {
|
||||
const fields = fieldsByObj.get(entry.obj.id) ?? [];
|
||||
entry.displayField = resolveDisplayField(entry.obj, fields);
|
||||
seenObjects.add(name);
|
||||
}
|
||||
|
||||
if (objectMap.size === 0) {continue;}
|
||||
|
||||
// Step 2: build a single UNION ALL query searching all pivot views
|
||||
// Wrap each SELECT in parens so per-view LIMIT is valid DuckDB syntax
|
||||
const unionParts: string[] = [];
|
||||
for (const [name, { displayField }] of objectMap) {
|
||||
const safeDisplay = sqlEscape(displayField);
|
||||
unionParts.push(
|
||||
`(SELECT '${sqlEscape(name)}' as _obj_name, entry_id, "${safeDisplay}" as _display
|
||||
FROM v_${name}
|
||||
WHERE LOWER(CAST("${safeDisplay}" AS VARCHAR)) LIKE LOWER('${likePattern}')
|
||||
LIMIT ${max})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (unionParts.length === 0) {continue;}
|
||||
|
||||
type EntryHit = { _obj_name: string; entry_id: string; _display: string };
|
||||
const hits = await duckdbQueryOnFileAsync<EntryHit>(
|
||||
dbPath,
|
||||
`${unionParts.join(" UNION ALL ")} LIMIT ${max}`,
|
||||
);
|
||||
|
||||
for (const hit of hits) {
|
||||
if (items.length >= max) {return items;}
|
||||
if (!hit.entry_id || !hit._display) {continue;}
|
||||
const objInfo = objectMap.get(hit._obj_name);
|
||||
items.push({
|
||||
name: String(hit._display),
|
||||
path: `workspace:entry:${hit._obj_name}:${hit.entry_id}`,
|
||||
type: "entry",
|
||||
icon: objInfo?.obj.icon,
|
||||
objectName: hit._obj_name,
|
||||
entryId: hit.entry_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const pathQuery = url.searchParams.get("path");
|
||||
const searchQuery = url.searchParams.get("q");
|
||||
const workspaceRoot = resolveWorkspaceRoot() ?? homedir();
|
||||
|
||||
// Search mode: find files, objects, and entries by name
|
||||
if (searchQuery) {
|
||||
// File search: workspace only (skip expensive home dir traversal)
|
||||
const fileResults: SuggestItem[] = [];
|
||||
searchFiles(workspaceRoot, searchQuery, fileResults, 15);
|
||||
|
||||
// DuckDB search: objects and entries (sequential to avoid lock contention)
|
||||
const objectResults = await searchObjects(searchQuery, workspaceRoot, 10);
|
||||
const entryResults = await searchEntries(searchQuery, 15);
|
||||
|
||||
// Deduplicate: if an object matches, remove the duplicate folder
|
||||
const objectNames = new Set(objectResults.map((o) => o.name));
|
||||
const dedupedFiles = fileResults.filter(
|
||||
(f) => !(f.type === "folder" && objectNames.has(f.name)),
|
||||
);
|
||||
|
||||
// Merge: objects first, then entries, then files
|
||||
const items = [...objectResults, ...entryResults, ...dedupedFiles].slice(0, 30);
|
||||
return Response.json({ items });
|
||||
}
|
||||
|
||||
// Browse mode: resolve path and list directory
|
||||
if (pathQuery) {
|
||||
const resolved = resolvePath(pathQuery, workspaceRoot);
|
||||
if (!resolved) {
|
||||
const results: SuggestItem[] = [];
|
||||
searchFiles(workspaceRoot, pathQuery, results, 20);
|
||||
return Response.json({ items: results });
|
||||
}
|
||||
const items = listDir(resolved.dir, resolved.filter);
|
||||
return Response.json({ items });
|
||||
}
|
||||
|
||||
// Default: list workspace root + all objects
|
||||
const fileItems = listDir(workspaceRoot);
|
||||
const objectItems = await searchObjects("", workspaceRoot, 20);
|
||||
// Deduplicate: if an object also appears as a folder, keep the object version
|
||||
const objectNames = new Set(objectItems.map((o) => o.name));
|
||||
const dedupedFiles = fileItems.filter(
|
||||
(f) => !(f.type === "folder" && objectNames.has(f.name)),
|
||||
);
|
||||
return Response.json({ items: [...objectItems, ...dedupedFiles] });
|
||||
}
|
||||
69
apps/web/app/api/workspace/thumbnail/route.ts
Normal file
69
apps/web/app/api/workspace/thumbnail/route.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { existsSync, readFileSync, mkdirSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, basename } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { safeResolvePath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const THUMB_DIR = join(tmpdir(), "ironclaw-thumbs");
|
||||
mkdirSync(THUMB_DIR, { recursive: true });
|
||||
|
||||
/**
|
||||
* Resolve a file path — supports absolute paths and workspace-relative paths.
|
||||
*/
|
||||
function resolveFile(path: string): string | null {
|
||||
if (path.startsWith("/")) {
|
||||
const abs = resolve(path);
|
||||
if (existsSync(abs)) {return abs;}
|
||||
}
|
||||
return safeResolvePath(path) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/workspace/thumbnail?path=...&size=200
|
||||
* Uses macOS Quick Look (qlmanage) to generate a thumbnail image.
|
||||
* Returns the thumbnail as image/png.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.searchParams.get("path");
|
||||
const size = url.searchParams.get("size") ?? "200";
|
||||
|
||||
if (!path) {
|
||||
return new Response("Missing path", { status: 400 });
|
||||
}
|
||||
|
||||
const absolute = resolveFile(path);
|
||||
if (!absolute) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
// The thumbnail output filename is <original-basename>.png
|
||||
const thumbName = `${basename(absolute)}.png`;
|
||||
const thumbPath = join(THUMB_DIR, thumbName);
|
||||
|
||||
try {
|
||||
// Generate thumbnail using macOS Quick Look
|
||||
execSync(
|
||||
`qlmanage -t -s ${parseInt(size, 10)} -o "${THUMB_DIR}" "${absolute}" 2>/dev/null`,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
if (!existsSync(thumbPath)) {
|
||||
return new Response("Thumbnail generation failed", { status: 500 });
|
||||
}
|
||||
|
||||
const buffer = readFileSync(thumbPath);
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response("Thumbnail generation failed", { status: 500 });
|
||||
}
|
||||
}
|
||||
233
apps/web/app/api/workspace/tree-browse.test.ts
Normal file
233
apps/web/app/api/workspace/tree-browse.test.ts
Normal file
@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { Dirent } from "node:fs";
|
||||
|
||||
// Mock node:fs
|
||||
vi.mock("node:fs", () => ({
|
||||
readdirSync: vi.fn(() => []),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
existsSync: vi.fn(() => false),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => false, size: 100 })),
|
||||
}));
|
||||
|
||||
// Mock node:os
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
|
||||
// Mock workspace
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
resolveWorkspaceRoot: vi.fn(() => null),
|
||||
parseSimpleYaml: vi.fn(() => ({})),
|
||||
duckdbQueryAll: vi.fn(() => []),
|
||||
duckdbQueryAllAsync: vi.fn(async () => []),
|
||||
isDatabaseFile: vi.fn(() => false),
|
||||
discoverDuckDBPaths: vi.fn(() => []),
|
||||
resolveDuckdbBin: vi.fn(() => null),
|
||||
safeResolvePath: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
function makeDirent(name: string, isDir: boolean): Dirent {
|
||||
return {
|
||||
name,
|
||||
isDirectory: () => isDir,
|
||||
isFile: () => !isDir,
|
||||
isBlockDevice: () => false,
|
||||
isCharacterDevice: () => false,
|
||||
isFIFO: () => false,
|
||||
isSocket: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
path: "",
|
||||
parentPath: "",
|
||||
} as Dirent;
|
||||
}
|
||||
|
||||
describe("Workspace Tree & Browse API", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mock("node:fs", () => ({
|
||||
readdirSync: vi.fn(() => []),
|
||||
readFileSync: vi.fn(() => ""),
|
||||
existsSync: vi.fn(() => false),
|
||||
statSync: vi.fn(() => ({ isDirectory: () => false, size: 100 })),
|
||||
}));
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: vi.fn(() => "/home/testuser"),
|
||||
}));
|
||||
vi.mock("@/lib/workspace", () => ({
|
||||
resolveWorkspaceRoot: vi.fn(() => null),
|
||||
parseSimpleYaml: vi.fn(() => ({})),
|
||||
duckdbQueryAll: vi.fn(() => []),
|
||||
duckdbQueryAllAsync: vi.fn(async () => []),
|
||||
isDatabaseFile: vi.fn(() => false),
|
||||
discoverDuckDBPaths: vi.fn(() => []),
|
||||
resolveDuckdbBin: vi.fn(() => null),
|
||||
safeResolvePath: vi.fn(() => null),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/tree ────────────────────────────────────
|
||||
|
||||
describe("GET /api/workspace/tree", () => {
|
||||
it("returns tree with exists=false when no workspace root", async () => {
|
||||
const { GET } = await import("./tree/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.exists).toBe(false);
|
||||
expect(json.tree).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns tree with workspace files", async () => {
|
||||
const { resolveWorkspaceRoot } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
|
||||
const { readdirSync: mockReaddir, existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockImplementation((dir) => {
|
||||
if (String(dir) === "/ws") {
|
||||
return [
|
||||
makeDirent("knowledge", true),
|
||||
makeDirent("readme.md", false),
|
||||
] as unknown as Dirent[];
|
||||
}
|
||||
return [] as unknown as Dirent[];
|
||||
});
|
||||
|
||||
const { GET } = await import("./tree/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.exists).toBe(true);
|
||||
expect(json.tree.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("includes workspaceRoot in response", async () => {
|
||||
const { resolveWorkspaceRoot } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
|
||||
const { existsSync: mockExists } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
|
||||
const { GET } = await import("./tree/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.workspaceRoot).toBe("/ws");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/browse ──────────────────────────────────
|
||||
|
||||
describe("GET /api/workspace/browse", () => {
|
||||
it("returns directory listing", async () => {
|
||||
const { existsSync: mockExists, readdirSync: mockReaddir, statSync: mockStat } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockReturnValue([
|
||||
makeDirent("file.txt", false),
|
||||
makeDirent("subfolder", true),
|
||||
] as unknown as Dirent[]);
|
||||
vi.mocked(mockStat).mockReturnValue({ isDirectory: () => false, size: 100 } as never);
|
||||
|
||||
const { GET } = await import("./browse/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/browse?dir=/tmp/test");
|
||||
const res = await GET(req);
|
||||
const json = await res.json();
|
||||
expect(json.entries).toBeDefined();
|
||||
expect(json.currentDir).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns parentDir for nested directories", async () => {
|
||||
const { existsSync: mockExists, readdirSync: mockReaddir, statSync: mockStat } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockReturnValue([]);
|
||||
vi.mocked(mockStat).mockReturnValue({ isDirectory: () => true, size: 0 } as never);
|
||||
|
||||
const { GET } = await import("./browse/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/browse?dir=/tmp/test/sub");
|
||||
const res = await GET(req);
|
||||
const json = await res.json();
|
||||
expect(json.parentDir).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/suggest-files ────────────────────────────
|
||||
|
||||
describe("GET /api/workspace/suggest-files", () => {
|
||||
it("returns suggestions when workspace exists", async () => {
|
||||
const { resolveWorkspaceRoot } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
|
||||
const { existsSync: mockExists, readdirSync: mockReaddir } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockReturnValue([
|
||||
makeDirent("doc.md", false),
|
||||
] as unknown as Dirent[]);
|
||||
|
||||
const { GET } = await import("./suggest-files/route.js");
|
||||
const req = new Request("http://localhost/api/workspace/suggest-files?q=doc");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.items).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/context ──────────────────────────────────
|
||||
|
||||
describe("GET /api/workspace/context", () => {
|
||||
it("returns exists=false when no workspace root", async () => {
|
||||
const { resolveWorkspaceRoot } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./context/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.exists).toBe(false);
|
||||
});
|
||||
|
||||
it("returns context when workspace_context.yaml exists", async () => {
|
||||
const { resolveWorkspaceRoot, parseSimpleYaml } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
|
||||
vi.mocked(parseSimpleYaml).mockReturnValue({ org_name: "Acme", org_slug: "acme" });
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReadFile).mockReturnValue("org_name: Acme" as never);
|
||||
|
||||
const { GET } = await import("./context/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.exists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/workspace/search-index ─────────────────────────────
|
||||
|
||||
describe("GET /api/workspace/search-index", () => {
|
||||
it("returns empty items when no workspace", async () => {
|
||||
const { resolveWorkspaceRoot } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue(null);
|
||||
|
||||
const { GET } = await import("./search-index/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns file items from workspace tree", async () => {
|
||||
const { resolveWorkspaceRoot, duckdbQueryAll } = await import("@/lib/workspace");
|
||||
vi.mocked(resolveWorkspaceRoot).mockReturnValue("/ws");
|
||||
vi.mocked(duckdbQueryAll).mockReturnValue([]);
|
||||
const { existsSync: mockExists, readdirSync: mockReaddir } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReaddir).mockImplementation((dir) => {
|
||||
if (String(dir) === "/ws") {
|
||||
return [makeDirent("readme.md", false)] as unknown as Dirent[];
|
||||
}
|
||||
return [] as unknown as Dirent[];
|
||||
});
|
||||
|
||||
const { GET } = await import("./search-index/route.js");
|
||||
const res = await GET();
|
||||
const json = await res.json();
|
||||
expect(json.items.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
264
apps/web/app/api/workspace/tree/route.ts
Normal file
264
apps/web/app/api/workspace/tree/route.ts
Normal file
@ -0,0 +1,264 @@
|
||||
import { readdirSync, readFileSync, existsSync, statSync, type Dirent } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveWorkspaceRoot, resolveOpenClawStateDir, getEffectiveProfile, parseSimpleYaml, duckdbQueryAll, isDatabaseFile } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
path: string; // relative to workspace root (or ~skills/ for virtual nodes)
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
/** Virtual nodes live outside the main workspace (e.g. Skills, Memories). */
|
||||
virtual?: boolean;
|
||||
/** True when the entry is a symbolic link. */
|
||||
symlink?: boolean;
|
||||
};
|
||||
|
||||
type DbObject = {
|
||||
name: string;
|
||||
icon?: string;
|
||||
default_view?: string;
|
||||
};
|
||||
|
||||
/** Read .object.yaml metadata from a directory if it exists. */
|
||||
function readObjectMeta(
|
||||
dirPath: string,
|
||||
): { icon?: string; defaultView?: string } | null {
|
||||
const yamlPath = join(dirPath, ".object.yaml");
|
||||
if (!existsSync(yamlPath)) {return null;}
|
||||
|
||||
try {
|
||||
const content = readFileSync(yamlPath, "utf-8");
|
||||
const parsed = parseSimpleYaml(content);
|
||||
return {
|
||||
icon: parsed.icon as string | undefined,
|
||||
defaultView: parsed.default_view as string | undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query ALL discovered DuckDB files for objects so we can identify object
|
||||
* directories even when .object.yaml files are missing.
|
||||
* Shallower databases win on name conflicts (parent priority).
|
||||
*/
|
||||
function loadDbObjects(): Map<string, DbObject> {
|
||||
const map = new Map<string, DbObject>();
|
||||
const rows = duckdbQueryAll<DbObject & { name: string }>(
|
||||
"SELECT name, icon, default_view FROM objects",
|
||||
"name",
|
||||
);
|
||||
for (const row of rows) {
|
||||
map.set(row.name, row);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Resolve a dirent's effective type, following symlinks to their target. */
|
||||
function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null {
|
||||
if (entry.isDirectory()) {return "directory";}
|
||||
if (entry.isFile()) {return "file";}
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
const st = statSync(absPath);
|
||||
if (st.isDirectory()) {return "directory";}
|
||||
if (st.isFile()) {return "file";}
|
||||
} catch {
|
||||
// Broken symlink -- skip
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recursively build a tree from a workspace directory. */
|
||||
function buildTree(
|
||||
absDir: string,
|
||||
relativeBase: string,
|
||||
dbObjects: Map<string, DbObject>,
|
||||
showHidden = false,
|
||||
): TreeNode[] {
|
||||
const nodes: TreeNode[] = [];
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(absDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const filtered = entries.filter((e) => {
|
||||
// .object.yaml is always needed for metadata; also shown as a node when showHidden is on
|
||||
if (e.name === ".object.yaml") {return true;}
|
||||
if (e.name.startsWith(".")) {return showHidden;}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort: directories first, then files, alphabetical within each group
|
||||
const sorted = filtered.toSorted((a, b) => {
|
||||
const absA = join(absDir, a.name);
|
||||
const absB = join(absDir, b.name);
|
||||
const typeA = resolveEntryType(a, absA);
|
||||
const typeB = resolveEntryType(b, absB);
|
||||
const dirA = typeA === "directory";
|
||||
const dirB = typeB === "directory";
|
||||
if (dirA && !dirB) {return -1;}
|
||||
if (!dirA && dirB) {return 1;}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
for (const entry of sorted) {
|
||||
// .object.yaml is consumed for metadata; only show it as a visible node when revealing hidden files
|
||||
if (entry.name === ".object.yaml" && !showHidden) {continue;}
|
||||
|
||||
const absPath = join(absDir, entry.name);
|
||||
const relPath = relativeBase
|
||||
? `${relativeBase}/${entry.name}`
|
||||
: entry.name;
|
||||
|
||||
const isSymlink = entry.isSymbolicLink();
|
||||
const effectiveType = resolveEntryType(entry, absPath);
|
||||
|
||||
if (effectiveType === "directory") {
|
||||
const objectMeta = readObjectMeta(absPath);
|
||||
const dbObject = dbObjects.get(entry.name);
|
||||
const children = buildTree(absPath, relPath, dbObjects, showHidden);
|
||||
|
||||
if (objectMeta || dbObject) {
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: "object",
|
||||
icon: objectMeta?.icon ?? dbObject?.icon,
|
||||
defaultView:
|
||||
((objectMeta?.defaultView ?? dbObject?.default_view) as
|
||||
| "table"
|
||||
| "kanban") ?? "table",
|
||||
children: children.length > 0 ? children : undefined,
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
} else {
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: "folder",
|
||||
children: children.length > 0 ? children : undefined,
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
}
|
||||
} else if (effectiveType === "file") {
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const isReport = entry.name.endsWith(".report.json");
|
||||
const isDocument = ext === "md" || ext === "mdx";
|
||||
const isDatabase = isDatabaseFile(entry.name);
|
||||
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file",
|
||||
...(isSymlink && { symlink: true }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// --- Virtual folder builders ---
|
||||
|
||||
/** Parse YAML frontmatter from a SKILL.md file (lightweight). */
|
||||
function parseSkillFrontmatter(content: string): { name?: string; emoji?: string } {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!match) {return {};}
|
||||
const yaml = match[1];
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of yaml.split("\n")) {
|
||||
const kv = line.match(/^(\w+)\s*:\s*(.+)/);
|
||||
if (kv) {result[kv[1]] = kv[2].replace(/^["']|["']$/g, "").trim();}
|
||||
}
|
||||
return { name: result.name, emoji: result.emoji };
|
||||
}
|
||||
|
||||
/** Build a virtual "Skills" folder from <stateDir>/skills/. */
|
||||
function buildSkillsVirtualFolder(): TreeNode | null {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const dirs = [
|
||||
join(stateDir, "skills"),
|
||||
];
|
||||
|
||||
const children: TreeNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) {continue;}
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || seen.has(entry.name)) {continue;}
|
||||
const skillMdPath = join(dir, entry.name, "SKILL.md");
|
||||
if (!existsSync(skillMdPath)) {continue;}
|
||||
|
||||
seen.add(entry.name);
|
||||
let displayName = entry.name;
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, "utf-8");
|
||||
const meta = parseSkillFrontmatter(content);
|
||||
if (meta.name) {displayName = meta.name;}
|
||||
if (meta.emoji) {displayName = `${meta.emoji} ${displayName}`;}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
|
||||
children.push({
|
||||
name: displayName,
|
||||
path: `~skills/${entry.name}/SKILL.md`,
|
||||
type: "document",
|
||||
virtual: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// dir unreadable
|
||||
}
|
||||
}
|
||||
|
||||
if (children.length === 0) {return null;}
|
||||
children.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return {
|
||||
name: "Skills",
|
||||
path: "~skills",
|
||||
type: "folder",
|
||||
virtual: true,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const showHidden = url.searchParams.get("showHidden") === "1";
|
||||
|
||||
const openclawDir = resolveOpenClawStateDir();
|
||||
const profile = getEffectiveProfile();
|
||||
const root = resolveWorkspaceRoot();
|
||||
if (!root) {
|
||||
const tree: TreeNode[] = [];
|
||||
const skillsFolder = buildSkillsVirtualFolder();
|
||||
if (skillsFolder) {tree.push(skillsFolder);}
|
||||
return Response.json({ tree, exists: false, workspaceRoot: null, openclawDir, profile });
|
||||
}
|
||||
|
||||
const dbObjects = loadDbObjects();
|
||||
|
||||
const tree = buildTree(root, "", dbObjects, showHidden);
|
||||
|
||||
const skillsFolder = buildSkillsVirtualFolder();
|
||||
if (skillsFolder) {tree.push(skillsFolder);}
|
||||
|
||||
return Response.json({ tree, exists: true, workspaceRoot: root, openclawDir, profile });
|
||||
}
|
||||
73
apps/web/app/api/workspace/upload/route.ts
Normal file
73
apps/web/app/api/workspace/upload/route.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { resolveWorkspaceRoot, safeResolveNewPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_SIZE = 25 * 1024 * 1024; // 25 MB
|
||||
|
||||
/**
|
||||
* POST /api/workspace/upload
|
||||
* Accepts multipart form data with a "file" field.
|
||||
* Saves to assets/<timestamp>-<filename> inside the workspace.
|
||||
* Returns { ok, path } where path is workspace-relative.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
const root = resolveWorkspaceRoot();
|
||||
if (!root) {
|
||||
return Response.json(
|
||||
{ error: "Workspace not found" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await req.formData();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid form data" }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!file || !(file instanceof File)) {
|
||||
return Response.json(
|
||||
{ error: "Missing 'file' field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (file.size > MAX_SIZE) {
|
||||
return Response.json(
|
||||
{ error: "File is too large (max 25 MB)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Build a safe filename: timestamp + sanitized original name
|
||||
const safeName = file.name
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.replace(/_{2,}/g, "_");
|
||||
const relPath = join("assets", `${Date.now()}-${safeName}`);
|
||||
|
||||
const absPath = safeResolveNewPath(relPath);
|
||||
if (!absPath) {
|
||||
return Response.json(
|
||||
{ error: "Invalid path" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(absPath), { recursive: true });
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
writeFileSync(absPath, buffer);
|
||||
return Response.json({ ok: true, path: relPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Upload failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
170
apps/web/app/api/workspace/virtual-file/route.ts
Normal file
170
apps/web/app/api/workspace/virtual-file/route.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import { join, dirname, resolve, normalize } from "node:path";
|
||||
import { resolveOpenClawStateDir, resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* Resolve a virtual path (~skills/... or ~memories/...) to an absolute filesystem path.
|
||||
* Returns null if the path is invalid or tries to escape.
|
||||
*/
|
||||
function resolveVirtualPath(virtualPath: string): string | null {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const workspaceDir = resolveWorkspaceRoot() ?? join(stateDir, "workspace");
|
||||
|
||||
if (virtualPath.startsWith("~skills/")) {
|
||||
// ~skills/<skillName>/SKILL.md
|
||||
const rest = virtualPath.slice("~skills/".length);
|
||||
// Validate: must be <name>/SKILL.md
|
||||
const parts = rest.split("/");
|
||||
if (parts.length !== 2 || parts[1] !== "SKILL.md" || !parts[0]) {
|
||||
return null;
|
||||
}
|
||||
const skillName = parts[0];
|
||||
// Prevent path traversal
|
||||
if (skillName.includes("..") || skillName.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check workspace skills first, then managed skills
|
||||
const candidates = [
|
||||
join(workspaceDir, "skills", skillName, "SKILL.md"),
|
||||
join(stateDir, "skills", skillName, "SKILL.md"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// Default to workspace skills dir for new files
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
if (virtualPath.startsWith("~memories/")) {
|
||||
const rest = virtualPath.slice("~memories/".length);
|
||||
// Prevent path traversal
|
||||
if (rest.includes("..") || rest.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rest === "MEMORY.md") {
|
||||
// Check both casing
|
||||
for (const filename of ["MEMORY.md", "memory.md"]) {
|
||||
const candidate = join(workspaceDir, filename);
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// Default to MEMORY.md for new files
|
||||
return join(workspaceDir, "MEMORY.md");
|
||||
}
|
||||
|
||||
// Daily log: must be a .md file in the memory/ subdirectory
|
||||
if (!rest.endsWith(".md")) {
|
||||
return null;
|
||||
}
|
||||
return join(workspaceDir, "memory", rest);
|
||||
}
|
||||
|
||||
if (virtualPath.startsWith("~workspace/")) {
|
||||
const rest = virtualPath.slice("~workspace/".length);
|
||||
// Only allow direct filenames (no subdirectories, no traversal)
|
||||
if (!rest || rest.includes("..") || rest.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
return join(workspaceDir, rest);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Double-check that the resolved path stays within expected directories.
|
||||
*/
|
||||
function isSafePath(absPath: string): boolean {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const workspaceDir = resolveWorkspaceRoot() ?? join(stateDir, "workspace");
|
||||
const normalized = normalize(resolve(absPath));
|
||||
const allowed = [
|
||||
normalize(join(stateDir, "skills")),
|
||||
normalize(join(workspaceDir, "skills")),
|
||||
normalize(workspaceDir),
|
||||
];
|
||||
return allowed.some((dir) => normalized.startsWith(dir));
|
||||
}
|
||||
|
||||
/** Extensions recognized as code files for syntax-highlighted viewing. */
|
||||
const VIRTUAL_CODE_EXTENSIONS = new Set([
|
||||
"ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs",
|
||||
"java", "kt", "swift", "c", "cpp", "h", "hpp", "cs", "css", "scss",
|
||||
"less", "html", "htm", "xml", "json", "jsonc", "toml", "sh", "bash",
|
||||
"zsh", "fish", "ps1", "sql", "graphql", "gql", "diff", "patch",
|
||||
"ini", "env", "tf", "proto", "zig", "lua", "php",
|
||||
]);
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.searchParams.get("path");
|
||||
|
||||
if (!path) {
|
||||
return Response.json({ error: "Missing 'path' query parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const absPath = resolveVirtualPath(path);
|
||||
if (!absPath || !isSafePath(absPath)) {
|
||||
return Response.json({ error: "Invalid virtual path" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!existsSync(absPath)) {
|
||||
return Response.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(absPath, "utf-8");
|
||||
const ext = absPath.split(".").pop()?.toLowerCase();
|
||||
let type: "markdown" | "yaml" | "code" | "text" = "text";
|
||||
if (ext === "md" || ext === "mdx") {type = "markdown";}
|
||||
else if (ext === "yaml" || ext === "yml") {type = "yaml";}
|
||||
else if (VIRTUAL_CODE_EXTENSIONS.has(ext ?? "")) {type = "code";}
|
||||
return Response.json({ content, type });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Read failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { path?: string; content?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { path: virtualPath, content } = body;
|
||||
if (!virtualPath || typeof virtualPath !== "string" || typeof content !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Missing 'path' and 'content' fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const absPath = resolveVirtualPath(virtualPath);
|
||||
if (!absPath || !isSafePath(absPath)) {
|
||||
return Response.json({ error: "Invalid virtual path" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(absPath), { recursive: true });
|
||||
writeFileSync(absPath, content, "utf-8");
|
||||
return Response.json({ ok: true, path: virtualPath });
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : "Write failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
148
apps/web/app/api/workspace/watch/route.ts
Normal file
148
apps/web/app/api/workspace/watch/route.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton watcher: one chokidar instance shared across all SSE connections.
|
||||
// Uses polling (no native fs.watch FDs) so it doesn't compete with Next.js's
|
||||
// own dev watcher for the macOS per-process file-descriptor limit.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Listener = (type: string, relPath: string) => void;
|
||||
|
||||
let listeners = new Set<Listener>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let sharedWatcher: any = null;
|
||||
let sharedRoot: string | null = null;
|
||||
let _watcherReady = false;
|
||||
|
||||
async function ensureWatcher(root: string) {
|
||||
if (sharedWatcher && sharedRoot === root) {return;}
|
||||
|
||||
// Root changed (e.g. profile switch) -- close the old watcher first.
|
||||
if (sharedWatcher) {
|
||||
await sharedWatcher.close();
|
||||
sharedWatcher = null;
|
||||
sharedRoot = null;
|
||||
_watcherReady = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const chokidar = await import("chokidar");
|
||||
sharedRoot = root;
|
||||
sharedWatcher = chokidar.watch(root, {
|
||||
ignoreInitial: true,
|
||||
usePolling: true,
|
||||
interval: 1500,
|
||||
binaryInterval: 3000,
|
||||
ignored: [
|
||||
/(^|[\\/])node_modules([\\/]|$)/,
|
||||
/(^|[\\/])\.git([\\/]|$)/,
|
||||
/(^|[\\/])\.next([\\/]|$)/,
|
||||
/(^|[\\/])dist([\\/]|$)/,
|
||||
/\.duckdb\.wal$/,
|
||||
/\.duckdb\.tmp$/,
|
||||
],
|
||||
depth: 5,
|
||||
});
|
||||
|
||||
sharedWatcher.on("all", (eventType: string, filePath: string) => {
|
||||
const rel = filePath.startsWith(root)
|
||||
? filePath.slice(root.length + 1)
|
||||
: filePath;
|
||||
for (const fn of listeners) {fn(eventType, rel);}
|
||||
});
|
||||
|
||||
sharedWatcher.once("ready", () => {_watcherReady = true;});
|
||||
|
||||
sharedWatcher.on("error", () => {
|
||||
// Swallow; polling mode shouldn't hit EMFILE but be safe.
|
||||
});
|
||||
} catch {
|
||||
// chokidar unavailable -- listeners simply won't fire.
|
||||
}
|
||||
}
|
||||
|
||||
function stopWatcherIfIdle() {
|
||||
if (listeners.size > 0 || !sharedWatcher) {return;}
|
||||
sharedWatcher.close();
|
||||
sharedWatcher = null;
|
||||
sharedRoot = null;
|
||||
_watcherReady = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/workspace/watch
|
||||
*
|
||||
* Server-Sent Events endpoint that watches the workspace for file changes.
|
||||
* Falls back gracefully if chokidar is unavailable.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const root = resolveWorkspaceRoot();
|
||||
if (!root) {
|
||||
return new Response("Workspace not found", { status: 404 });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue(encoder.encode("event: connected\ndata: {}\n\n"));
|
||||
|
||||
const listener: Listener = (_type, _rel) => {
|
||||
if (closed) {return;}
|
||||
if (debounceTimer) {clearTimeout(debounceTimer);}
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (closed) {return;}
|
||||
try {
|
||||
const data = JSON.stringify({ type: _type, path: _rel });
|
||||
controller.enqueue(encoder.encode(`event: change\ndata: ${data}\n\n`));
|
||||
} catch { /* stream closed */ }
|
||||
}, 300);
|
||||
};
|
||||
|
||||
heartbeat = setInterval(() => {
|
||||
if (closed) {return;}
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": heartbeat\n\n"));
|
||||
} catch { /* closed */ }
|
||||
}, 30_000);
|
||||
|
||||
function teardown() {
|
||||
if (closed) {return;}
|
||||
closed = true;
|
||||
listeners.delete(listener);
|
||||
if (heartbeat) {clearInterval(heartbeat);}
|
||||
if (debounceTimer) {clearTimeout(debounceTimer);}
|
||||
stopWatcherIfIdle();
|
||||
}
|
||||
|
||||
req.signal.addEventListener("abort", teardown, { once: true });
|
||||
|
||||
listeners.add(listener);
|
||||
await ensureWatcher(root);
|
||||
|
||||
if (!sharedWatcher) {
|
||||
controller.enqueue(
|
||||
encoder.encode("event: error\ndata: {\"error\":\"File watching unavailable\"}\n\n"),
|
||||
);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
1726
apps/web/app/components/chain-of-thought.tsx
Normal file
1726
apps/web/app/components/chain-of-thought.tsx
Normal file
File diff suppressed because it is too large
Load Diff
426
apps/web/app/components/charts/chart-panel.tsx
Normal file
426
apps/web/app/components/charts/chart-panel.tsx
Normal file
@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
RadarChart,
|
||||
Radar,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
FunnelChart,
|
||||
Funnel,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import type { PanelConfig } from "./types";
|
||||
|
||||
// --- Color palette derived from CSS variables + accessible defaults ---
|
||||
|
||||
const CHART_PALETTE = [
|
||||
"#2563eb", // accent
|
||||
"#60a5fa", // blue
|
||||
"#22c55e", // green
|
||||
"#f59e0b", // amber
|
||||
"#c084fc", // purple
|
||||
"#fb923c", // orange
|
||||
"#14b8a6", // teal
|
||||
"#f43f5e", // rose
|
||||
"#a78bfa", // violet
|
||||
"#38bdf8", // sky
|
||||
];
|
||||
|
||||
type ChartPanelProps = {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
/** Compact mode for inline chat cards */
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
// --- Shared tooltip/axis styles ---
|
||||
|
||||
const axisStyle = {
|
||||
fontSize: 11,
|
||||
fill: "var(--color-text-muted)",
|
||||
};
|
||||
|
||||
const gridStyle = {
|
||||
stroke: "var(--color-border-strong)",
|
||||
strokeDasharray: "3 3",
|
||||
};
|
||||
|
||||
function tooltipStyle() {
|
||||
return {
|
||||
contentStyle: {
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--color-text)",
|
||||
},
|
||||
itemStyle: { color: "var(--color-text)" },
|
||||
labelStyle: { color: "var(--color-text-muted)", marginBottom: 4 },
|
||||
};
|
||||
}
|
||||
|
||||
// --- Formatters ---
|
||||
|
||||
/** Safe string conversion for chart values (handles objects via JSON.stringify). */
|
||||
function toDisplayStr(val: unknown): string {
|
||||
if (val == null) {return "";}
|
||||
if (typeof val === "object") {return JSON.stringify(val);}
|
||||
if (typeof val === "string") {return val;}
|
||||
if (typeof val === "number" || typeof val === "boolean") {return String(val);}
|
||||
// symbol, bigint, function — val is narrowed (object already handled above)
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
if (val === null || val === undefined) {return "";}
|
||||
if (typeof val === "object") {return JSON.stringify(val);}
|
||||
if (typeof val === "number") {
|
||||
if (Math.abs(val) >= 1_000_000) {return `${(val / 1_000_000).toFixed(1)}M`;}
|
||||
if (Math.abs(val) >= 1_000) {return `${(val / 1_000).toFixed(1)}K`;}
|
||||
return Number.isInteger(val) ? String(val) : val.toFixed(2);
|
||||
}
|
||||
return toDisplayStr(val);
|
||||
}
|
||||
|
||||
function formatLabel(val: unknown): string {
|
||||
if (val === null || val === undefined) {return "";}
|
||||
const str = toDisplayStr(val);
|
||||
// Truncate long date strings
|
||||
if (str.length > 16 && !isNaN(Date.parse(str))) {
|
||||
return str.slice(0, 10);
|
||||
}
|
||||
// Truncate long labels
|
||||
if (str.length > 20) {return str.slice(0, 18) + "...";}
|
||||
return str;
|
||||
}
|
||||
|
||||
// --- Chart renderers ---
|
||||
|
||||
function CartesianChart({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
ChartComponent,
|
||||
SeriesComponent,
|
||||
areaProps,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
ChartComponent: typeof BarChart ;
|
||||
SeriesComponent: typeof Bar | typeof Line | typeof Area;
|
||||
areaProps?: Record<string, unknown>;
|
||||
}) {
|
||||
const { mapping } = config;
|
||||
const xKey = mapping.xAxis ?? Object.keys(data[0] ?? {})[0] ?? "x";
|
||||
const yKeys = mapping.yAxis ?? Object.keys(data[0] ?? {}).filter((k) => k !== xKey);
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ChartComponent data={data} margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid {...gridStyle} />
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
tick={axisStyle}
|
||||
tickFormatter={formatLabel}
|
||||
axisLine={{ stroke: "var(--color-border)" }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={axisStyle}
|
||||
tickFormatter={formatValue}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={48}
|
||||
/>
|
||||
<Tooltip {...ttStyle} formatter={formatValue} labelFormatter={formatLabel} />
|
||||
{yKeys.length > 1 && !compact && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||
{yKeys.map((key, i) => {
|
||||
const color = colors[i % colors.length];
|
||||
const props: Record<string, unknown> = {
|
||||
key,
|
||||
dataKey: key,
|
||||
fill: color,
|
||||
stroke: color,
|
||||
name: key,
|
||||
...areaProps,
|
||||
};
|
||||
if (SeriesComponent === Bar) {
|
||||
props.radius = [4, 4, 0, 0];
|
||||
props.maxBarSize = 48;
|
||||
}
|
||||
if (SeriesComponent === Line) {
|
||||
props.strokeWidth = 2;
|
||||
props.dot = { r: 3, fill: color };
|
||||
props.activeDot = { r: 5 };
|
||||
}
|
||||
if (SeriesComponent === Area) {
|
||||
props.fillOpacity = 0.15;
|
||||
props.strokeWidth = 2;
|
||||
}
|
||||
// @ts-expect-error - dynamic component props
|
||||
return <SeriesComponent {...props} />;
|
||||
})}
|
||||
</ChartComponent>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function PieDonutChart({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { mapping, type } = config;
|
||||
const nameKey = mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name";
|
||||
const valueKey = mapping.valueKey ?? Object.keys(data[0] ?? {})[1] ?? "value";
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
const innerRadius = type === "donut" ? "50%" : 0;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey={valueKey}
|
||||
nameKey={nameKey}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={compact ? 70 : 110}
|
||||
paddingAngle={2}
|
||||
label={compact ? undefined : ((props: unknown) => {
|
||||
const p = props as Record<string, unknown>;
|
||||
const name = p.name;
|
||||
const percent = typeof p.percent === "number" ? p.percent : 0;
|
||||
return `${formatLabel(name)} ${(percent * 100).toFixed(0)}%`;
|
||||
}) as never}
|
||||
labelLine={!compact}
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={colors[i % colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip {...ttStyle} formatter={formatValue} />
|
||||
{!compact && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function RadarChartPanel({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { mapping } = config;
|
||||
const nameKey = mapping.xAxis ?? mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name";
|
||||
const valueKeys = mapping.yAxis ?? [Object.keys(data[0] ?? {})[1] ?? "value"];
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RadarChart data={data} cx="50%" cy="50%" outerRadius={compact ? 60 : 100}>
|
||||
<PolarGrid stroke="var(--color-border)" />
|
||||
<PolarAngleAxis dataKey={nameKey} tick={{ fontSize: 11, fill: "var(--color-text-muted)" }} />
|
||||
<PolarRadiusAxis tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} />
|
||||
{valueKeys.map((key, i) => (
|
||||
<Radar
|
||||
key={key}
|
||||
name={key}
|
||||
dataKey={key}
|
||||
stroke={colors[i % colors.length]}
|
||||
fill={colors[i % colors.length]}
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
))}
|
||||
<Tooltip {...ttStyle} />
|
||||
{!compact && valueKeys.length > 1 && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ScatterChartPanel({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { mapping } = config;
|
||||
const xKey = mapping.xAxis ?? Object.keys(data[0] ?? {})[0] ?? "x";
|
||||
const yKeys = mapping.yAxis ?? [Object.keys(data[0] ?? {})[1] ?? "y"];
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ScatterChart margin={{ top: 8, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid {...gridStyle} />
|
||||
<XAxis dataKey={xKey} tick={axisStyle} name={xKey} axisLine={{ stroke: "var(--color-border)" }} tickLine={false} />
|
||||
<YAxis tick={axisStyle} tickFormatter={formatValue} axisLine={false} tickLine={false} width={48} />
|
||||
<Tooltip {...ttStyle} />
|
||||
{yKeys.map((key, i) => (
|
||||
<Scatter
|
||||
key={key}
|
||||
name={key}
|
||||
data={data}
|
||||
fill={colors[i % colors.length]}
|
||||
/>
|
||||
))}
|
||||
{!compact && yKeys.length > 1 && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function FunnelChartPanel({
|
||||
config,
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
config: PanelConfig;
|
||||
data: Record<string, unknown>[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { mapping } = config;
|
||||
const nameKey = mapping.nameKey ?? Object.keys(data[0] ?? {})[0] ?? "name";
|
||||
const valueKey = mapping.valueKey ?? Object.keys(data[0] ?? {})[1] ?? "value";
|
||||
const colors = mapping.colors ?? CHART_PALETTE;
|
||||
const height = compact ? 200 : 320;
|
||||
const ttStyle = tooltipStyle();
|
||||
|
||||
// Funnel expects data with fill colors
|
||||
const funnelData = data.map((row, i) => ({
|
||||
...row,
|
||||
fill: colors[i % colors.length],
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<FunnelChart>
|
||||
<Tooltip {...ttStyle} />
|
||||
<Funnel
|
||||
data={funnelData}
|
||||
dataKey={valueKey}
|
||||
nameKey={nameKey}
|
||||
isAnimationActive
|
||||
>
|
||||
<LabelList
|
||||
position="right"
|
||||
fill="var(--color-text-muted)"
|
||||
stroke="none"
|
||||
fontSize={11}
|
||||
dataKey={nameKey}
|
||||
/>
|
||||
</Funnel>
|
||||
</FunnelChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main ChartPanel component ---
|
||||
|
||||
export function ChartPanel({ config, data, compact }: ChartPanelProps) {
|
||||
// Coerce numeric values for Recharts
|
||||
const processedData = useMemo(() => {
|
||||
if (!data || data.length === 0) {return [];}
|
||||
const { mapping } = config;
|
||||
const numericKeys = new Set([
|
||||
...(mapping.yAxis ?? []),
|
||||
...(mapping.valueKey ? [mapping.valueKey] : []),
|
||||
]);
|
||||
|
||||
return data.map((row) => {
|
||||
const out: Record<string, unknown> = { ...row };
|
||||
for (const key of numericKeys) {
|
||||
if (key in out) {
|
||||
const v = out[key];
|
||||
if (typeof v === "string" && v !== "" && !isNaN(Number(v))) {
|
||||
out[key] = Number(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}, [data, config]);
|
||||
|
||||
if (processedData.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-xl"
|
||||
style={{
|
||||
height: compact ? 200 : 320,
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (config.type) {
|
||||
case "bar":
|
||||
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={BarChart} SeriesComponent={Bar} />;
|
||||
case "line":
|
||||
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={LineChart} SeriesComponent={Line} />;
|
||||
case "area":
|
||||
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={AreaChart} SeriesComponent={Area} />;
|
||||
case "pie":
|
||||
return <PieDonutChart config={config} data={processedData} compact={compact} />;
|
||||
case "donut":
|
||||
return <PieDonutChart config={config} data={processedData} compact={compact} />;
|
||||
case "radar":
|
||||
case "radialBar":
|
||||
return <RadarChartPanel config={config} data={processedData} compact={compact} />;
|
||||
case "scatter":
|
||||
return <ScatterChartPanel config={config} data={processedData} compact={compact} />;
|
||||
case "funnel":
|
||||
return <FunnelChartPanel config={config} data={processedData} compact={compact} />;
|
||||
default:
|
||||
return <CartesianChart config={config} data={processedData} compact={compact} ChartComponent={BarChart} SeriesComponent={Bar} />;
|
||||
}
|
||||
}
|
||||
349
apps/web/app/components/charts/filter-bar.tsx
Normal file
349
apps/web/app/components/charts/filter-bar.tsx
Normal file
@ -0,0 +1,349 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import type { FilterConfig, FilterState, FilterValue } from "./types";
|
||||
|
||||
type FilterBarProps = {
|
||||
filters: FilterConfig[];
|
||||
value: FilterState;
|
||||
onChange: (state: FilterState) => void;
|
||||
};
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function FilterIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Individual filter components ---
|
||||
|
||||
function DateRangeFilter({
|
||||
filter,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
filter: FilterConfig;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue) => void;
|
||||
}) {
|
||||
const current = value?.type === "dateRange" ? value : { type: "dateRange" as const };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
|
||||
{filter.label}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={current.from ?? ""}
|
||||
onChange={(e) => onChange({ ...current, from: e.target.value || undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
colorScheme: "dark",
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>to</span>
|
||||
<input
|
||||
type="date"
|
||||
value={current.to ?? ""}
|
||||
onChange={(e) => onChange({ ...current, to: e.target.value || undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
colorScheme: "dark",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectFilter({
|
||||
filter,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
filter: FilterConfig;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue) => void;
|
||||
options: string[];
|
||||
}) {
|
||||
const current = value?.type === "select" ? value.value : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
|
||||
{filter.label}
|
||||
</label>
|
||||
<select
|
||||
value={current ?? ""}
|
||||
onChange={(e) => onChange({ type: "select", value: e.target.value || undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none cursor-pointer"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
minWidth: 100,
|
||||
}}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectFilter({
|
||||
filter,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
filter: FilterConfig;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue) => void;
|
||||
options: string[];
|
||||
}) {
|
||||
const current = value?.type === "multiSelect" ? (value.values ?? []) : [];
|
||||
|
||||
const toggleOption = (opt: string) => {
|
||||
const next = current.includes(opt)
|
||||
? current.filter((v) => v !== opt)
|
||||
: [...current, opt];
|
||||
onChange({ type: "multiSelect", values: next.length > 0 ? next : undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
|
||||
{filter.label}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{options.map((opt) => {
|
||||
const selected = current.includes(opt);
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => toggleOption(opt)}
|
||||
className="px-2 py-0.5 rounded-full text-[10px] transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: selected ? "var(--color-accent-light)" : "var(--color-surface)",
|
||||
border: `1px solid ${selected ? "var(--color-accent)" : "var(--color-border)"}`,
|
||||
color: selected ? "var(--color-accent)" : "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberFilter({
|
||||
filter,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
filter: FilterConfig;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue) => void;
|
||||
}) {
|
||||
const current = value?.type === "number" ? value : { type: "number" as const };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] whitespace-nowrap" style={{ color: "var(--color-text-muted)" }}>
|
||||
{filter.label}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min"
|
||||
value={current.min ?? ""}
|
||||
onChange={(e) => onChange({ ...current, min: e.target.value ? Number(e.target.value) : undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none w-20"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}>to</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max"
|
||||
value={current.max ?? ""}
|
||||
onChange={(e) => onChange({ ...current, max: e.target.value ? Number(e.target.value) : undefined })}
|
||||
className="px-2 py-1 rounded-md text-[11px] outline-none w-20"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main FilterBar ---
|
||||
|
||||
export function FilterBar({ filters, value, onChange }: FilterBarProps) {
|
||||
// Fetch options for select/multiSelect filters
|
||||
const [optionsMap, setOptionsMap] = useState<Record<string, string[]>>({});
|
||||
|
||||
const fetchOptions = useCallback(async () => {
|
||||
const toFetch = filters.filter(
|
||||
(f) => (f.type === "select" || f.type === "multiSelect") && f.sql,
|
||||
);
|
||||
if (toFetch.length === 0) {return;}
|
||||
|
||||
const results: Record<string, string[]> = {};
|
||||
await Promise.all(
|
||||
toFetch.map(async (f) => {
|
||||
try {
|
||||
const res = await fetch("/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: f.sql }),
|
||||
});
|
||||
if (!res.ok) {return;}
|
||||
const data = await res.json();
|
||||
const rows: Record<string, unknown>[] = data.rows ?? [];
|
||||
// Extract the first column's values as options
|
||||
const opts = rows
|
||||
.map((r) => {
|
||||
const vals = Object.values(r);
|
||||
const v = vals[0];
|
||||
if (v == null) {return null;}
|
||||
if (typeof v === "object") {return JSON.stringify(v);}
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- v narrowed, object handled above
|
||||
return typeof v === "string" ? v : (typeof v === "number" || typeof v === "boolean" ? String(v) : String(v));
|
||||
})
|
||||
.filter((v): v is string => v !== null);
|
||||
results[f.id] = opts;
|
||||
} catch {
|
||||
// skip failed option fetches
|
||||
}
|
||||
}),
|
||||
);
|
||||
setOptionsMap(results);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOptions();
|
||||
}, [fetchOptions]);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(filterId: string, v: FilterValue) => {
|
||||
onChange({ ...value, [filterId]: v });
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
const hasActiveFilters = Object.values(value).some((v) => {
|
||||
if (!v) {return false;}
|
||||
if (v.type === "dateRange") {return v.from || v.to;}
|
||||
if (v.type === "select") {return v.value;}
|
||||
if (v.type === "multiSelect") {return v.values && v.values.length > 0;}
|
||||
if (v.type === "number") {return v.min !== undefined || v.max !== undefined;}
|
||||
return false;
|
||||
});
|
||||
|
||||
const clearFilters = () => onChange({});
|
||||
|
||||
if (filters.length === 0) {return null;}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-4 px-4 py-2.5 border-b flex-wrap"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||
<FilterIcon />
|
||||
Filters
|
||||
</span>
|
||||
|
||||
{filters.map((filter) => {
|
||||
const fv = value[filter.id];
|
||||
switch (filter.type) {
|
||||
case "dateRange":
|
||||
return (
|
||||
<DateRangeFilter
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
value={fv}
|
||||
onChange={(v) => handleFilterChange(filter.id, v)}
|
||||
/>
|
||||
);
|
||||
case "select":
|
||||
return (
|
||||
<SelectFilter
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
value={fv}
|
||||
onChange={(v) => handleFilterChange(filter.id, v)}
|
||||
options={optionsMap[filter.id] ?? []}
|
||||
/>
|
||||
);
|
||||
case "multiSelect":
|
||||
return (
|
||||
<MultiSelectFilter
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
value={fv}
|
||||
onChange={(v) => handleFilterChange(filter.id, v)}
|
||||
options={optionsMap[filter.id] ?? []}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<NumberFilter
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
value={fv}
|
||||
onChange={(v) => handleFilterChange(filter.id, v)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors cursor-pointer"
|
||||
style={{
|
||||
color: "var(--color-accent)",
|
||||
background: "var(--color-accent-light)",
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
464
apps/web/app/components/charts/report-card.tsx
Normal file
464
apps/web/app/components/charts/report-card.tsx
Normal file
@ -0,0 +1,464 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChartPanel } from "./chart-panel";
|
||||
import type { ReportConfig, PanelConfig } from "./types";
|
||||
|
||||
type ReportCardProps = {
|
||||
config: ReportConfig;
|
||||
};
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function ChartBarIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="20" y2="10" />
|
||||
<line x1="18" x2="18" y1="20" y2="4" />
|
||||
<line x1="6" x2="6" y1="20" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<polyline points="9 21 3 21 3 15" />
|
||||
<line x1="21" x2="14" y1="3" y2="10" />
|
||||
<line x1="3" x2="10" y1="21" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapseIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="4 14 10 14 10 20" />
|
||||
<polyline points="20 10 14 10 14 4" />
|
||||
<line x1="14" x2="21" y1="10" y2="3" />
|
||||
<line x1="3" x2="10" y1="21" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PinIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="17" y2="22" />
|
||||
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Panel data state ---
|
||||
|
||||
type PanelData = {
|
||||
rows: Record<string, unknown>[];
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// --- Grid size helpers ---
|
||||
|
||||
function panelColSpan(size?: string): string {
|
||||
switch (size) {
|
||||
case "full":
|
||||
return "col-span-6";
|
||||
case "third":
|
||||
return "col-span-2";
|
||||
case "half":
|
||||
default:
|
||||
return "col-span-3";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ReportCard ---
|
||||
|
||||
export function ReportCard({ config }: ReportCardProps) {
|
||||
const [panelData, setPanelData] = useState<Record<string, PanelData>>({});
|
||||
const [pinning, setPinning] = useState(false);
|
||||
const [pinned, setPinned] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// In compact mode show at most 2 panels; expanded shows all
|
||||
const visiblePanels = expanded ? config.panels : config.panels.slice(0, 2);
|
||||
const hasMore = config.panels.length > 2;
|
||||
|
||||
// Execute panel SQL queries
|
||||
const executePanels = useCallback(async (panels: PanelConfig[]) => {
|
||||
const initial: Record<string, PanelData> = {};
|
||||
for (const panel of panels) {
|
||||
initial[panel.id] = { rows: [], loading: true };
|
||||
}
|
||||
setPanelData((prev) => ({ ...prev, ...initial }));
|
||||
|
||||
await Promise.all(
|
||||
panels.map(async (panel) => {
|
||||
try {
|
||||
const res = await fetch("/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sql: panel.sql }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: { rows: [], loading: false, error: data.error || `HTTP ${res.status}` },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: { rows: data.rows ?? [], loading: false },
|
||||
}));
|
||||
} catch (err) {
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: { rows: [], loading: false, error: err instanceof Error ? err.message : "Failed" },
|
||||
}));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Load initial compact panels
|
||||
useEffect(() => {
|
||||
void executePanels(config.panels.slice(0, 2));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// When expanding, fetch any panels not yet loaded
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
setExpanded((prev) => {
|
||||
const next = !prev;
|
||||
if (next && hasMore) {
|
||||
const unloaded = config.panels.filter((p) => !panelData[p.id]);
|
||||
if (unloaded.length > 0) {
|
||||
void executePanels(unloaded);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [hasMore, config.panels, panelData, executePanels]);
|
||||
|
||||
// Refresh all visible panels
|
||||
const handleRefresh = useCallback(() => {
|
||||
void executePanels(expanded ? config.panels : config.panels.slice(0, 2));
|
||||
}, [expanded, config.panels, executePanels]);
|
||||
|
||||
// Pin report to workspace /reports directory
|
||||
const handlePin = async () => {
|
||||
setPinning(true);
|
||||
try {
|
||||
const slug = config.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
const filename = `${slug}.report.json`;
|
||||
|
||||
await fetch("/api/workspace/file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: `reports/${filename}`,
|
||||
content: JSON.stringify(config, null, 2),
|
||||
}),
|
||||
});
|
||||
setPinned(true);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setPinning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl overflow-hidden my-2 transition-all duration-200"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 border-b"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span style={{ color: "#22c55e" }}>
|
||||
<ChartBarIcon />
|
||||
</span>
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{config.title}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: "rgba(34, 197, 94, 0.1)",
|
||||
color: "#22c55e",
|
||||
}}
|
||||
>
|
||||
{config.panels.length} chart{config.panels.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{expanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="p-1 rounded-md transition-colors cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
)}
|
||||
{!pinned ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePin}
|
||||
disabled={pinning}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] transition-colors cursor-pointer disabled:opacity-40"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
title="Save to workspace /reports"
|
||||
>
|
||||
<PinIcon />
|
||||
{pinning ? "Saving..." : "Pin"}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-md"
|
||||
style={{ color: "#22c55e", background: "rgba(34, 197, 94, 0.1)" }}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
Pinned
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleExpand}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] transition-colors cursor-pointer"
|
||||
style={{
|
||||
color: expanded ? "var(--color-text)" : "var(--color-accent)",
|
||||
background: expanded ? "var(--color-surface-hover)" : "var(--color-accent-light)",
|
||||
}}
|
||||
title={expanded ? "Collapse report" : "Expand full report"}
|
||||
>
|
||||
{expanded ? <CollapseIcon /> : <ExpandIcon />}
|
||||
{expanded ? "Collapse" : "Open"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{config.description && (
|
||||
<div className="px-3 py-1.5">
|
||||
<p className="text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{expanded ? (
|
||||
/* ── Expanded: full grid with all panels ── */
|
||||
<motion.div
|
||||
key="expanded"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeInOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="grid grid-cols-6 gap-3 p-3">
|
||||
{config.panels.map((panel) => (
|
||||
<ExpandedPanelCard
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
data={panelData[panel.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
/* ── Compact: max 2 panels ── */
|
||||
<motion.div
|
||||
key="compact"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className={`grid gap-2 p-2 ${visiblePanels.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}>
|
||||
{visiblePanels.map((panel) => (
|
||||
<CompactPanelCard
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
data={panelData[panel.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* More panels indicator */}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleExpand}
|
||||
className="w-full px-3 py-1.5 text-center border-t cursor-pointer transition-colors hover:opacity-80"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span className="text-[10px]" style={{ color: "var(--color-accent)" }}>
|
||||
+{config.panels.length - 2} more chart{config.panels.length - 2 !== 1 ? "s" : ""} — click to expand
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Compact panel card for inline rendering ---
|
||||
|
||||
function CompactPanelCard({
|
||||
panel,
|
||||
data,
|
||||
}: {
|
||||
panel: PanelConfig;
|
||||
data?: PanelData;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div className="px-2.5 py-1.5">
|
||||
<h4
|
||||
className="text-[11px] font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{panel.title}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
{data?.loading ? (
|
||||
<div className="flex items-center justify-center" style={{ height: 200 }}>
|
||||
<div
|
||||
className="w-4 h-4 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<div className="flex items-center justify-center" style={{ height: 200 }}>
|
||||
<p className="text-[10px]" style={{ color: "#f87171" }}>
|
||||
{data.error}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ChartPanel config={panel} data={data?.rows ?? []} compact />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Expanded panel card for full report view ---
|
||||
|
||||
function ExpandedPanelCard({
|
||||
panel,
|
||||
data,
|
||||
}: {
|
||||
panel: PanelConfig;
|
||||
data?: PanelData;
|
||||
}) {
|
||||
const colSpan = panelColSpan(panel.size);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${colSpan} rounded-xl overflow-hidden`}
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div className="px-3 py-2 flex items-center justify-between">
|
||||
<h4
|
||||
className="text-xs font-medium"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{panel.title}
|
||||
</h4>
|
||||
{data && !data.loading && !data.error && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{data.rows.length} rows
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-1.5 pb-2">
|
||||
{data?.loading ? (
|
||||
<div className="flex items-center justify-center" style={{ height: 280 }}>
|
||||
<div
|
||||
className="w-4 h-4 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<div className="flex flex-col items-center justify-center gap-1.5" style={{ height: 280 }}>
|
||||
<p className="text-[10px]" style={{ color: "#f87171" }}>
|
||||
Query error
|
||||
</p>
|
||||
<p
|
||||
className="text-[10px] px-2 py-1 rounded max-w-xs text-center"
|
||||
style={{ background: "rgba(248, 113, 113, 0.1)", color: "#f87171" }}
|
||||
>
|
||||
{data.error}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ChartPanel config={panel} data={data?.rows ?? []} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
407
apps/web/app/components/charts/report-viewer.tsx
Normal file
407
apps/web/app/components/charts/report-viewer.tsx
Normal file
@ -0,0 +1,407 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { ChartPanel } from "./chart-panel";
|
||||
import { FilterBar } from "./filter-bar";
|
||||
import type { ReportConfig, FilterState, PanelConfig, FilterConfig } from "./types";
|
||||
|
||||
type ReportViewerProps = {
|
||||
/** Report config object (inline or loaded) */
|
||||
config?: ReportConfig;
|
||||
/** Path to load report config from filesystem */
|
||||
reportPath?: string;
|
||||
};
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
function ChartBarIcon({ size = 20 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="20" y2="10" />
|
||||
<line x1="18" x2="18" y1="20" y2="4" />
|
||||
<line x1="6" x2="6" y1="20" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
type PanelData = {
|
||||
panelId: string;
|
||||
rows: Record<string, unknown>[];
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Build filter entries for the API from active filter state + filter configs. */
|
||||
function buildFilterEntries(
|
||||
filterState: FilterState,
|
||||
filterConfigs: FilterConfig[],
|
||||
): Array<{ id: string; column: string; value: FilterState[string] }> {
|
||||
const entries: Array<{ id: string; column: string; value: FilterState[string] }> = [];
|
||||
for (const fc of filterConfigs) {
|
||||
const v = filterState[fc.id];
|
||||
if (!v) {continue;}
|
||||
// Only include if the filter has an active value
|
||||
const hasValue =
|
||||
(v.type === "dateRange" && (v.from || v.to)) ||
|
||||
(v.type === "select" && v.value) ||
|
||||
(v.type === "multiSelect" && v.values && v.values.length > 0) ||
|
||||
(v.type === "number" && (v.min !== undefined || v.max !== undefined));
|
||||
if (hasValue) {
|
||||
entries.push({ id: fc.id, column: fc.column, value: v });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// --- Grid size helpers ---
|
||||
|
||||
function panelColSpan(size?: string): string {
|
||||
switch (size) {
|
||||
case "full":
|
||||
return "col-span-6";
|
||||
case "third":
|
||||
return "col-span-2";
|
||||
case "half":
|
||||
default:
|
||||
return "col-span-3";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ReportViewer ---
|
||||
|
||||
export function ReportViewer({ config: propConfig, reportPath }: ReportViewerProps) {
|
||||
const [config, setConfig] = useState<ReportConfig | null>(propConfig ?? null);
|
||||
const [configLoading, setConfigLoading] = useState(!propConfig && !!reportPath);
|
||||
const [configError, setConfigError] = useState<string | null>(null);
|
||||
const [panelData, setPanelData] = useState<Record<string, PanelData>>({});
|
||||
const [filterState, setFilterState] = useState<FilterState>({});
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Load report config from filesystem if path provided
|
||||
useEffect(() => {
|
||||
if (propConfig) {
|
||||
setConfig(propConfig);
|
||||
return;
|
||||
}
|
||||
if (!reportPath) {return;}
|
||||
|
||||
let cancelled = false;
|
||||
setConfigLoading(true);
|
||||
setConfigError(null);
|
||||
|
||||
fetch(`/api/workspace/file?path=${encodeURIComponent(reportPath)}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {throw new Error(`Failed to load report: HTTP ${res.status}`);}
|
||||
const data = await res.json();
|
||||
if (cancelled) {return;}
|
||||
try {
|
||||
const parsed = JSON.parse(data.content) as ReportConfig;
|
||||
setConfig(parsed);
|
||||
} catch {
|
||||
throw new Error("Invalid report JSON");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setConfigError(err instanceof Error ? err.message : "Failed to load report");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {setConfigLoading(false);}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [propConfig, reportPath]);
|
||||
|
||||
// Execute all panel SQL queries when config or filters change
|
||||
const executeAllPanels = useCallback(async () => {
|
||||
if (!config) {return;}
|
||||
|
||||
const filterEntries = buildFilterEntries(filterState, config.filters ?? []);
|
||||
|
||||
// Mark all panels as loading
|
||||
const initialState: Record<string, PanelData> = {};
|
||||
for (const panel of config.panels) {
|
||||
initialState[panel.id] = { panelId: panel.id, rows: [], loading: true };
|
||||
}
|
||||
setPanelData(initialState);
|
||||
|
||||
// Execute all panels in parallel
|
||||
await Promise.all(
|
||||
config.panels.map(async (panel) => {
|
||||
try {
|
||||
const res = await fetch("/api/workspace/reports/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sql: panel.sql,
|
||||
filters: filterEntries.length > 0 ? filterEntries : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: {
|
||||
panelId: panel.id,
|
||||
rows: [],
|
||||
loading: false,
|
||||
error: data.error || `HTTP ${res.status}`,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: {
|
||||
panelId: panel.id,
|
||||
rows: data.rows ?? [],
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
setPanelData((prev) => ({
|
||||
...prev,
|
||||
[panel.id]: {
|
||||
panelId: panel.id,
|
||||
rows: [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "Query failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, [config, filterState]);
|
||||
|
||||
// Re-execute when config, filters, or refresh key changes
|
||||
useEffect(() => {
|
||||
void executeAllPanels();
|
||||
}, [executeAllPanels, refreshKey]);
|
||||
|
||||
const totalRows = useMemo(() => {
|
||||
return Object.values(panelData).reduce((sum, pd) => sum + pd.rows.length, 0);
|
||||
}, [panelData]);
|
||||
|
||||
// --- Loading state ---
|
||||
if (configLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full gap-3">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Loading report...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Error state ---
|
||||
if (configError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 p-8">
|
||||
<ChartBarIcon size={48} />
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Failed to load report
|
||||
</p>
|
||||
<p
|
||||
className="text-xs px-3 py-2 rounded-lg max-w-md text-center"
|
||||
style={{ background: "var(--color-surface)", color: "#f87171" }}
|
||||
>
|
||||
{configError}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
No report configuration found
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Report header */}
|
||||
<div
|
||||
className="px-6 py-4 border-b flex-shrink-0"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5 mb-1">
|
||||
<span style={{ color: "var(--color-accent)" }}>
|
||||
<ChartBarIcon />
|
||||
</span>
|
||||
<h1
|
||||
className="text-xl font-bold"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{config.title}
|
||||
</h1>
|
||||
</div>
|
||||
{config.description && (
|
||||
<p
|
||||
className="text-sm ml-7"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-[10px] px-2 py-1 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{config.panels.length} panel{config.panels.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-2 py-1 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{totalRows} rows
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefreshKey((k) => k + 1)}
|
||||
className="p-1.5 rounded-md transition-colors cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{config.filters && config.filters.length > 0 && (
|
||||
<FilterBar
|
||||
filters={config.filters}
|
||||
value={filterState}
|
||||
onChange={setFilterState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Panel grid */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-6 gap-5">
|
||||
{config.panels.map((panel) => (
|
||||
<PanelCard
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
data={panelData[panel.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Individual panel card ---
|
||||
|
||||
function PanelCard({
|
||||
panel,
|
||||
data,
|
||||
}: {
|
||||
panel: PanelConfig;
|
||||
data?: PanelData;
|
||||
}) {
|
||||
const colSpan = panelColSpan(panel.size);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${colSpan} rounded-xl overflow-hidden`}
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{/* Panel header */}
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<h3
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{panel.title}
|
||||
</h3>
|
||||
{data && !data.loading && !data.error && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{data.rows.length} rows
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart area */}
|
||||
<div className="px-2 pb-3">
|
||||
{data?.loading ? (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ height: 320 }}
|
||||
>
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
borderTopColor: "var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-2"
|
||||
style={{ height: 320 }}
|
||||
>
|
||||
<p className="text-xs" style={{ color: "#f87171" }}>
|
||||
Query error
|
||||
</p>
|
||||
<p
|
||||
className="text-[10px] px-2 py-1 rounded max-w-xs text-center"
|
||||
style={{ background: "rgba(248, 113, 113, 0.1)", color: "#f87171" }}
|
||||
>
|
||||
{data.error}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ChartPanel config={panel} data={data?.rows ?? []} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
apps/web/app/components/charts/types.ts
Normal file
64
apps/web/app/components/charts/types.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/** Shared types for the report/analytics system. */
|
||||
|
||||
export type ChartType =
|
||||
| "bar"
|
||||
| "line"
|
||||
| "area"
|
||||
| "pie"
|
||||
| "donut"
|
||||
| "radar"
|
||||
| "radialBar"
|
||||
| "scatter"
|
||||
| "funnel";
|
||||
|
||||
export type PanelSize = "full" | "half" | "third";
|
||||
|
||||
export type PanelMapping = {
|
||||
/** Key for x-axis or category axis */
|
||||
xAxis?: string;
|
||||
/** One or more keys for y-axis values (supports stacked/multi-series) */
|
||||
yAxis?: string[];
|
||||
/** Key used as label for pie/donut/funnel */
|
||||
nameKey?: string;
|
||||
/** Key used as value for pie/donut/funnel */
|
||||
valueKey?: string;
|
||||
/** Custom colors for series (hex). Falls back to palette. */
|
||||
colors?: string[];
|
||||
};
|
||||
|
||||
export type PanelConfig = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: ChartType;
|
||||
sql: string;
|
||||
mapping: PanelMapping;
|
||||
size?: PanelSize;
|
||||
};
|
||||
|
||||
export type FilterType = "dateRange" | "select" | "multiSelect" | "number";
|
||||
|
||||
export type FilterConfig = {
|
||||
id: string;
|
||||
type: FilterType;
|
||||
label: string;
|
||||
column: string;
|
||||
/** SQL to fetch available options (for select/multiSelect) */
|
||||
sql?: string;
|
||||
};
|
||||
|
||||
export type ReportConfig = {
|
||||
version: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
panels: PanelConfig[];
|
||||
filters?: FilterConfig[];
|
||||
};
|
||||
|
||||
/** Active filter values keyed by filter ID */
|
||||
export type FilterState = Record<string, FilterValue>;
|
||||
|
||||
export type FilterValue =
|
||||
| { type: "dateRange"; from?: string; to?: string }
|
||||
| { type: "select"; value?: string }
|
||||
| { type: "multiSelect"; values?: string[] }
|
||||
| { type: "number"; min?: number; max?: number };
|
||||
940
apps/web/app/components/chat-message.tsx
Normal file
940
apps/web/app/components/chat-message.tsx
Normal file
@ -0,0 +1,940 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { UIMessage } from "ai";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { Components } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ChainOfThought, type ChainPart } from "./chain-of-thought";
|
||||
import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
|
||||
import { splitDiffBlocks, hasDiffBlocks } from "@/lib/diff-blocks";
|
||||
import type { ReportConfig } from "./charts/types";
|
||||
import { DiffCard } from "./diff-viewer";
|
||||
import { SyntaxBlock } from "./syntax-block";
|
||||
|
||||
// Lazy-load ReportCard (uses Recharts which is heavy)
|
||||
const ReportCard = dynamic(
|
||||
() =>
|
||||
import("./charts/report-card").then((m) => ({
|
||||
default: m.ReportCard,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className="h-48 rounded-2xl animate-pulse"
|
||||
style={{ background: "var(--color-surface-hover)" }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
/* ─── Silent-reply leak filter ─── */
|
||||
|
||||
const _SILENT_TOKEN = "NO_REPLY";
|
||||
|
||||
function isLeakedSilentToken(text: string): boolean {
|
||||
const t = text.trim();
|
||||
if (!t) {return false;}
|
||||
if (new RegExp(`^${_SILENT_TOKEN}\\W*$`).test(t)) {return true;}
|
||||
if (_SILENT_TOKEN.startsWith(t) && t.length >= 2 && t.length < _SILENT_TOKEN.length) {return true;}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ─── Part grouping ─── */
|
||||
|
||||
type MessageSegment =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "chain"; parts: ChainPart[] }
|
||||
| { type: "report-artifact"; config: ReportConfig }
|
||||
| { type: "diff-artifact"; diff: string }
|
||||
| { type: "subagent-card"; task: string; label?: string; status: "running" | "done" | "error" };
|
||||
|
||||
/** Map AI SDK tool state string to a simplified status */
|
||||
function toolStatus(state: string): "running" | "done" | "error" {
|
||||
if (state === "output-available") {
|
||||
return "done";
|
||||
}
|
||||
if (state === "error") {
|
||||
return "error";
|
||||
}
|
||||
return "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* Group consecutive non-text parts (reasoning + tools) into chain-of-thought
|
||||
* blocks, with text parts standing alone between them.
|
||||
*/
|
||||
function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
|
||||
const segments: MessageSegment[] = [];
|
||||
let chain: ChainPart[] = [];
|
||||
|
||||
const flush = (textFollows?: boolean) => {
|
||||
if (chain.length > 0) {
|
||||
// If text content follows this chain, all tools must have
|
||||
// completed — force any stuck "running" tools to "done".
|
||||
if (textFollows) {
|
||||
for (const cp of chain) {
|
||||
if (cp.kind === "tool" && cp.status === "running") {
|
||||
cp.status = "done";
|
||||
}
|
||||
}
|
||||
}
|
||||
segments.push({ type: "chain", parts: [...chain] });
|
||||
chain = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
const text = (part as { type: "text"; text: string }).text;
|
||||
if (isLeakedSilentToken(text)) { continue; }
|
||||
flush(true);
|
||||
if (hasReportBlocks(text)) {
|
||||
segments.push(
|
||||
...(splitReportBlocks(text) as MessageSegment[]),
|
||||
);
|
||||
} else if (hasDiffBlocks(text)) {
|
||||
for (const seg of splitDiffBlocks(text)) {
|
||||
if (seg.type === "diff-artifact") {
|
||||
segments.push({ type: "diff-artifact", diff: seg.diff });
|
||||
} else {
|
||||
segments.push({ type: "text", text: seg.text });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
segments.push({ type: "text", text });
|
||||
}
|
||||
} else if (part.type === "reasoning") {
|
||||
const rp = part as {
|
||||
type: "reasoning";
|
||||
text: string;
|
||||
state?: string;
|
||||
};
|
||||
// Skip lifecycle/compaction status labels — they add noise
|
||||
// (e.g. "Preparing response...", "Optimizing session context...")
|
||||
const statusLabels = [
|
||||
"Preparing response...",
|
||||
"Optimizing session context...",
|
||||
];
|
||||
const isStatus = statusLabels.some((l) =>
|
||||
rp.text.startsWith(l),
|
||||
);
|
||||
if (!isStatus) {
|
||||
chain.push({
|
||||
kind: "reasoning",
|
||||
text: rp.text,
|
||||
isStreaming: rp.state === "streaming",
|
||||
});
|
||||
}
|
||||
} else if (part.type === "dynamic-tool") {
|
||||
const tp = part as {
|
||||
type: "dynamic-tool";
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
state: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
};
|
||||
if (tp.toolName === "sessions_spawn") {
|
||||
flush(true);
|
||||
const args = asRecord(tp.input);
|
||||
const task = typeof args?.task === "string" ? args.task : "Subagent task";
|
||||
const label = typeof args?.label === "string" ? args.label : undefined;
|
||||
segments.push({ type: "subagent-card", task, label, status: toolStatus(tp.state) });
|
||||
} else {
|
||||
chain.push({
|
||||
kind: "tool",
|
||||
toolName: tp.toolName,
|
||||
toolCallId: tp.toolCallId,
|
||||
status: toolStatus(tp.state),
|
||||
args: asRecord(tp.input),
|
||||
output: asRecord(tp.output),
|
||||
});
|
||||
}
|
||||
} else if (part.type.startsWith("tool-")) {
|
||||
// Handles both live SSE parts (input/output fields) and
|
||||
// persisted JSONL parts (args/result fields from tool-invocation)
|
||||
const tp = part as {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
state?: string;
|
||||
title?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
// Persisted JSONL format uses args/result instead
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
const resolvedToolName = tp.title ?? tp.toolName ?? part.type.replace("tool-", "");
|
||||
if (resolvedToolName === "sessions_spawn") {
|
||||
flush(true);
|
||||
const args = asRecord(tp.input) ?? asRecord(tp.args);
|
||||
const task = typeof args?.task === "string" ? args.task : "Subagent task";
|
||||
const label = typeof args?.label === "string" ? args.label : undefined;
|
||||
const resolvedState =
|
||||
tp.state ??
|
||||
(tp.errorText ? "error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available");
|
||||
segments.push({ type: "subagent-card", task, label, status: toolStatus(resolvedState) });
|
||||
} else {
|
||||
// Persisted tool-invocation parts have no state field but
|
||||
// include result/output/errorText to indicate completion.
|
||||
const resolvedState =
|
||||
tp.state ??
|
||||
(tp.errorText ? "error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available");
|
||||
chain.push({
|
||||
kind: "tool",
|
||||
toolName: resolvedToolName,
|
||||
toolCallId: tp.toolCallId,
|
||||
status: toolStatus(resolvedState),
|
||||
args: asRecord(tp.input) ?? asRecord(tp.args),
|
||||
output: asRecord(tp.output) ?? asRecord(tp.result),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Safely cast unknown to Record if it's a non-null object */
|
||||
function asRecord(
|
||||
val: unknown,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (val && typeof val === "object" && !Array.isArray(val)) {
|
||||
return val as Record<string, unknown>;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/* ─── Attachment parsing for sent messages ─── */
|
||||
|
||||
function parseAttachments(
|
||||
text: string,
|
||||
): { paths: string[]; message: string } | null {
|
||||
const match = text.match(/\[Attached files: (.+?)\]/);
|
||||
if (!match) {return null;}
|
||||
const afterIdx = (match.index ?? 0) + match[0].length;
|
||||
const message = text.slice(afterIdx).trim();
|
||||
const paths = match[1]
|
||||
.split(", ")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
return { paths, message };
|
||||
}
|
||||
|
||||
function getCategoryFromPath(
|
||||
filePath: string,
|
||||
): "image" | "video" | "audio" | "pdf" | "code" | "document" | "other" {
|
||||
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (
|
||||
[
|
||||
"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp",
|
||||
"ico", "tiff", "heic",
|
||||
].includes(ext)
|
||||
)
|
||||
{return "image";}
|
||||
if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext))
|
||||
{return "video";}
|
||||
if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext))
|
||||
{return "audio";}
|
||||
if (ext === "pdf") {return "pdf";}
|
||||
if (
|
||||
[
|
||||
"js", "ts", "tsx", "jsx", "py", "rb", "go", "rs",
|
||||
"java", "cpp", "c", "h", "css", "html", "json",
|
||||
"yaml", "yml", "toml", "md", "sh", "bash", "sql",
|
||||
"swift", "kt",
|
||||
].includes(ext)
|
||||
)
|
||||
{return "code";}
|
||||
if (
|
||||
[
|
||||
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt",
|
||||
"rtf", "csv", "pages", "numbers", "key",
|
||||
].includes(ext)
|
||||
)
|
||||
{return "document";}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function _shortenPath(path: string): string {
|
||||
return path
|
||||
.replace(/^\/Users\/[^/]+/, "~")
|
||||
.replace(/^\/home\/[^/]+/, "~")
|
||||
.replace(/^[A-Z]:\\Users\\[^\\]+/, "~");
|
||||
}
|
||||
|
||||
const _attachCategoryMeta: Record<string, { bg: string; fg: string }> = {
|
||||
image: { bg: "rgba(16, 185, 129, 0.15)", fg: "#10b981" },
|
||||
video: { bg: "rgba(139, 92, 246, 0.15)", fg: "#8b5cf6" },
|
||||
audio: { bg: "rgba(245, 158, 11, 0.15)", fg: "#f59e0b" },
|
||||
pdf: { bg: "rgba(239, 68, 68, 0.15)", fg: "#ef4444" },
|
||||
code: { bg: "rgba(59, 130, 246, 0.15)", fg: "#3b82f6" },
|
||||
document: { bg: "rgba(107, 114, 128, 0.15)", fg: "#6b7280" },
|
||||
other: { bg: "rgba(107, 114, 128, 0.10)", fg: "#9ca3af" },
|
||||
};
|
||||
|
||||
function _AttachFileIcon({ category }: { category: string }) {
|
||||
const props = {
|
||||
width: 14,
|
||||
height: 14,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
};
|
||||
switch (category) {
|
||||
case "image":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<rect
|
||||
width="18"
|
||||
height="18"
|
||||
x="3"
|
||||
y="3"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
|
||||
<rect x="2" y="6" width="14" height="12" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
case "pdf":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="M10 13h4" />
|
||||
<path d="M10 17h4" />
|
||||
</svg>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
);
|
||||
case "document":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="M16 13H8" />
|
||||
<path d="M16 17H8" />
|
||||
<path d="M10 9H8" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function AttachedFilesCard({ paths }: { paths: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2 justify-end">
|
||||
{paths.map((filePath, i) => {
|
||||
const category = getCategoryFromPath(filePath);
|
||||
const src = category === "image"
|
||||
? `/api/workspace/raw-file?path=${encodeURIComponent(filePath)}`
|
||||
: `/api/workspace/thumbnail?path=${encodeURIComponent(filePath)}&size=200`;
|
||||
const ext = filePath.split(".").pop()?.toUpperCase() ?? "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="relative rounded-xl overflow-hidden shrink-0"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={filePath.split("/").pop() ?? ""}
|
||||
className="block rounded-xl object-cover"
|
||||
style={{ maxHeight: 140, maxWidth: 160, background: "rgba(0,0,0,0.04)" }}
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
{category !== "image" && (
|
||||
<span
|
||||
className="absolute bottom-2 left-2 rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.85)",
|
||||
color: "rgba(0,0,0,0.5)",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
{ext}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── File path detection for clickable inline code ─── */
|
||||
|
||||
/**
|
||||
* Detect whether an inline code string looks like a local file/directory path.
|
||||
* Matches anything starting with:
|
||||
* ~/ (home-relative)
|
||||
* / (absolute)
|
||||
* ./ (current-dir-relative)
|
||||
* ../ (parent-dir-relative)
|
||||
* Must contain at least one `/` separator to distinguish from plain commands.
|
||||
*/
|
||||
function looksLikeFilePath(text: string): boolean {
|
||||
const t = text.trim();
|
||||
if (!t || t.length < 3 || t.length > 500) {return false;}
|
||||
// Full path prefix
|
||||
if (t.startsWith("~/") || t.startsWith("/") || t.startsWith("./") || t.startsWith("../")) {
|
||||
const afterPrefix = t.startsWith("~/") ? t.slice(2) :
|
||||
t.startsWith("../") ? t.slice(3) :
|
||||
t.startsWith("./") ? t.slice(2) :
|
||||
t.slice(1);
|
||||
return afterPrefix.includes("/") || afterPrefix.includes(".");
|
||||
}
|
||||
// Bare filename with a known extension (e.g. "Rachapoom-Passport.pdf")
|
||||
const fileExtPattern = /\.(pdf|docx?|xlsx?|pptx?|csv|txt|rtf|pages|numbers|key|md|json|yaml|yml|toml|xml|html?|css|jsx?|tsx?|py|rb|go|rs|java|cpp|c|h|sh|sql|swift|kt|png|jpe?g|gif|webp|svg|bmp|ico|heic|tiff|mp[34]|webm|mov|avi|mkv|flv|wav|ogg|aac|flac|m4a|zip|tar|gz|dmg)$/i;
|
||||
if (fileExtPattern.test(t) && !t.includes(" ")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Check if text looks like a filename (allows spaces, used for bold text). */
|
||||
function looksLikeFileName(text: string): boolean {
|
||||
const t = text.trim();
|
||||
if (!t || t.length < 3 || t.length > 300) {return false;}
|
||||
const fileExtPattern = /\.(pdf|docx?|xlsx?|pptx?|csv|txt|rtf|pages|numbers|key|md|json|yaml|yml|toml|xml|html?|css|jsx?|tsx?|py|rb|go|rs|java|cpp|c|h|sh|sql|swift|kt|png|jpe?g|gif|webp|svg|bmp|ico|heic|tiff|mp[34]|webm|mov|avi|mkv|flv|wav|ogg|aac|flac|m4a|zip|tar|gz|dmg)$/i;
|
||||
return fileExtPattern.test(t);
|
||||
}
|
||||
|
||||
/** Open a file path using the system default application. */
|
||||
async function openFilePath(path: string, reveal = false) {
|
||||
try {
|
||||
const res = await fetch("/api/workspace/open-file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, reveal }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
console.error("Failed to open file:", data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to open file:", err);
|
||||
}
|
||||
}
|
||||
|
||||
type FilePathClickHandler = (
|
||||
path: string,
|
||||
) => Promise<boolean | void> | boolean | void;
|
||||
|
||||
/** Convert file:// URLs to local paths for in-app preview routing. */
|
||||
function normalizePathReference(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("file://")) {
|
||||
return trimmed;
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== "file:") {
|
||||
return trimmed;
|
||||
}
|
||||
const decoded = decodeURIComponent(url.pathname);
|
||||
// Windows file URLs are /C:/... in URL form
|
||||
if (/^\/[A-Za-z]:\//.test(decoded)) {
|
||||
return decoded.slice(1);
|
||||
}
|
||||
return decoded;
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clickable file path inline code element */
|
||||
function FilePathCode({
|
||||
path,
|
||||
children,
|
||||
onFilePathClick,
|
||||
}: {
|
||||
path: string;
|
||||
children: React.ReactNode;
|
||||
onFilePathClick?: FilePathClickHandler;
|
||||
}) {
|
||||
const [status, setStatus] = useState<"idle" | "opening" | "error">("idle");
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setStatus("opening");
|
||||
try {
|
||||
if (onFilePathClick) {
|
||||
const handled = await onFilePathClick(path);
|
||||
if (handled === false) {
|
||||
setStatus("error");
|
||||
setTimeout(() => setStatus("idle"), 2000);
|
||||
return;
|
||||
}
|
||||
setStatus("idle");
|
||||
} else {
|
||||
const res = await fetch("/api/workspace/open-file", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setStatus("error");
|
||||
setTimeout(() => setStatus("idle"), 2000);
|
||||
} else {
|
||||
setStatus("idle");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setTimeout(() => setStatus("idle"), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = async (e: React.MouseEvent) => {
|
||||
// Right-click reveals in Finder instead of opening
|
||||
e.preventDefault();
|
||||
await openFilePath(path, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<code
|
||||
className={`px-[0.3em] no-underline transition-colors duration-150 rounded-[4px] border border-[color:var(--color-border)] bg-white/20 hover:bg-white/40 active:bg-white ${status === "opening" ? "cursor-wait opacity-70" : "cursor-pointer"}`}
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
title={
|
||||
status === "error"
|
||||
? "File not found"
|
||||
: onFilePathClick
|
||||
? "Click to preview in workspace · Right-click to reveal in Finder"
|
||||
: "Click to open · Right-click to reveal in Finder"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Markdown component overrides for chat ─── */
|
||||
|
||||
function createMarkdownComponents(
|
||||
onFilePathClick?: FilePathClickHandler,
|
||||
): Components {
|
||||
return {
|
||||
// Open external links in new tab; intercept local file-path links
|
||||
a: ({ href, children, ...props }) => {
|
||||
const rawHref = typeof href === "string" ? href : "";
|
||||
const normalizedHref = normalizePathReference(rawHref);
|
||||
const isExternal =
|
||||
rawHref && (rawHref.startsWith("http://") || rawHref.startsWith("https://") || rawHref.startsWith("//"));
|
||||
const isWorkspaceAppLink = rawHref.startsWith("/workspace");
|
||||
const isLocalPathLink =
|
||||
!isWorkspaceAppLink &&
|
||||
(Boolean(rawHref.startsWith("file://")) ||
|
||||
looksLikeFilePath(normalizedHref));
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...(isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
if (!isLocalPathLink || !onFilePathClick) {return;}
|
||||
e.preventDefault();
|
||||
void onFilePathClick(normalizedHref);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Route local image paths through raw-file API so workspace images render
|
||||
img: ({ src, alt, ...props }) => {
|
||||
const resolvedSrc = typeof src === "string" && !src.startsWith("http://") && !src.startsWith("https://") && !src.startsWith("data:")
|
||||
? `/api/workspace/raw-file?path=${encodeURIComponent(src)}`
|
||||
: src;
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={resolvedSrc} alt={alt ?? ""} loading="lazy" {...props} />
|
||||
);
|
||||
},
|
||||
// Syntax-highlighted fenced code blocks
|
||||
pre: ({ children, ...props }) => {
|
||||
const child = Array.isArray(children) ? children[0] : children;
|
||||
if (
|
||||
child &&
|
||||
typeof child === "object" &&
|
||||
"type" in child &&
|
||||
(child as { type?: string }).type === "code"
|
||||
) {
|
||||
const codeEl = child as {
|
||||
props?: {
|
||||
className?: string;
|
||||
children?: string;
|
||||
};
|
||||
};
|
||||
const className = codeEl.props?.className ?? "";
|
||||
const langMatch = className.match(/language-(\w+)/);
|
||||
const lang = langMatch?.[1] ?? "";
|
||||
const code =
|
||||
typeof codeEl.props?.children === "string"
|
||||
? codeEl.props.children.replace(/\n$/, "")
|
||||
: "";
|
||||
|
||||
// Diff language: render as DiffCard
|
||||
if (lang === "diff") {
|
||||
return <DiffCard diff={code} />;
|
||||
}
|
||||
|
||||
// Known language: syntax-highlight with shiki
|
||||
if (lang) {
|
||||
return (
|
||||
<div className="chat-code-block">
|
||||
<div
|
||||
className="chat-code-lang"
|
||||
>
|
||||
{lang}
|
||||
</div>
|
||||
<SyntaxBlock code={code} lang={lang} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fallback: default pre rendering
|
||||
return <pre {...props}>{children}</pre>;
|
||||
},
|
||||
// Inline code — detect file paths and make them clickable
|
||||
code: ({ children, className, ...props }) => {
|
||||
// If this code has a language class, it's inside a <pre> and
|
||||
// will be handled by the pre override above. Just return raw.
|
||||
if (className?.startsWith("language-")) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the inline code content looks like a file path
|
||||
const text = typeof children === "string" ? children : "";
|
||||
const normalizedText = normalizePathReference(text);
|
||||
if (normalizedText && looksLikeFilePath(normalizedText)) {
|
||||
return (
|
||||
<FilePathCode path={normalizedText} onFilePathClick={onFilePathClick}>
|
||||
{children}
|
||||
</FilePathCode>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular inline code
|
||||
return <code {...props}>{children}</code>;
|
||||
},
|
||||
// Bold text — detect filenames and make them clickable
|
||||
strong: ({ children, ...props }) => {
|
||||
const text = typeof children === "string" ? children
|
||||
: Array.isArray(children) ? children.filter((c) => typeof c === "string").join("")
|
||||
: "";
|
||||
if (text && looksLikeFileName(text)) {
|
||||
return (
|
||||
<strong {...props}>
|
||||
<FilePathCode path={text} onFilePathClick={onFilePathClick}>
|
||||
{children}
|
||||
</FilePathCode>
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
return <strong {...props}>{children}</strong>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ─── Chat message ─── */
|
||||
|
||||
export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onSubagentClick, onFilePathClick }: { message: UIMessage; isStreaming?: boolean; onSubagentClick?: (task: string) => void; onFilePathClick?: FilePathClickHandler }) {
|
||||
const isUser = message.role === "user";
|
||||
const segments = groupParts(message.parts);
|
||||
const markdownComponents = useMemo(
|
||||
() => createMarkdownComponents(onFilePathClick),
|
||||
[onFilePathClick],
|
||||
);
|
||||
|
||||
if (isUser) {
|
||||
// User: right-aligned subtle pill
|
||||
const textContent = segments
|
||||
.filter(
|
||||
(s): s is { type: "text"; text: string } =>
|
||||
s.type === "text",
|
||||
)
|
||||
.map((s) => s.text)
|
||||
.join("\n");
|
||||
|
||||
// Parse attachment prefix from sent messages
|
||||
const attachmentInfo = parseAttachments(textContent);
|
||||
|
||||
if (attachmentInfo) {
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1.5 py-2">
|
||||
{/* Attachment previews — standalone above the text bubble */}
|
||||
<AttachedFilesCard paths={attachmentInfo.paths} />
|
||||
{/* Text bubble */}
|
||||
{attachmentInfo.message && (
|
||||
<div
|
||||
className="max-w-[80%] w-fit rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 break-words chat-message-font"
|
||||
style={{
|
||||
background: "var(--color-user-bubble)",
|
||||
color: "var(--color-user-bubble-text)",
|
||||
}}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
{attachmentInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end py-2">
|
||||
<div
|
||||
className="max-w-[80%] min-w-0 rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 overflow-hidden break-words chat-message-font"
|
||||
style={{
|
||||
background: "var(--color-user-bubble)",
|
||||
color: "var(--color-user-bubble-text)",
|
||||
}}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words text-right">
|
||||
{textContent}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Find the last text segment index for streaming optimization
|
||||
const lastTextIdx = isStreaming
|
||||
? segments.reduce((acc, s, i) => (s.type === "text" ? i : acc), -1)
|
||||
: -1;
|
||||
|
||||
// Assistant: free-flowing text, left-aligned, NO bubble
|
||||
return (
|
||||
<div className="py-3 space-y-2 min-w-0 overflow-hidden">
|
||||
<AnimatePresence initial={false}>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === "text") {
|
||||
// Detect agent error messages
|
||||
const errorMatch = segment.text.match(
|
||||
/^\[error\]\s*([\s\S]*)$/,
|
||||
);
|
||||
if (errorMatch) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="chat-message-font flex items-start gap-2 rounded-xl px-3 py-2 text-[13px] leading-relaxed overflow-hidden"
|
||||
style={{
|
||||
background: `color-mix(in srgb, var(--color-error) 6%, var(--color-surface))`,
|
||||
color: "var(--color-error)",
|
||||
border: `1px solid color-mix(in srgb, var(--color-error) 18%, transparent)`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex-shrink-0 mt-0.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="8"
|
||||
x2="12"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="16"
|
||||
x2="12.01"
|
||||
y2="16"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap break-all min-w-0">
|
||||
{errorMatch[1].trim()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// During streaming, render the active text as plain text
|
||||
// to avoid expensive ReactMarkdown re-parses on every token.
|
||||
// Switch to full markdown once streaming ends.
|
||||
if (index === lastTextIdx) {
|
||||
return (
|
||||
<motion.div
|
||||
key={`text-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="chat-prose chat-message-font text-sm whitespace-pre-wrap break-all"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{segment.text}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`text-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="chat-prose chat-message-font text-sm"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{segment.text}
|
||||
</ReactMarkdown>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
if (segment.type === "report-artifact") {
|
||||
return (
|
||||
<motion.div
|
||||
key={`report-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<ReportCard config={segment.config} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
if (segment.type === "diff-artifact") {
|
||||
return (
|
||||
<motion.div
|
||||
key={`diff-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<DiffCard diff={segment.diff} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
if (segment.type === "subagent-card") {
|
||||
const truncatedTask = segment.task.length > 80 ? segment.task.slice(0, 80) + "..." : segment.task;
|
||||
const isRunning = segment.status === "running";
|
||||
return (
|
||||
<motion.div
|
||||
key={`subagent-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubagentClick?.(segment.task)}
|
||||
className="w-full text-left rounded-xl px-3.5 py-2.5 transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: "var(--color-accent-light)",
|
||||
border: "1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{isRunning ? (
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full animate-pulse flex-shrink-0"
|
||||
style={{ background: "var(--color-accent)" }}
|
||||
/>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ color: "var(--color-accent)" }}>
|
||||
<path d="M16 3h5v5" /><path d="m21 3-7 7" /><path d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "var(--color-accent)" }}>
|
||||
{isRunning ? "Running Subagent" : "Subagent"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5 leading-relaxed" style={{ color: "var(--color-text)" }}>
|
||||
{segment.label || truncatedTask}
|
||||
</p>
|
||||
</div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0 opacity-40" style={{ color: "var(--color-text)" }}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
key={`chain-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<ChainOfThought
|
||||
parts={segment.parts}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
2146
apps/web/app/components/chat-panel.tsx
Normal file
2146
apps/web/app/components/chat-panel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
444
apps/web/app/components/cron/cron-dashboard.tsx
Normal file
444
apps/web/app/components/cron/cron-dashboard.tsx
Normal file
@ -0,0 +1,444 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import type {
|
||||
CronJob,
|
||||
HeartbeatInfo,
|
||||
CronStatusInfo,
|
||||
CronJobsResponse,
|
||||
} from "../../types/cron";
|
||||
|
||||
/* ─── Helpers ─── */
|
||||
|
||||
function formatSchedule(schedule: CronJob["schedule"]): string {
|
||||
switch (schedule.kind) {
|
||||
case "cron":
|
||||
return `cron: ${schedule.expr}${schedule.tz ? ` (${schedule.tz})` : ""}`;
|
||||
case "every": {
|
||||
const ms = schedule.everyMs;
|
||||
if (ms >= 86_400_000) {return `every ${Math.round(ms / 86_400_000)}d`;}
|
||||
if (ms >= 3_600_000) {return `every ${Math.round(ms / 3_600_000)}h`;}
|
||||
if (ms >= 60_000) {return `every ${Math.round(ms / 60_000)}m`;}
|
||||
return `every ${Math.round(ms / 1000)}s`;
|
||||
}
|
||||
case "at":
|
||||
return `at ${schedule.at}`;
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function formatCountdown(ms: number): string {
|
||||
if (ms <= 0) {return "now";}
|
||||
const totalSec = Math.ceil(ms / 1000);
|
||||
if (totalSec < 60) {return `${totalSec}s`;}
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
if (min < 60) {return sec > 0 ? `${min}m ${sec}s` : `${min}m`;}
|
||||
const hr = Math.floor(min / 60);
|
||||
const remMin = min % 60;
|
||||
return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
|
||||
}
|
||||
|
||||
function formatTimeAgo(ms: number): string {
|
||||
const ago = Date.now() - ms;
|
||||
if (ago < 60_000) {return "just now";}
|
||||
if (ago < 3_600_000) {return `${Math.floor(ago / 60_000)}m ago`;}
|
||||
if (ago < 86_400_000) {return `${Math.floor(ago / 3_600_000)}h ago`;}
|
||||
return `${Math.floor(ago / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) {return `${ms}ms`;}
|
||||
if (ms < 60_000) {return `${(ms / 1000).toFixed(1)}s`;}
|
||||
return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;
|
||||
}
|
||||
|
||||
function jobStatusLabel(job: CronJob): string {
|
||||
if (!job.enabled) {return "disabled";}
|
||||
if (job.state.runningAtMs) {return "running";}
|
||||
return job.state.lastStatus ?? "idle";
|
||||
}
|
||||
|
||||
function jobStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "ok": return "var(--color-success, #22c55e)";
|
||||
case "running": return "var(--color-accent)";
|
||||
case "error": return "var(--color-error, #ef4444)";
|
||||
case "disabled": return "var(--color-text-muted)";
|
||||
case "skipped": return "var(--color-warning, #f59e0b)";
|
||||
default: return "var(--color-text-muted)";
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Countdown hook ─── */
|
||||
|
||||
function useCountdown(targetMs: number | null | undefined): string | null {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
if (!targetMs) {return;}
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [targetMs]);
|
||||
if (!targetMs) {return null;}
|
||||
return formatCountdown(targetMs - now);
|
||||
}
|
||||
|
||||
/* ─── Main component ─── */
|
||||
|
||||
export function CronDashboard({
|
||||
onSelectJob,
|
||||
}: {
|
||||
onSelectJob: (jobId: string) => void;
|
||||
}) {
|
||||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||
const [heartbeat, setHeartbeat] = useState<HeartbeatInfo>({ intervalMs: 30 * 60_000, nextDueEstimateMs: null });
|
||||
const [cronStatus, setCronStatus] = useState<CronStatusInfo>({ enabled: false, nextWakeAtMs: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cron/jobs");
|
||||
const data: CronJobsResponse = await res.json();
|
||||
setJobs(data.jobs ?? []);
|
||||
setHeartbeat(data.heartbeat ?? { intervalMs: 30 * 60_000, nextDueEstimateMs: null });
|
||||
setCronStatus(data.cronStatus ?? { enabled: false, nextWakeAtMs: null });
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
const id = setInterval(() => void fetchData(), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchData]);
|
||||
|
||||
const heartbeatCountdown = useCountdown(heartbeat.nextDueEstimateMs);
|
||||
const cronWakeCountdown = useCountdown(cronStatus.nextWakeAtMs);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const enabledJobs = jobs.filter((j) => j.enabled);
|
||||
const disabledJobs = jobs.filter((j) => !j.enabled);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<h1
|
||||
className="font-instrument text-3xl tracking-tight mb-1"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
Cron
|
||||
</h1>
|
||||
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
|
||||
Scheduled jobs and heartbeat status
|
||||
</p>
|
||||
|
||||
{/* Status cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{/* Heartbeat card */}
|
||||
<StatusCard
|
||||
title="Heartbeat"
|
||||
icon={<HeartbeatIcon />}
|
||||
value={heartbeatCountdown ? `in ${heartbeatCountdown}` : "unknown"}
|
||||
subtitle={`Interval: ${formatCountdown(heartbeat.intervalMs)}`}
|
||||
description="The heartbeat wakes the agent periodically. Cron jobs with wakeMode=next-heartbeat piggyback on this loop."
|
||||
/>
|
||||
|
||||
{/* Cron scheduler card */}
|
||||
<StatusCard
|
||||
title="Cron Scheduler"
|
||||
icon={<ClockIcon />}
|
||||
value={cronWakeCountdown ? `next in ${cronWakeCountdown}` : jobs.length === 0 ? "no jobs" : "idle"}
|
||||
subtitle={`${enabledJobs.length} active / ${jobs.length} total jobs`}
|
||||
description="The cron timer fires every ~60s, checking for due jobs. Isolated jobs run independently; main-session jobs wake the heartbeat."
|
||||
/>
|
||||
|
||||
{/* Running card */}
|
||||
<StatusCard
|
||||
title="Active Runs"
|
||||
icon={<RunningIcon />}
|
||||
value={`${jobs.filter((j) => j.state.runningAtMs).length}`}
|
||||
subtitle={`${jobs.filter((j) => j.state.lastStatus === "error").length} errors`}
|
||||
description="Jobs currently executing. Errors show consecutive failures."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Timeline - upcoming runs in next 24h */}
|
||||
<TimelineSection jobs={enabledJobs} />
|
||||
|
||||
{/* Jobs table */}
|
||||
<div className="mb-6">
|
||||
<h2
|
||||
className="text-sm font-medium uppercase tracking-wider mb-3"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Jobs
|
||||
</h2>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div
|
||||
className="p-8 text-center rounded-2xl"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
No cron jobs configured. Use <code className="px-1.5 py-0.5 rounded text-xs" style={{ background: "var(--color-surface-hover)" }}>ironclaw cron add</code> to create one.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
}}
|
||||
>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-xs uppercase tracking-wider">Name</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-xs uppercase tracking-wider">Schedule</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-xs uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-xs uppercase tracking-wider">Next Run</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-xs uppercase tracking-wider">Last Run</th>
|
||||
<th className="text-left px-4 py-2.5 font-medium text-xs uppercase tracking-wider">Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...enabledJobs, ...disabledJobs].map((job) => (
|
||||
<JobRow key={job.id} job={job} onClick={() => onSelectJob(job.id)} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Status card ─── */
|
||||
|
||||
function StatusCard({
|
||||
title,
|
||||
icon,
|
||||
value,
|
||||
subtitle,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
value: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-4"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "var(--shadow-sm)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span style={{ color: "var(--color-accent)" }}>{icon}</span>
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: "var(--color-text-muted)" }}>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-lg font-semibold mb-0.5" style={{ color: "var(--color-text)" }}>
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-xs mb-2" style={{ color: "var(--color-text-muted)" }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
<div className="text-[11px] leading-relaxed" style={{ color: "var(--color-text-muted)", opacity: 0.7 }}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Timeline ─── */
|
||||
|
||||
function TimelineSection({ jobs }: { jobs: CronJob[] }) {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const horizon = 24 * 60 * 60 * 1000; // 24h
|
||||
const upcoming = jobs
|
||||
.filter((j) => j.state.nextRunAtMs && j.state.nextRunAtMs > now && j.state.nextRunAtMs < now + horizon)
|
||||
.toSorted((a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0));
|
||||
|
||||
if (upcoming.length === 0) {return null;}
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2
|
||||
className="text-sm font-medium uppercase tracking-wider mb-3"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Upcoming (next 24h)
|
||||
</h2>
|
||||
<div
|
||||
className="rounded-2xl p-4"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Timeline bar */}
|
||||
<div
|
||||
className="absolute top-0 left-3 bottom-0 w-px"
|
||||
style={{ background: "var(--color-border)" }}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{upcoming.map((job) => {
|
||||
const timeUntil = (job.state.nextRunAtMs ?? 0) - now;
|
||||
return (
|
||||
<div key={job.id} className="flex items-center gap-3 pl-1">
|
||||
<div
|
||||
className="relative z-10 w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: "var(--color-accent)", opacity: 0.8 }}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: "var(--color-bg)" }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{job.name}
|
||||
</span>
|
||||
<span className="text-xs ml-2" style={{ color: "var(--color-text-muted)" }}>
|
||||
in {formatCountdown(timeUntil)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] flex-shrink-0" style={{ color: "var(--color-text-muted)" }}>
|
||||
{new Date(job.state.nextRunAtMs!).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Job row ─── */
|
||||
|
||||
function JobRow({ job, onClick }: { job: CronJob; onClick: () => void }) {
|
||||
const status = jobStatusLabel(job);
|
||||
const statusColor = jobStatusColor(status);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 5000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const nextRunStr = job.state.nextRunAtMs
|
||||
? job.state.nextRunAtMs > now
|
||||
? `in ${formatCountdown(job.state.nextRunAtMs - now)}`
|
||||
: "overdue"
|
||||
: "-";
|
||||
|
||||
const lastRunStr = job.state.lastRunAtMs
|
||||
? `${formatTimeAgo(job.state.lastRunAtMs)}${job.state.lastDurationMs ? ` (${formatDuration(job.state.lastDurationMs)})` : ""}`
|
||||
: "-";
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="cursor-pointer transition-colors"
|
||||
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||
onClick={onClick}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium" style={{ color: "var(--color-text)" }}>{job.name}</div>
|
||||
{job.description && (
|
||||
<div className="text-xs truncate max-w-[200px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
{job.description}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{formatSchedule(job.schedule)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${statusColor} 12%, transparent)`,
|
||||
color: statusColor,
|
||||
}}
|
||||
>
|
||||
{status === "running" && (
|
||||
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: statusColor }} />
|
||||
)}
|
||||
{status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{nextRunStr}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{lastRunStr}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{job.sessionTarget}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Icons ─── */
|
||||
|
||||
function HeartbeatIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ClockIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RunningIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
469
apps/web/app/components/cron/cron-job-detail.tsx
Normal file
469
apps/web/app/components/cron/cron-job-detail.tsx
Normal file
@ -0,0 +1,469 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { CronJob, CronRunLogEntry, CronRunsResponse } from "../../types/cron";
|
||||
import { CronRunChat, CronRunTranscriptSearch } from "./cron-run-chat";
|
||||
|
||||
/* ─── Helpers ─── */
|
||||
|
||||
function formatSchedule(schedule: CronJob["schedule"]): string {
|
||||
switch (schedule.kind) {
|
||||
case "cron":
|
||||
return schedule.expr + (schedule.tz ? ` (${schedule.tz})` : "");
|
||||
case "every": {
|
||||
const ms = schedule.everyMs;
|
||||
if (ms >= 86_400_000) {return `every ${Math.round(ms / 86_400_000)} day(s)`;}
|
||||
if (ms >= 3_600_000) {return `every ${Math.round(ms / 3_600_000)} hour(s)`;}
|
||||
if (ms >= 60_000) {return `every ${Math.round(ms / 60_000)} minute(s)`;}
|
||||
return `every ${Math.round(ms / 1000)} second(s)`;
|
||||
}
|
||||
case "at":
|
||||
return new Date(schedule.at).toLocaleString();
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function formatCountdown(ms: number): string {
|
||||
if (ms <= 0) {return "now";}
|
||||
const totalSec = Math.ceil(ms / 1000);
|
||||
if (totalSec < 60) {return `${totalSec}s`;}
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
if (min < 60) {return sec > 0 ? `${min}m ${sec}s` : `${min}m`;}
|
||||
const hr = Math.floor(min / 60);
|
||||
const remMin = min % 60;
|
||||
return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) {return `${ms}ms`;}
|
||||
if (ms < 60_000) {return `${(ms / 1000).toFixed(1)}s`;}
|
||||
return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;
|
||||
}
|
||||
|
||||
function payloadSummary(payload: CronJob["payload"]): string {
|
||||
if (payload.kind === "systemEvent") {return payload.text.slice(0, 120);}
|
||||
return payload.message.slice(0, 120);
|
||||
}
|
||||
|
||||
/* ─── Main component ─── */
|
||||
|
||||
export function CronJobDetail({
|
||||
job,
|
||||
onBack,
|
||||
}: {
|
||||
job: CronJob;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [runs, setRuns] = useState<CronRunLogEntry[]>([]);
|
||||
const [loadingRuns, setLoadingRuns] = useState(true);
|
||||
const [expandedRunTs, setExpandedRunTs] = useState<number | null>(null);
|
||||
|
||||
const fetchRuns = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/cron/jobs/${encodeURIComponent(job.id)}/runs?limit=50`);
|
||||
const data: CronRunsResponse = await res.json();
|
||||
setRuns(data.entries ?? []);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingRuns(false);
|
||||
}
|
||||
}, [job.id]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchRuns();
|
||||
const id = setInterval(fetchRuns, 15_000);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchRuns]);
|
||||
|
||||
const status = !job.enabled
|
||||
? "disabled"
|
||||
: job.state.runningAtMs
|
||||
? "running"
|
||||
: (job.state.lastStatus ?? "idle");
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* Back button + header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1 text-sm mb-4 cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
|
||||
</svg>
|
||||
Back to Cron
|
||||
</button>
|
||||
|
||||
{/* Job header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h1
|
||||
className="font-instrument text-3xl tracking-tight"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{job.name}
|
||||
</h1>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
{job.description && (
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
{job.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config + countdown grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||
{/* Next run countdown */}
|
||||
<NextRunCard job={job} />
|
||||
|
||||
{/* Job config */}
|
||||
<div
|
||||
className="rounded-2xl p-4"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider mb-3" style={{ color: "var(--color-text-muted)" }}>
|
||||
Configuration
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<ConfigRow label="Schedule" value={formatSchedule(job.schedule)} />
|
||||
<ConfigRow label="Target" value={job.sessionTarget} />
|
||||
<ConfigRow label="Wake mode" value={job.wakeMode} />
|
||||
<ConfigRow label="Payload" value={`${job.payload.kind}: ${payloadSummary(job.payload)}`} />
|
||||
{job.agentId && <ConfigRow label="Agent" value={job.agentId} />}
|
||||
{job.delivery && <ConfigRow label="Delivery" value={job.delivery.mode} />}
|
||||
<ConfigRow label="Created" value={new Date(job.createdAtMs).toLocaleString()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error streak */}
|
||||
{job.state.consecutiveErrors && job.state.consecutiveErrors > 0 && (
|
||||
<div
|
||||
className="rounded-2xl p-4 mb-6"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--color-error, #ef4444) 6%, var(--color-surface))",
|
||||
border: "1px solid color-mix(in srgb, var(--color-error, #ef4444) 18%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-error, #ef4444)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-error, #ef4444)" }}>
|
||||
{job.state.consecutiveErrors} consecutive error{job.state.consecutiveErrors > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{job.state.lastError && (
|
||||
<p className="text-xs font-mono mt-1" style={{ color: "var(--color-error, #ef4444)", opacity: 0.8 }}>
|
||||
{job.state.lastError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Run history */}
|
||||
<div>
|
||||
<h2
|
||||
className="text-sm font-medium uppercase tracking-wider mb-3"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Run History
|
||||
</h2>
|
||||
|
||||
{loadingRuns ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div
|
||||
className="p-8 text-center rounded-2xl"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
No runs recorded yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{runs.toReversed().map((run) => (
|
||||
<RunCard
|
||||
key={`${run.ts}-${run.jobId}`}
|
||||
run={run}
|
||||
isExpanded={expandedRunTs === run.ts}
|
||||
onToggle={() => setExpandedRunTs(expandedRunTs === run.ts ? null : run.ts)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Next run countdown card ─── */
|
||||
|
||||
function NextRunCard({ job }: { job: CronJob }) {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const nextMs = job.state.nextRunAtMs;
|
||||
const isRunning = !!job.state.runningAtMs;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-4 flex flex-col justify-center"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: "var(--color-text-muted)" }}>
|
||||
{isRunning ? "Currently Running" : "Next Run"}
|
||||
</h3>
|
||||
{isRunning ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full animate-pulse"
|
||||
style={{ background: "var(--color-accent)" }}
|
||||
/>
|
||||
<span className="text-2xl font-semibold" style={{ color: "var(--color-accent)" }}>
|
||||
Running now
|
||||
</span>
|
||||
</div>
|
||||
) : nextMs ? (
|
||||
<>
|
||||
<div className="text-3xl font-semibold mb-1" style={{ color: "var(--color-text)" }}>
|
||||
{nextMs > now ? formatCountdown(nextMs - now) : "overdue"}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{new Date(nextMs).toLocaleString()}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-lg" style={{ color: "var(--color-text-muted)" }}>
|
||||
{job.enabled ? "Not scheduled" : "Disabled"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Run card ─── */
|
||||
|
||||
function RunCard({
|
||||
run,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
run: CronRunLogEntry;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const statusColor = run.status === "ok"
|
||||
? "var(--color-success, #22c55e)"
|
||||
: run.status === "error"
|
||||
? "var(--color-error, #ef4444)"
|
||||
: "var(--color-warning, #f59e0b)";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl overflow-hidden"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{/* Run header - clickable */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left cursor-pointer transition-colors"
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ background: statusColor }}
|
||||
/>
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="text-sm" style={{ color: "var(--color-text)" }}>
|
||||
{new Date(run.ts).toLocaleString()}
|
||||
</span>
|
||||
|
||||
{/* Status badge */}
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${statusColor} 12%, transparent)`,
|
||||
color: statusColor,
|
||||
}}
|
||||
>
|
||||
{run.status ?? "unknown"}
|
||||
</span>
|
||||
|
||||
{/* Duration */}
|
||||
{run.durationMs != null && (
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{formatDuration(run.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{run.summary && (
|
||||
<span className="text-xs truncate flex-1 min-w-0" style={{ color: "var(--color-text-muted)" }}>
|
||||
{run.summary}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Has session indicator */}
|
||||
{run.sessionId && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: "var(--color-surface-hover)", color: "var(--color-text-muted)" }}>
|
||||
chat
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Chevron */}
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="var(--color-text-muted)"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`flex-shrink-0 transition-transform duration-200 ${isExpanded ? "" : "-rotate-90"}`}
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div
|
||||
className="px-4 pb-4"
|
||||
style={{ borderTop: "1px solid var(--color-border)" }}
|
||||
>
|
||||
{/* Error message */}
|
||||
{run.error && (
|
||||
<div
|
||||
className="mt-3 text-xs font-mono rounded-lg px-3 py-2"
|
||||
style={{
|
||||
color: "var(--color-error, #ef4444)",
|
||||
background: "color-mix(in srgb, var(--color-error, #ef4444) 6%, var(--color-surface))",
|
||||
}}
|
||||
>
|
||||
{run.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session transcript */}
|
||||
{run.sessionId ? (
|
||||
<div className="mt-4">
|
||||
<CronRunChat sessionId={run.sessionId} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<RunTranscriptOrSummary run={run} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Transcript search with summary fallback ─── */
|
||||
|
||||
function RunTranscriptOrSummary({ run }: { run: CronRunLogEntry }) {
|
||||
const summaryFallback = run.summary ? (
|
||||
<div>
|
||||
<div
|
||||
className="text-[11px] uppercase tracking-wider font-medium mb-2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Run Output
|
||||
</div>
|
||||
<div className="chat-prose text-sm" style={{ color: "var(--color-text)" }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{run.summary}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
No output recorded for this run.
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<CronRunTranscriptSearch
|
||||
jobId={run.jobId}
|
||||
runAtMs={run.runAtMs}
|
||||
summary={run.summary}
|
||||
fallback={summaryFallback}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Subcomponents ─── */
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const color = status === "ok"
|
||||
? "var(--color-success, #22c55e)"
|
||||
: status === "running"
|
||||
? "var(--color-accent)"
|
||||
: status === "error"
|
||||
? "var(--color-error, #ef4444)"
|
||||
: "var(--color-text-muted)";
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${color} 12%, transparent)`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{status === "running" && (
|
||||
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: color }} />
|
||||
)}
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs font-medium w-20 flex-shrink-0" style={{ color: "var(--color-text-muted)" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs break-all" style={{ color: "var(--color-text)" }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
445
apps/web/app/components/cron/cron-run-chat.tsx
Normal file
445
apps/web/app/components/cron/cron-run-chat.tsx
Normal file
@ -0,0 +1,445 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { SessionMessage, SessionMessagePart, CronRunSessionResponse } from "../../types/cron";
|
||||
|
||||
/* ─── Main component ─── */
|
||||
|
||||
export function CronRunChat({ sessionId }: { sessionId: string }) {
|
||||
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchSession = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/cron/runs/${encodeURIComponent(sessionId)}`);
|
||||
if (!res.ok) {
|
||||
setError(res.status === 404 ? "Session transcript not found" : "Failed to load session");
|
||||
return;
|
||||
}
|
||||
const data: CronRunSessionResponse = await res.json();
|
||||
setMessages(data.messages ?? []);
|
||||
} catch {
|
||||
setError("Failed to load session");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSession();
|
||||
}, [fetchSession]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-4">
|
||||
<div
|
||||
className="w-4 h-4 border-[1.5px] rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>Loading session transcript...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="text-xs rounded-lg px-3 py-2"
|
||||
style={{ color: "var(--color-text-muted)", background: "var(--color-surface-hover)" }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="text-xs py-2" style={{ color: "var(--color-text-muted)" }}>
|
||||
Empty session transcript.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] uppercase tracking-wider font-medium mb-2" style={{ color: "var(--color-text-muted)" }}>
|
||||
Session Transcript
|
||||
</div>
|
||||
{messages.map((msg) => (
|
||||
<CronChatMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Transcript search fallback (no sessionId) ─── */
|
||||
|
||||
export function CronRunTranscriptSearch({
|
||||
jobId,
|
||||
runAtMs,
|
||||
summary,
|
||||
fallback,
|
||||
}: {
|
||||
jobId: string;
|
||||
runAtMs?: number;
|
||||
summary?: string;
|
||||
fallback?: React.ReactNode;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
const fetchTranscript = useCallback(async () => {
|
||||
if (!runAtMs || !summary) {
|
||||
setLoading(false);
|
||||
setNotFound(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
jobId,
|
||||
runAtMs: String(runAtMs),
|
||||
summary,
|
||||
});
|
||||
const res = await fetch(`/api/cron/runs/search-transcript?${params}`);
|
||||
if (!res.ok) {
|
||||
setNotFound(true);
|
||||
return;
|
||||
}
|
||||
const data = await res.json() as { messages?: SessionMessage[] };
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
setMessages(data.messages);
|
||||
} else {
|
||||
setNotFound(true);
|
||||
}
|
||||
} catch {
|
||||
setNotFound(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobId, runAtMs, summary]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchTranscript();
|
||||
}, [fetchTranscript]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-4">
|
||||
<div
|
||||
className="w-4 h-4 border-[1.5px] rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>Searching for transcript...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (notFound || messages.length === 0) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] uppercase tracking-wider font-medium mb-2" style={{ color: "var(--color-text-muted)" }}>
|
||||
Session Transcript
|
||||
</div>
|
||||
{messages.map((msg) => (
|
||||
<CronChatMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Message rendering ─── */
|
||||
|
||||
function CronChatMessage({ message }: { message: SessionMessage }) {
|
||||
const isUser = message.role === "user";
|
||||
const isSystem = message.role === "system";
|
||||
|
||||
// Group parts into segments
|
||||
const segments = groupPartsIntoSegments(message.parts);
|
||||
|
||||
if (isSystem) {
|
||||
const textContent = message.parts
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n");
|
||||
return (
|
||||
<div
|
||||
className="text-xs rounded-lg px-3 py-2 font-mono"
|
||||
style={{
|
||||
background: "var(--color-surface-hover)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px dashed var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">system:</span> {textContent.slice(0, 500)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUser) {
|
||||
const textContent = message.parts
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n");
|
||||
return (
|
||||
<div className="flex justify-end py-1">
|
||||
<div
|
||||
className="max-w-[80%] rounded-2xl rounded-br-sm px-4 py-2.5 text-sm"
|
||||
style={{
|
||||
background: "var(--color-user-bubble)",
|
||||
color: "var(--color-user-bubble-text)",
|
||||
}}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{textContent}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Assistant message
|
||||
return (
|
||||
<div className="py-2 space-y-2">
|
||||
{segments.map((segment, idx) => {
|
||||
if (segment.type === "text") {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="chat-prose text-sm"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{segment.text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (segment.type === "thinking") {
|
||||
return <ThinkingBlock key={idx} text={segment.thinking} />;
|
||||
}
|
||||
|
||||
if (segment.type === "tool-group") {
|
||||
return <ToolGroup key={idx} tools={segment.tools} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Part grouping ─── */
|
||||
|
||||
type ChatSegment =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "thinking"; thinking: string }
|
||||
| { type: "tool-group"; tools: Array<SessionMessagePart & { type: "tool-call" }> };
|
||||
|
||||
function groupPartsIntoSegments(parts: SessionMessagePart[]): ChatSegment[] {
|
||||
const segments: ChatSegment[] = [];
|
||||
let toolBuffer: Array<SessionMessagePart & { type: "tool-call" }> = [];
|
||||
|
||||
const flushTools = () => {
|
||||
if (toolBuffer.length > 0) {
|
||||
segments.push({ type: "tool-group", tools: [...toolBuffer] });
|
||||
toolBuffer = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
flushTools();
|
||||
segments.push({ type: "text", text: part.text });
|
||||
} else if (part.type === "thinking") {
|
||||
flushTools();
|
||||
segments.push({ type: "thinking", thinking: part.thinking });
|
||||
} else if (part.type === "tool-call") {
|
||||
toolBuffer.push(part as SessionMessagePart & { type: "tool-call" });
|
||||
}
|
||||
}
|
||||
flushTools();
|
||||
return segments;
|
||||
}
|
||||
|
||||
/* ─── Thinking block (always expanded for historical runs) ─── */
|
||||
|
||||
function ThinkingBlock({ text }: { text: string }) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const isLong = text.length > 600;
|
||||
|
||||
return (
|
||||
<div className="my-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex items-center gap-2 py-1 text-[13px] cursor-pointer"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="opacity-60"
|
||||
>
|
||||
<path d="M12 2a7 7 0 0 0-7 7c0 2.38 1.19 4.47 3 5.74V17a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-2.26c1.81-1.27 3-3.36 3-5.74a7 7 0 0 0-7-7z" />
|
||||
<path d="M10 21h4" />
|
||||
</svg>
|
||||
<span className="font-medium">Thinking</span>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 12 12" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
className={`transition-transform duration-200 ${expanded ? "" : "-rotate-90"}`}
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" />
|
||||
</svg>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div
|
||||
className={`text-[13px] whitespace-pre-wrap leading-relaxed pl-6 ${isLong && !expanded ? "max-h-24 overflow-hidden" : ""}`}
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Tool group ─── */
|
||||
|
||||
function ToolGroup({ tools }: { tools: Array<SessionMessagePart & { type: "tool-call" }> }) {
|
||||
return (
|
||||
<div className="my-2 relative">
|
||||
{/* Timeline connector */}
|
||||
<div
|
||||
className="absolute w-px"
|
||||
style={{ left: 9, top: 8, bottom: 8, background: "var(--color-border)" }}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
{tools.map((tool) => (
|
||||
<ToolCallStep key={tool.toolCallId} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Tool call step ─── */
|
||||
|
||||
function ToolCallStep({ tool }: { tool: SessionMessagePart & { type: "tool-call" } }) {
|
||||
const [showOutput, setShowOutput] = useState(false);
|
||||
const label = buildToolLabel(tool.toolName, tool.args);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 py-1">
|
||||
<div
|
||||
className="relative z-10 flex-shrink-0 w-5 h-5 mt-0.5 flex items-center justify-center rounded-full"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
<ToolIcon toolName={tool.toolName} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] leading-snug" style={{ color: "var(--color-text-secondary)" }}>
|
||||
{label}
|
||||
</div>
|
||||
{tool.output && (
|
||||
<div className="mt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOutput((v) => !v)}
|
||||
className="text-[11px] hover:underline cursor-pointer"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
{showOutput ? "Hide output" : "Show output"}
|
||||
</button>
|
||||
{showOutput && (
|
||||
<pre
|
||||
className="mt-1 text-[11px] font-mono rounded-lg px-2.5 py-2 overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto leading-relaxed"
|
||||
style={{ color: "var(--color-text-muted)", background: "var(--color-bg)" }}
|
||||
>
|
||||
{tool.output.length > 3000 ? tool.output.slice(0, 3000) + "\n..." : tool.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Tool label builder ─── */
|
||||
|
||||
function buildToolLabel(toolName: string, args?: unknown): string {
|
||||
const a = args as Record<string, unknown> | undefined;
|
||||
const strVal = (key: string) => {
|
||||
const v = a?.[key];
|
||||
return typeof v === "string" && v.length > 0 ? v : null;
|
||||
};
|
||||
|
||||
const n = toolName.toLowerCase().replace(/[_-]/g, "");
|
||||
|
||||
if (["websearch", "search", "googlesearch"].some((k) => n.includes(k))) {
|
||||
const q = strVal("query") ?? strVal("search_query") ?? strVal("q");
|
||||
return q ? `Searching: ${q.slice(0, 80)}` : "Searching...";
|
||||
}
|
||||
if (["fetchurl", "fetch", "webfetch"].some((k) => n.includes(k))) {
|
||||
const u = strVal("url") ?? strVal("path");
|
||||
return u ? `Fetching: ${u.slice(0, 60)}` : "Fetching page";
|
||||
}
|
||||
if (["read", "readfile", "getfile"].some((k) => n.includes(k))) {
|
||||
const p = strVal("path") ?? strVal("file");
|
||||
return p ? `Reading: ${p.split("/").pop()}` : "Reading file";
|
||||
}
|
||||
if (["bash", "shell", "execute", "exec", "terminal"].some((k) => n.includes(k))) {
|
||||
const cmd = strVal("command") ?? strVal("cmd");
|
||||
return cmd ? `Running: ${cmd.slice(0, 60)}` : "Running command";
|
||||
}
|
||||
if (["write", "create", "edit", "str_replace", "save"].some((k) => n.includes(k))) {
|
||||
const p = strVal("path") ?? strVal("file");
|
||||
return p ? `Editing: ${p.split("/").pop()}` : "Editing file";
|
||||
}
|
||||
|
||||
return toolName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/* ─── Tool icon ─── */
|
||||
|
||||
function ToolIcon({ toolName }: { toolName: string }) {
|
||||
const color = "var(--color-text-muted)";
|
||||
const n = toolName.toLowerCase().replace(/[_-]/g, "");
|
||||
|
||||
if (["search", "websearch"].some((k) => n.includes(k))) {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (["bash", "shell", "exec", "terminal"].some((k) => n.includes(k))) {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="4 17 10 11 4 5" /><line x1="12" x2="20" y1="19" y2="19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (["write", "edit", "create", "save"].some((k) => n.includes(k))) {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
// Default: file/read icon
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
300
apps/web/app/components/diff-viewer.tsx
Normal file
300
apps/web/app/components/diff-viewer.tsx
Normal file
@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
type DiffCardProps = {
|
||||
/** Raw unified diff text (contents of a ```diff block) */
|
||||
diff: string;
|
||||
};
|
||||
|
||||
type DiffFile = {
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
hunks: DiffHunk[];
|
||||
additions: number;
|
||||
deletions: number;
|
||||
};
|
||||
|
||||
type DiffHunk = {
|
||||
header: string;
|
||||
lines: DiffLine[];
|
||||
};
|
||||
|
||||
type DiffLine = {
|
||||
type: "addition" | "deletion" | "context" | "header";
|
||||
content: string;
|
||||
oldLine?: number;
|
||||
newLine?: number;
|
||||
};
|
||||
|
||||
/** Parse unified diff text into structured file sections. */
|
||||
function parseDiff(raw: string): DiffFile[] {
|
||||
const files: DiffFile[] = [];
|
||||
const lines = raw.split("\n");
|
||||
let current: DiffFile | null = null;
|
||||
let currentHunk: DiffHunk | null = null;
|
||||
let oldLine = 0;
|
||||
let newLine = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// File header: --- a/path or --- /dev/null
|
||||
if (line.startsWith("--- ")) {
|
||||
const nextLine = lines[i + 1];
|
||||
if (nextLine?.startsWith("+++ ")) {
|
||||
const oldPath = line.replace(/^--- (a\/)?/, "").trim();
|
||||
const newPath = nextLine.replace(/^\+\+\+ (b\/)?/, "").trim();
|
||||
current = { oldPath, newPath, hunks: [], additions: 0, deletions: 0 };
|
||||
files.push(current);
|
||||
i++; // skip +++ line
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Hunk header: @@ -old,count +new,count @@
|
||||
const hunkMatch = line.match(/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)/);
|
||||
if (hunkMatch) {
|
||||
oldLine = parseInt(hunkMatch[1], 10);
|
||||
newLine = parseInt(hunkMatch[2], 10);
|
||||
currentHunk = {
|
||||
header: line,
|
||||
lines: [{ type: "header", content: line }],
|
||||
};
|
||||
if (current) {
|
||||
current.hunks.push(currentHunk);
|
||||
} else {
|
||||
// Diff without file headers -- create an implicit file
|
||||
current = { oldPath: "", newPath: "", hunks: [currentHunk], additions: 0, deletions: 0 };
|
||||
files.push(current);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentHunk || !current) {continue;}
|
||||
|
||||
if (line.startsWith("+")) {
|
||||
currentHunk.lines.push({ type: "addition", content: line.slice(1), newLine });
|
||||
current.additions++;
|
||||
newLine++;
|
||||
} else if (line.startsWith("-")) {
|
||||
currentHunk.lines.push({ type: "deletion", content: line.slice(1), oldLine });
|
||||
current.deletions++;
|
||||
oldLine++;
|
||||
} else if (line.startsWith(" ") || line === "") {
|
||||
currentHunk.lines.push({ type: "context", content: line.slice(1) || "", oldLine, newLine });
|
||||
oldLine++;
|
||||
newLine++;
|
||||
}
|
||||
}
|
||||
|
||||
// If no structured files were found, treat the whole thing as one block
|
||||
if (files.length === 0 && raw.trim()) {
|
||||
const fallbackLines = raw.split("\n").map((l): DiffLine => {
|
||||
if (l.startsWith("+")) {return { type: "addition", content: l.slice(1) };}
|
||||
if (l.startsWith("-")) {return { type: "deletion", content: l.slice(1) };}
|
||||
return { type: "context", content: l };
|
||||
});
|
||||
files.push({
|
||||
oldPath: "",
|
||||
newPath: "",
|
||||
hunks: [{ header: "", lines: fallbackLines }],
|
||||
additions: fallbackLines.filter((l) => l.type === "addition").length,
|
||||
deletions: fallbackLines.filter((l) => l.type === "deletion").length,
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function displayPath(file: DiffFile): string {
|
||||
if (file.newPath && file.newPath !== "/dev/null") {return file.newPath;}
|
||||
if (file.oldPath && file.oldPath !== "/dev/null") {return file.oldPath;}
|
||||
return "diff";
|
||||
}
|
||||
|
||||
/* ─── Icons ─── */
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ expanded }: { expanded: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
transition: "transform 150ms ease",
|
||||
transform: expanded ? "rotate(90deg)" : "rotate(0deg)",
|
||||
}}
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Single file diff ─── */
|
||||
|
||||
function DiffFileCard({ file }: { file: DiffFile }) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const path = displayPath(file);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
{/* File header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-left"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderBottom: expanded ? "1px solid var(--color-border)" : "none",
|
||||
}}
|
||||
>
|
||||
<ChevronIcon expanded={expanded} />
|
||||
<FileIcon />
|
||||
<span
|
||||
className="text-sm font-mono font-medium flex-1 truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{path}
|
||||
</span>
|
||||
{file.additions > 0 && (
|
||||
<span className="text-xs font-mono font-medium" style={{ color: "var(--diff-add-badge)" }}>
|
||||
+{file.additions}
|
||||
</span>
|
||||
)}
|
||||
{file.deletions > 0 && (
|
||||
<span className="text-xs font-mono font-medium" style={{ color: "var(--diff-del-badge)" }}>
|
||||
-{file.deletions}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Diff lines */}
|
||||
{expanded && (
|
||||
<div
|
||||
className="overflow-x-auto"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
<table className="w-full text-xs font-mono leading-5 border-collapse" style={{ tabSize: 4 }}>
|
||||
<tbody>
|
||||
{file.hunks.map((hunk, hi) =>
|
||||
hunk.lines.map((line, li) => {
|
||||
if (line.type === "header") {
|
||||
return (
|
||||
<tr key={`${hi}-${li}`}>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="px-3 py-1 select-none"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{line.content}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const bgColor =
|
||||
line.type === "addition"
|
||||
? "var(--diff-add-bg)"
|
||||
: line.type === "deletion"
|
||||
? "var(--diff-del-bg)"
|
||||
: "transparent";
|
||||
const textColor =
|
||||
line.type === "addition"
|
||||
? "var(--diff-add-text)"
|
||||
: line.type === "deletion"
|
||||
? "var(--diff-del-text)"
|
||||
: "var(--color-text)";
|
||||
const prefix =
|
||||
line.type === "addition"
|
||||
? "+"
|
||||
: line.type === "deletion"
|
||||
? "-"
|
||||
: " ";
|
||||
|
||||
return (
|
||||
<tr key={`${hi}-${li}`} style={{ background: bgColor }}>
|
||||
{/* Old line number */}
|
||||
<td
|
||||
className="select-none text-right pr-2 pl-3"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
opacity: 0.5,
|
||||
width: "1%",
|
||||
whiteSpace: "nowrap",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{line.type !== "addition" ? line.oldLine : ""}
|
||||
</td>
|
||||
{/* New line number */}
|
||||
<td
|
||||
className="select-none text-right pr-3"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
opacity: 0.5,
|
||||
width: "1%",
|
||||
whiteSpace: "nowrap",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{line.type !== "deletion" ? line.newLine : ""}
|
||||
</td>
|
||||
{/* Content */}
|
||||
<td
|
||||
className="pr-4"
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
<span
|
||||
className="select-none inline-block w-4 text-center"
|
||||
style={{ opacity: 0.6, userSelect: "none" }}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
{line.content}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main DiffCard ─── */
|
||||
|
||||
export function DiffCard({ diff }: DiffCardProps) {
|
||||
const files = useMemo(() => parseDiff(diff), [diff]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 my-3">
|
||||
{files.map((file, i) => (
|
||||
<DiffFileCard key={i} file={file} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
936
apps/web/app/components/file-picker-modal.tsx
Normal file
936
apps/web/app/components/file-picker-modal.tsx
Normal file
@ -0,0 +1,936 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
type BrowseEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "folder" | "file" | "document" | "database";
|
||||
children?: BrowseEntry[];
|
||||
};
|
||||
|
||||
export type SelectedFile = {
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type FilePickerModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (files: SelectedFile[]) => void;
|
||||
};
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function getCategoryFromName(
|
||||
name: string,
|
||||
): "image" | "video" | "audio" | "pdf" | "code" | "document" | "folder" | "other" {
|
||||
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (
|
||||
["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext)
|
||||
)
|
||||
{return "image";}
|
||||
if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext)) {return "video";}
|
||||
if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext)) {return "audio";}
|
||||
if (ext === "pdf") {return "pdf";}
|
||||
if (
|
||||
[
|
||||
"js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
|
||||
"cpp", "c", "h", "css", "html", "json", "yaml", "yml",
|
||||
"toml", "md", "sh", "bash", "sql", "swift", "kt",
|
||||
].includes(ext)
|
||||
)
|
||||
{return "code";}
|
||||
if (
|
||||
["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "csv"].includes(ext)
|
||||
)
|
||||
{return "document";}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function buildBreadcrumbs(
|
||||
dir: string,
|
||||
): { label: string; path: string }[] {
|
||||
const segments: { label: string; path: string }[] = [];
|
||||
const homeMatch = dir.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/);
|
||||
const homeDir = homeMatch?.[1];
|
||||
|
||||
if (homeDir) {
|
||||
segments.push({ label: "~", path: homeDir });
|
||||
const rest = dir.slice(homeDir.length);
|
||||
const parts = rest.split("/").filter(Boolean);
|
||||
let currentPath = homeDir;
|
||||
for (const part of parts) {
|
||||
currentPath += "/" + part;
|
||||
segments.push({ label: part, path: currentPath });
|
||||
}
|
||||
} else if (dir === "/") {
|
||||
segments.push({ label: "/", path: "/" });
|
||||
} else {
|
||||
segments.push({ label: "/", path: "/" });
|
||||
const parts = dir.split("/").filter(Boolean);
|
||||
let currentPath = "";
|
||||
for (const part of parts) {
|
||||
currentPath += "/" + part;
|
||||
segments.push({ label: part, path: currentPath });
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
const pickerColors: Record<string, { bg: string; fg: string }> = {
|
||||
folder: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
|
||||
image: { bg: "rgba(16, 185, 129, 0.12)", fg: "#10b981" },
|
||||
video: { bg: "rgba(139, 92, 246, 0.12)", fg: "#8b5cf6" },
|
||||
audio: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
|
||||
pdf: { bg: "rgba(239, 68, 68, 0.12)", fg: "#ef4444" },
|
||||
code: { bg: "rgba(59, 130, 246, 0.12)", fg: "#3b82f6" },
|
||||
document: { bg: "rgba(107, 114, 128, 0.12)", fg: "#6b7280" },
|
||||
other: { bg: "rgba(107, 114, 128, 0.08)", fg: "#9ca3af" },
|
||||
};
|
||||
|
||||
// ── Icons ──
|
||||
|
||||
function PickerIcon({
|
||||
category,
|
||||
size = 16,
|
||||
}: {
|
||||
category: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const props = {
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
};
|
||||
switch (category) {
|
||||
case "folder":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
</svg>
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
|
||||
<rect x="2" y="6" width="14" height="12" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
case "pdf":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="M10 13h4" />
|
||||
<path d="M10 17h4" />
|
||||
</svg>
|
||||
);
|
||||
case "code":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
);
|
||||
case "document":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="M16 13H8" />
|
||||
<path d="M16 17H8" />
|
||||
<path d="M10 9H8" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main component ──
|
||||
|
||||
export function FilePickerModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: FilePickerModalProps) {
|
||||
const [currentDir, setCurrentDir] = useState<string | null>(null);
|
||||
const [displayDir, setDisplayDir] = useState("");
|
||||
const [entries, setEntries] = useState<BrowseEntry[]>([]);
|
||||
const [parentDir, setParentDir] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<
|
||||
Map<string, SelectedFile>
|
||||
>(new Map());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Animation
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => setVisible(true)),
|
||||
);
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset transient state on close
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch("");
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Search input ref for autofocus
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fetch directory
|
||||
const fetchDir = useCallback(async (dir: string | null) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = dir
|
||||
? `/api/workspace/browse?dir=${encodeURIComponent(dir)}`
|
||||
: "/api/workspace/browse";
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {throw new Error("Failed to list directory");}
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
setDisplayDir(data.currentDir || "");
|
||||
setParentDir(data.parentDir ?? null);
|
||||
} catch {
|
||||
setError("Could not load this directory");
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch on open and navigation
|
||||
useEffect(() => {
|
||||
if (open) { void fetchDir(currentDir); }
|
||||
}, [open, currentDir, fetchDir]);
|
||||
|
||||
// Escape key
|
||||
useEffect(() => {
|
||||
if (!open) {return;}
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {onClose();}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Handlers
|
||||
const toggleSelect = useCallback(
|
||||
(entry: BrowseEntry) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(entry.path)) {
|
||||
next.delete(entry.path);
|
||||
} else {
|
||||
next.set(entry.path, {
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const navigateInto = useCallback((path: string) => {
|
||||
setCurrentDir(path);
|
||||
setSearch("");
|
||||
setCreatingFolder(false);
|
||||
}, []);
|
||||
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
if (!newFolderName.trim() || !displayDir) {return;}
|
||||
const folderPath = `${displayDir}/${newFolderName.trim()}`;
|
||||
try {
|
||||
await fetch("/api/workspace/mkdir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: folderPath }),
|
||||
});
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName("");
|
||||
void fetchDir(currentDir);
|
||||
} catch {
|
||||
setError("Failed to create folder");
|
||||
}
|
||||
}, [newFolderName, displayDir, currentDir, fetchDir]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
onSelect(Array.from(selected.values()));
|
||||
setSelected(new Map());
|
||||
onClose();
|
||||
}, [selected, onSelect, onClose]);
|
||||
|
||||
// Filter & sort entries (folders first, then alphabetically)
|
||||
const sorted = entries
|
||||
.filter(
|
||||
(e) =>
|
||||
!search ||
|
||||
e.name
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()),
|
||||
)
|
||||
.toSorted((a, b) => {
|
||||
if (a.type === "folder" && b.type !== "folder")
|
||||
{return -1;}
|
||||
if (a.type !== "folder" && b.type === "folder")
|
||||
{return 1;}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const breadcrumbs = displayDir
|
||||
? buildBreadcrumbs(displayDir)
|
||||
: [];
|
||||
|
||||
if (!open) {return null;}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{
|
||||
opacity: visible ? 1 : 0,
|
||||
transition: "opacity 150ms ease-out",
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className="relative flex flex-col rounded-2xl shadow-2xl overflow-hidden w-[calc(100%-2rem)] max-w-[540px]"
|
||||
style={{
|
||||
maxHeight: "70vh",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
transform: visible
|
||||
? "scale(1)"
|
||||
: "scale(0.97)",
|
||||
transition:
|
||||
"transform 150ms ease-out",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3.5 border-b flex-shrink-0"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style={{
|
||||
background:
|
||||
pickerColors.folder
|
||||
.bg,
|
||||
color: pickerColors
|
||||
.folder.fg,
|
||||
}}
|
||||
>
|
||||
<PickerIcon
|
||||
category="folder"
|
||||
size={18}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2
|
||||
className="text-sm font-semibold"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
Select Files
|
||||
</h2>
|
||||
<p
|
||||
className="text-[11px]"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
Browse and attach
|
||||
files
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
background:
|
||||
"var(--color-surface-hover)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb path */}
|
||||
{displayDir && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-5 py-2 border-b overflow-x-auto flex-shrink-0"
|
||||
style={{
|
||||
borderColor:
|
||||
"var(--color-border)",
|
||||
scrollbarWidth: "thin",
|
||||
}}
|
||||
>
|
||||
{breadcrumbs.map(
|
||||
(seg, i) => (
|
||||
<Fragment
|
||||
key={
|
||||
seg.path
|
||||
}
|
||||
>
|
||||
{i >
|
||||
0 && (
|
||||
<span
|
||||
className="text-[10px] flex-shrink-0"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigateInto(
|
||||
seg.path,
|
||||
)
|
||||
}
|
||||
className="text-[12px] font-medium flex-shrink-0 rounded px-1 py-0.5 hover:underline"
|
||||
style={{
|
||||
color:
|
||||
i ===
|
||||
breadcrumbs.length -
|
||||
1
|
||||
? "var(--color-text)"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{
|
||||
seg.label
|
||||
}
|
||||
</button>
|
||||
</Fragment>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar + New Folder */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2 border-b flex-shrink-0"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex-1 flex items-center gap-2 rounded-lg px-2.5 py-1.5"
|
||||
style={{
|
||||
background:
|
||||
"var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<circle
|
||||
cx="11"
|
||||
cy="11"
|
||||
r="8"
|
||||
/>
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) =>
|
||||
setSearch(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Filter files..."
|
||||
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)]"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreatingFolder(true);
|
||||
setTimeout(
|
||||
() =>
|
||||
newFolderRef.current?.focus(),
|
||||
50,
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[12px] font-medium whitespace-nowrap"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
background:
|
||||
"var(--color-surface-hover)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M12 5v14" />
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div
|
||||
className="w-5 h-5 border-2 rounded-full animate-spin"
|
||||
style={{
|
||||
borderColor:
|
||||
"var(--color-border)",
|
||||
borderTopColor:
|
||||
"var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className="flex items-center justify-center py-16 text-[13px]"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Parent directory row */}
|
||||
{parentDir && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigateInto(
|
||||
parentDir,
|
||||
)
|
||||
}
|
||||
className="w-full flex items-center gap-3 px-4 py-2 text-left"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background:
|
||||
"var(--color-surface-hover)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-[13px] font-medium">
|
||||
..
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* New folder input */}
|
||||
{creatingFolder && (
|
||||
<div className="flex items-center gap-3 px-4 py-2">
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background:
|
||||
pickerColors
|
||||
.folder
|
||||
.bg,
|
||||
color: pickerColors
|
||||
.folder
|
||||
.fg,
|
||||
}}
|
||||
>
|
||||
<PickerIcon category="folder" />
|
||||
</div>
|
||||
<input
|
||||
ref={
|
||||
newFolderRef
|
||||
}
|
||||
type="text"
|
||||
value={
|
||||
newFolderName
|
||||
}
|
||||
onChange={(
|
||||
e,
|
||||
) =>
|
||||
setNewFolderName(
|
||||
e
|
||||
.target
|
||||
.value,
|
||||
)
|
||||
}
|
||||
onKeyDown={(
|
||||
e,
|
||||
) => {
|
||||
if (
|
||||
e.key ===
|
||||
"Enter"
|
||||
) {
|
||||
void handleCreateFolder();
|
||||
}
|
||||
if (
|
||||
e.key ===
|
||||
"Escape"
|
||||
) {
|
||||
setCreatingFolder(
|
||||
false,
|
||||
);
|
||||
setNewFolderName(
|
||||
"",
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="Folder name..."
|
||||
className="flex-1 bg-transparent outline-none text-[13px] placeholder:text-[var(--color-text-muted)] rounded px-2 py-1"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
background:
|
||||
"var(--color-surface)",
|
||||
border: "1px solid var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entries */}
|
||||
{sorted.length ===
|
||||
0 &&
|
||||
!parentDir && (
|
||||
<div
|
||||
className="flex items-center justify-center py-16 text-[13px]"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
This
|
||||
folder
|
||||
is
|
||||
empty
|
||||
</div>
|
||||
)}
|
||||
{sorted.map(
|
||||
(entry) => {
|
||||
const isFolder =
|
||||
entry.type ===
|
||||
"folder";
|
||||
const category =
|
||||
isFolder
|
||||
? "folder"
|
||||
: getCategoryFromName(
|
||||
entry.name,
|
||||
);
|
||||
const colors =
|
||||
pickerColors[
|
||||
category
|
||||
] ??
|
||||
pickerColors.other;
|
||||
const isSelected =
|
||||
selected.has(
|
||||
entry.path,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={
|
||||
entry.path
|
||||
}
|
||||
className="flex items-center gap-3 px-4 py-1.5 group cursor-pointer"
|
||||
style={{
|
||||
background:
|
||||
isSelected
|
||||
? "color-mix(in srgb, var(--color-accent) 8%, transparent)"
|
||||
: undefined,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (
|
||||
isFolder
|
||||
) {
|
||||
navigateInto(
|
||||
entry.path,
|
||||
);
|
||||
} else {
|
||||
toggleSelect(
|
||||
entry,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(
|
||||
e,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
toggleSelect(
|
||||
entry,
|
||||
);
|
||||
}}
|
||||
className="w-4 h-4 rounded flex items-center justify-center flex-shrink-0 border"
|
||||
style={{
|
||||
borderColor:
|
||||
isSelected
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-border-strong)",
|
||||
background:
|
||||
isSelected
|
||||
? "var(--color-accent)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background:
|
||||
colors.bg,
|
||||
color: colors.fg,
|
||||
}}
|
||||
>
|
||||
<PickerIcon
|
||||
category={
|
||||
category
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
className="flex-1 text-[13px] truncate"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
fontWeight:
|
||||
isFolder
|
||||
? 500
|
||||
: 400,
|
||||
}}
|
||||
title={
|
||||
entry.path
|
||||
}
|
||||
>
|
||||
{
|
||||
entry.name
|
||||
}
|
||||
</span>
|
||||
|
||||
{/* Folder chevron */}
|
||||
{isFolder && (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-50 transition-opacity"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3 border-t flex-shrink-0"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[12px]"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{selected.size > 0
|
||||
? `${selected.size} ${selected.size === 1 ? "item" : "items"} selected`
|
||||
: "No files selected"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 rounded-lg text-[13px] font-medium"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
background:
|
||||
"var(--color-surface-hover)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={
|
||||
selected.size === 0
|
||||
}
|
||||
className="px-3 py-1.5 rounded-lg text-[13px] font-medium disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
color: "white",
|
||||
background:
|
||||
selected.size > 0
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-border-strong)",
|
||||
}}
|
||||
>
|
||||
Attach{" "}
|
||||
{selected.size > 0 &&
|
||||
`${selected.size} ${selected.size === 1 ? "file" : "files"}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
533
apps/web/app/components/sidebar.tsx
Normal file
533
apps/web/app/components/sidebar.tsx
Normal file
@ -0,0 +1,533 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { FileManagerTree } from "./workspace/file-manager-tree";
|
||||
import { ProfileSwitcher } from "./workspace/profile-switcher";
|
||||
import { CreateWorkspaceDialog } from "./workspace/create-workspace-dialog";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type WebSession = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
type SkillEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
emoji?: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
type MemoryFile = {
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
type TreeNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
||||
icon?: string;
|
||||
defaultView?: "table" | "kanban";
|
||||
children?: TreeNode[];
|
||||
};
|
||||
|
||||
type SidebarSection = "chats" | "skills" | "memories" | "workspace" | "reports";
|
||||
|
||||
type SidebarProps = {
|
||||
onSessionSelect?: (sessionId: string) => void;
|
||||
onNewSession?: () => void;
|
||||
activeSessionId?: string;
|
||||
refreshKey?: number;
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) {return `${seconds}s ago`;}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {return `${minutes}m ago`;}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {return `${hours}h ago`;}
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
// --- Section Components ---
|
||||
|
||||
function ChatsSection({
|
||||
sessions,
|
||||
onSessionSelect,
|
||||
activeSessionId,
|
||||
}: {
|
||||
sessions: WebSession[];
|
||||
onSessionSelect?: (sessionId: string) => void;
|
||||
activeSessionId?: string;
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const filteredSessions = sessions.filter((s) =>
|
||||
s.title.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sessions.length > 3 && (
|
||||
<div className="px-3">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search chats..."
|
||||
className="w-full px-3 py-1.5 text-xs bg-[var(--color-bg)] border border-[var(--color-border)] rounded-md text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)] focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredSessions.length === 0 ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)] px-3">
|
||||
{searchTerm ? "No matching chats." : "No chats yet. Send a message to start."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{filteredSessions.map((s) => {
|
||||
const isActive = s.id === activeSessionId;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => onSessionSelect?.(s.id)}
|
||||
className={`mx-2 px-3 py-2 rounded-lg hover:bg-[var(--color-surface-hover)] cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? "bg-[var(--color-surface-hover)] border-l-2 border-[var(--color-accent)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm truncate flex-1">{s.title}</span>
|
||||
<span className="text-xs text-[var(--color-text-muted)] flex-shrink-0">
|
||||
{timeAgo(s.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{s.messageCount > 0 && (
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
|
||||
{s.messageCount} message{s.messageCount !== 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillsSection({ skills }: { skills: SkillEntry[] }) {
|
||||
if (skills.length === 0) {
|
||||
return <p className="text-sm text-[var(--color-text-muted)] px-3">No skills found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{skills.map((skill) => (
|
||||
<div
|
||||
key={`${skill.source}:${skill.name}`}
|
||||
className="px-3 py-2 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{skill.emoji && <span className="text-base">{skill.emoji}</span>}
|
||||
<span className="text-sm font-medium">{skill.name}</span>
|
||||
<span className="text-xs text-[var(--color-text-muted)] ml-auto">{skill.source}</span>
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-0.5 line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoriesSection({
|
||||
mainMemory,
|
||||
dailyLogs,
|
||||
}: {
|
||||
mainMemory: string | null;
|
||||
dailyLogs: MemoryFile[];
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{mainMemory ? (
|
||||
<div className="px-3">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-xs text-[var(--color-accent)] hover:text-[var(--color-accent-hover)] mb-1"
|
||||
>
|
||||
{expanded ? "Collapse" : "Show"} MEMORY.md ({mainMemory.length} chars)
|
||||
</button>
|
||||
{expanded && (
|
||||
<pre className="text-xs text-[var(--color-text-muted)] bg-[var(--color-bg)] rounded p-2 overflow-auto max-h-64 whitespace-pre-wrap">
|
||||
{mainMemory}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--color-text-muted)] px-3">No MEMORY.md found.</p>
|
||||
)}
|
||||
|
||||
{dailyLogs.length > 0 && (
|
||||
<div className="px-3">
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-1">
|
||||
Daily logs ({dailyLogs.length})
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{dailyLogs.slice(0, 10).map((log) => (
|
||||
<div
|
||||
key={log.name}
|
||||
className="text-xs text-[var(--color-text-muted)] flex justify-between"
|
||||
>
|
||||
<span>{log.name}</span>
|
||||
<span>{(log.sizeBytes / 1024).toFixed(1)}kb</span>
|
||||
</div>
|
||||
))}
|
||||
{dailyLogs.length > 10 && (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
...and {dailyLogs.length - 10} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Workspace Section (uses FileManagerTree in compact mode) ---
|
||||
|
||||
function WorkspaceSection({ tree, onRefresh }: { tree: TreeNode[]; onRefresh: () => void }) {
|
||||
const handleSelect = useCallback((node: TreeNode) => {
|
||||
// Navigate to workspace page for actionable items
|
||||
if (node.type === "object" || node.type === "document" || node.type === "file" || node.type === "database" || node.type === "report") {
|
||||
window.location.href = `/workspace?path=${encodeURIComponent(node.path)}`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-[var(--color-text-muted)] px-3 py-1">
|
||||
No workspace data yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<FileManagerTree
|
||||
tree={tree}
|
||||
activePath={null}
|
||||
onSelect={handleSelect}
|
||||
onRefresh={onRefresh}
|
||||
compact
|
||||
/>
|
||||
|
||||
{/* Full workspace link */}
|
||||
<a
|
||||
href="/workspace"
|
||||
className="flex items-center gap-1.5 mx-2 mt-2 px-2 py-1.5 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" x2="21" y1="14" y2="3" />
|
||||
</svg>
|
||||
Open full workspace
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Reports Section ---
|
||||
|
||||
function ReportsSection({ tree }: { tree: TreeNode[] }) {
|
||||
// Collect all report nodes from the tree (recursive)
|
||||
const reports: TreeNode[] = [];
|
||||
function collect(nodes: TreeNode[]) {
|
||||
for (const n of nodes) {
|
||||
if (n.type === "report") {reports.push(n);}
|
||||
if (n.children) {collect(n.children);}
|
||||
}
|
||||
}
|
||||
collect(tree);
|
||||
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-[var(--color-text-muted)] px-3 py-1">
|
||||
No reports yet. Ask the agent to create one.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{reports.map((report) => (
|
||||
<a
|
||||
key={report.path}
|
||||
href={`/workspace?path=${encodeURIComponent(report.path)}`}
|
||||
className="flex items-center gap-2 mx-2 px-2 py-1.5 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="flex-shrink-0" style={{ color: "#22c55e" }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="20" y2="10" />
|
||||
<line x1="18" x2="18" y1="20" y2="4" />
|
||||
<line x1="6" x2="6" y1="20" y2="14" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="truncate flex-1">
|
||||
{report.name.replace(/\.report\.json$/, "")}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Collapsible Header ---
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
count,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold text-[var(--color-text)] hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors"
|
||||
>
|
||||
<span>
|
||||
{title}
|
||||
{count != null && (
|
||||
<span className="ml-1.5 text-xs text-[var(--color-text-muted)] font-normal">
|
||||
({count})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-[var(--color-text-muted)] transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Sidebar ---
|
||||
|
||||
export function Sidebar({
|
||||
onSessionSelect,
|
||||
onNewSession,
|
||||
activeSessionId,
|
||||
refreshKey,
|
||||
}: SidebarProps) {
|
||||
const [openSections, setOpenSections] = useState<Set<SidebarSection>>(new Set(["chats", "workspace"]));
|
||||
const [webSessions, setWebSessions] = useState<WebSession[]>([]);
|
||||
const [skills, setSkills] = useState<SkillEntry[]>([]);
|
||||
const [mainMemory, setMainMemory] = useState<string | null>(null);
|
||||
const [dailyLogs, setDailyLogs] = useState<MemoryFile[]>([]);
|
||||
const [workspaceTree, setWorkspaceTree] = useState<TreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateWorkspace, setShowCreateWorkspace] = useState(false);
|
||||
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
|
||||
|
||||
const toggleSection = (section: SidebarSection) => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) {next.delete(section);}
|
||||
else {next.add(section);}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Full sidebar re-fetch after profile switch or workspace creation
|
||||
const handleProfileSwitch = useCallback(() => {
|
||||
setSidebarRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
// Fetch sidebar data (re-runs when refreshKey or sidebarRefreshKey changes)
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [webSessionsRes, skillsRes, memoriesRes, workspaceRes] = await Promise.all([
|
||||
fetch("/api/web-sessions").then((r) => r.json()),
|
||||
fetch("/api/skills").then((r) => r.json()),
|
||||
fetch("/api/memories").then((r) => r.json()),
|
||||
fetch("/api/workspace/tree").then((r) => r.json()).catch(() => ({ tree: [] })),
|
||||
]);
|
||||
setWebSessions(webSessionsRes.sessions ?? []);
|
||||
setSkills(skillsRes.skills ?? []);
|
||||
setMainMemory(memoriesRes.mainMemory ?? null);
|
||||
setDailyLogs(memoriesRes.dailyLogs ?? []);
|
||||
setWorkspaceTree(workspaceRes.tree ?? []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load sidebar data:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
void load();
|
||||
}, [refreshKey, sidebarRefreshKey]);
|
||||
|
||||
const refreshWorkspace = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/workspace/tree");
|
||||
const data = await res.json();
|
||||
setWorkspaceTree(data.tree ?? []);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<aside className="w-72 h-screen flex flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-[var(--color-border)]">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<h1 className="text-base font-bold flex items-center gap-2">
|
||||
<span>Ironclaw</span>
|
||||
</h1>
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
title="New Chat"
|
||||
className="p-1.5 rounded-md hover:bg-[var(--color-surface-hover)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ProfileSwitcher
|
||||
onProfileSwitch={handleProfileSwitch}
|
||||
onCreateWorkspace={() => setShowCreateWorkspace(true)}
|
||||
activeProfileHint={String(sidebarRefreshKey)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create workspace dialog */}
|
||||
<CreateWorkspaceDialog
|
||||
isOpen={showCreateWorkspace}
|
||||
onClose={() => setShowCreateWorkspace(false)}
|
||||
onCreated={handleProfileSwitch}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto py-2 space-y-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-5 h-5 border-2 border-[var(--color-border)] border-t-[var(--color-accent)] rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Workspace */}
|
||||
{workspaceTree.length > 0 && (
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Workspace"
|
||||
count={workspaceTree.length}
|
||||
isOpen={openSections.has("workspace")}
|
||||
onToggle={() => toggleSection("workspace")}
|
||||
/>
|
||||
{openSections.has("workspace") && (
|
||||
<WorkspaceSection tree={workspaceTree} onRefresh={refreshWorkspace} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chats (web sessions) */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Chats"
|
||||
count={webSessions.length}
|
||||
isOpen={openSections.has("chats")}
|
||||
onToggle={() => toggleSection("chats")}
|
||||
/>
|
||||
{openSections.has("chats") && (
|
||||
<ChatsSection
|
||||
sessions={webSessions}
|
||||
onSessionSelect={onSessionSelect}
|
||||
activeSessionId={activeSessionId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reports */}
|
||||
{workspaceTree.length > 0 && (
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Reports"
|
||||
isOpen={openSections.has("reports")}
|
||||
onToggle={() => toggleSection("reports")}
|
||||
/>
|
||||
{openSections.has("reports") && (
|
||||
<ReportsSection tree={workspaceTree} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Skills"
|
||||
count={skills.length}
|
||||
isOpen={openSections.has("skills")}
|
||||
onToggle={() => toggleSection("skills")}
|
||||
/>
|
||||
{openSections.has("skills") && <SkillsSection skills={skills} />}
|
||||
</div>
|
||||
|
||||
{/* Memories */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Memories"
|
||||
count={dailyLogs.length}
|
||||
isOpen={openSections.has("memories")}
|
||||
onToggle={() => toggleSection("memories")}
|
||||
/>
|
||||
{openSections.has("memories") && (
|
||||
<MemoriesSection mainMemory={mainMemory} dailyLogs={dailyLogs} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
424
apps/web/app/components/subagent-panel.tsx
Normal file
424
apps/web/app/components/subagent-panel.tsx
Normal file
@ -0,0 +1,424 @@
|
||||
"use client";
|
||||
|
||||
import type { UIMessage } from "ai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import { createStreamParser } from "./chat-panel";
|
||||
import { UnicodeSpinner } from "./unicode-spinner";
|
||||
import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor";
|
||||
|
||||
type SubagentPanelProps = {
|
||||
sessionKey: string;
|
||||
task: string;
|
||||
label?: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string;
|
||||
text: string;
|
||||
mentionedFiles: Array<{ name: string; path: string }>;
|
||||
};
|
||||
|
||||
function taskMessage(sessionKey: string, task: string): UIMessage {
|
||||
return {
|
||||
id: `task-${sessionKey}`,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: task }],
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
function buildMessagesFromParsed(
|
||||
sessionKey: string,
|
||||
task: string,
|
||||
parts: Array<Record<string, unknown>>,
|
||||
): UIMessage[] {
|
||||
const messages: UIMessage[] = [taskMessage(sessionKey, task)];
|
||||
let assistantParts: UIMessage["parts"] = [];
|
||||
let assistantCount = 0;
|
||||
let userCount = 0;
|
||||
|
||||
const pushAssistant = () => {
|
||||
if (assistantParts.length === 0) {return;}
|
||||
messages.push({
|
||||
id: `assistant-${sessionKey}-${assistantCount++}`,
|
||||
role: "assistant",
|
||||
parts: assistantParts,
|
||||
} as UIMessage);
|
||||
assistantParts = [];
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "user-message") {
|
||||
pushAssistant();
|
||||
messages.push({
|
||||
id: (part.id as string | undefined) ?? `user-${sessionKey}-${userCount++}`,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: (part.text as string) ?? "" }],
|
||||
} as UIMessage);
|
||||
continue;
|
||||
}
|
||||
assistantParts.push(part as UIMessage["parts"][number]);
|
||||
}
|
||||
pushAssistant();
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function SubagentPanel({ sessionKey, task, label, onBack }: SubagentPanelProps) {
|
||||
const editorRef = useRef<ChatEditorHandle>(null);
|
||||
const [editorEmpty, setEditorEmpty] = useState(true);
|
||||
const [messages, setMessages] = useState<UIMessage[]>(() => [taskMessage(sessionKey, task)]);
|
||||
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const userScrolledAwayRef = useRef(false);
|
||||
const streamAbortRef = useRef<AbortController | null>(null);
|
||||
const scrollRafRef = useRef(0);
|
||||
|
||||
const displayLabel = label || (task.length > 60 ? task.slice(0, 60) + "..." : task);
|
||||
|
||||
const streamFromResponse = useCallback(
|
||||
async (
|
||||
res: Response,
|
||||
onUpdate: (parts: Array<Record<string, unknown>>) => void,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
if (!res.body) {return;}
|
||||
const parser = createStreamParser();
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let frameRequested = false;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {break;}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx;
|
||||
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
||||
const chunk = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
if (chunk.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(chunk.slice(6)) as Record<string, unknown>;
|
||||
parser.processEvent(event);
|
||||
} catch {
|
||||
// ignore malformed event
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!frameRequested) {
|
||||
frameRequested = true;
|
||||
requestAnimationFrame(() => {
|
||||
frameRequested = false;
|
||||
if (!signal.aborted) {
|
||||
onUpdate(parser.getParts() as Array<Record<string, unknown>>);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
onUpdate(parser.getParts() as Array<Record<string, unknown>>);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reconnect = useCallback(async () => {
|
||||
streamAbortRef.current?.abort();
|
||||
const abort = new AbortController();
|
||||
streamAbortRef.current = abort;
|
||||
setIsReconnecting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/chat/stream?sessionKey=${encodeURIComponent(sessionKey)}`, {
|
||||
signal: abort.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
setConnected(false);
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
setConnected(true);
|
||||
setIsStreaming(res.headers.get("X-Run-Active") !== "false");
|
||||
await streamFromResponse(
|
||||
res,
|
||||
(parts) => setMessages(buildMessagesFromParsed(sessionKey, task, parts)),
|
||||
abort.signal,
|
||||
);
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
console.error("Subagent reconnect error:", err);
|
||||
}
|
||||
} finally {
|
||||
setIsReconnecting(false);
|
||||
if (!abort.signal.aborted) {
|
||||
setIsStreaming(false);
|
||||
streamAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [sessionKey, task, streamFromResponse]);
|
||||
|
||||
const sendSubagentMessage = useCallback(
|
||||
async (text: string, mentionedFiles: Array<{ name: string; path: string }>) => {
|
||||
const trimmed = text.trim();
|
||||
const hasMentions = mentionedFiles.length > 0;
|
||||
if (!trimmed && !hasMentions) {return;}
|
||||
|
||||
const allFilePaths = mentionedFiles.map((f) => f.path);
|
||||
const payloadText = allFilePaths.length > 0
|
||||
? `[Attached files: ${allFilePaths.join(", ")}]\n\n${trimmed}`
|
||||
: trimmed;
|
||||
|
||||
const optimisticUser: UIMessage = {
|
||||
id: `user-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: payloadText }],
|
||||
} as UIMessage;
|
||||
const baseMessages = [...messages, optimisticUser];
|
||||
setMessages(baseMessages);
|
||||
|
||||
streamAbortRef.current?.abort();
|
||||
const abort = new AbortController();
|
||||
streamAbortRef.current = abort;
|
||||
setIsStreaming(true);
|
||||
setConnected(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: abort.signal,
|
||||
body: JSON.stringify({
|
||||
sessionKey,
|
||||
messages: [optimisticUser],
|
||||
}),
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
await streamFromResponse(
|
||||
res,
|
||||
(parts) => {
|
||||
const assistantMsg: UIMessage = {
|
||||
id: `assistant-${sessionKey}-${Date.now()}`,
|
||||
role: "assistant",
|
||||
parts: parts as UIMessage["parts"],
|
||||
} as UIMessage;
|
||||
setMessages([...baseMessages, assistantMsg]);
|
||||
},
|
||||
abort.signal,
|
||||
);
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
console.error("Subagent send error:", err);
|
||||
}
|
||||
} finally {
|
||||
if (!abort.signal.aborted) {
|
||||
setIsStreaming(false);
|
||||
streamAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[messages, sessionKey, streamFromResponse],
|
||||
);
|
||||
|
||||
const handleEditorSubmit = useCallback(
|
||||
async (text: string, mentionedFiles: Array<{ name: string; path: string }>) => {
|
||||
if (isStreaming || isReconnecting) {
|
||||
setQueuedMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
text,
|
||||
mentionedFiles,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
await sendSubagentMessage(text, mentionedFiles);
|
||||
},
|
||||
[isStreaming, isReconnecting, sendSubagentMessage],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
streamAbortRef.current?.abort();
|
||||
streamAbortRef.current = null;
|
||||
setIsStreaming(false);
|
||||
setIsReconnecting(false);
|
||||
try {
|
||||
await fetch("/api/chat/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionKey }),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void reconnect();
|
||||
return () => {
|
||||
streamAbortRef.current?.abort();
|
||||
};
|
||||
}, [reconnect]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasBusy = isStreaming || isReconnecting;
|
||||
if (wasBusy || queuedMessages.length === 0) {return;}
|
||||
const [next, ...rest] = queuedMessages;
|
||||
setQueuedMessages(rest);
|
||||
queueMicrotask(() => {
|
||||
void sendSubagentMessage(next.text, next.mentionedFiles);
|
||||
});
|
||||
}, [isStreaming, isReconnecting, queuedMessages, sendSubagentMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) {return;}
|
||||
const onScroll = () => {
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
userScrolledAwayRef.current = distanceFromBottom > 80;
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (userScrolledAwayRef.current) {return;}
|
||||
if (scrollRafRef.current) {return;}
|
||||
scrollRafRef.current = requestAnimationFrame(() => {
|
||||
scrollRafRef.current = 0;
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const statusLabel = useMemo(() => {
|
||||
if (!connected && (isStreaming || isReconnecting)) {return <UnicodeSpinner name="braille">Connecting</UnicodeSpinner>;}
|
||||
if (isReconnecting) {return <UnicodeSpinner name="braille">Resuming</UnicodeSpinner>;}
|
||||
if (isStreaming) {return <UnicodeSpinner name="braille" />;}
|
||||
return "Completed";
|
||||
}, [connected, isStreaming, isReconnecting]);
|
||||
|
||||
return (
|
||||
<div ref={scrollContainerRef} className="h-full overflow-y-auto">
|
||||
<div className="flex flex-col min-h-full">
|
||||
<header
|
||||
className="px-3 py-2 md:px-6 md:py-3 flex items-center gap-3 sticky top-0 z-20 backdrop-blur-md"
|
||||
style={{ background: "var(--color-bg-glass)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Back to parent chat"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold truncate" style={{ color: "var(--color-text)" }}>
|
||||
{displayLabel}
|
||||
</h2>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{statusLabel}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 px-6">
|
||||
<div className="max-w-2xl mx-auto py-3">
|
||||
{messages.map((message, i) => (
|
||||
<ChatMessage key={message.id} message={message} isStreaming={(isStreaming || isReconnecting) && i === messages.length - 1} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="px-3 pb-3 pt-0 md:px-6 md:pb-5 sticky bottom-0 z-20 backdrop-blur-md"
|
||||
style={{ background: "var(--color-bg-glass)" }}
|
||||
>
|
||||
<div className="max-w-[720px] mx-auto rounded-3xl overflow-hidden" style={{ background: "var(--color-chat-input-bg)", border: "1px solid var(--color-border)" }}>
|
||||
{queuedMessages.length > 0 && (
|
||||
<div className="px-3 pt-3">
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px dashed var(--color-border-strong)", background: "var(--color-bg-elevated)" }}>
|
||||
<div className="px-3 py-1.5 text-[11px] font-medium tracking-wide uppercase" style={{ borderBottom: "1px solid var(--color-border)", color: "var(--color-text-muted)", fontFamily: "var(--font-mono, monospace)" }}>
|
||||
Queued ({queuedMessages.length})
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-1.5">
|
||||
{queuedMessages.map((msg) => (
|
||||
<div key={msg.id} className="flex items-center justify-between gap-2 rounded-lg px-2.5 py-2" style={{ background: "var(--color-bg-secondary)" }}>
|
||||
<p className="flex-1 text-[13px] leading-[1.45] line-clamp-2" style={{ color: "var(--color-text)", whiteSpace: "pre-wrap" }}>
|
||||
{msg.text}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1 transition-colors hover:bg-[var(--color-bg)]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
onClick={() => setQueuedMessages((prev) => prev.filter((m) => m.id !== msg.id))}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ChatEditor
|
||||
ref={editorRef}
|
||||
onSubmit={handleEditorSubmit}
|
||||
onChange={(isEmpty) => setEditorEmpty(isEmpty)}
|
||||
placeholder={isStreaming || isReconnecting ? "Type to queue a message..." : "Type @ to mention files..."}
|
||||
/>
|
||||
<div className="flex items-center justify-end px-3 pb-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(isStreaming || isReconnecting) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStop()}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center"
|
||||
style={{ background: "var(--color-text)", color: "var(--color-bg)" }}
|
||||
title="Stop generating"
|
||||
>
|
||||
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
|
||||
<rect width="10" height="10" rx="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editorRef.current?.submit();
|
||||
}}
|
||||
disabled={editorEmpty}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: !editorEmpty ? ((isStreaming || isReconnecting) ? "var(--color-text-muted)" : "var(--color-accent)") : "var(--color-border-strong)",
|
||||
color: "white",
|
||||
}}
|
||||
title={(isStreaming || isReconnecting) ? "Queue message" : "Send message"}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19V5" />
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/web/app/components/syntax-block.tsx
Normal file
89
apps/web/app/components/syntax-block.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createHighlighter, type Highlighter } from "shiki";
|
||||
|
||||
// Singleton highlighter (shared with code-viewer)
|
||||
let highlighterPromise: Promise<Highlighter> | null = null;
|
||||
|
||||
/** Languages to preload for chat code blocks. */
|
||||
const CHAT_LANGS = [
|
||||
"typescript", "tsx", "javascript", "jsx",
|
||||
"python", "ruby", "go", "rust", "java",
|
||||
"c", "cpp", "csharp", "swift", "kotlin",
|
||||
"css", "scss", "html", "xml",
|
||||
"json", "yaml", "toml",
|
||||
"bash", "sql", "graphql",
|
||||
"markdown", "diff", "php", "lua",
|
||||
"vue", "svelte", "dart", "zig",
|
||||
];
|
||||
|
||||
function getHighlighter(): Promise<Highlighter> {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: ["github-dark", "github-light"],
|
||||
langs: CHAT_LANGS,
|
||||
});
|
||||
}
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
type SyntaxBlockProps = {
|
||||
code: string;
|
||||
lang: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a syntax-highlighted code block using shiki.
|
||||
* Falls back to plain monospace while loading.
|
||||
*/
|
||||
export function SyntaxBlock({ code, lang }: SyntaxBlockProps) {
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getHighlighter().then((hl) => {
|
||||
if (cancelled) {return;}
|
||||
try {
|
||||
const result = hl.codeToHtml(code, {
|
||||
lang,
|
||||
themes: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
},
|
||||
});
|
||||
setHtml(result);
|
||||
} catch {
|
||||
// If the language isn't loaded, fall back to plain text
|
||||
try {
|
||||
const result = hl.codeToHtml(code, {
|
||||
lang: "text",
|
||||
themes: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
},
|
||||
});
|
||||
setHtml(result);
|
||||
} catch {
|
||||
// Give up on highlighting
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [code, lang]);
|
||||
|
||||
if (html) {
|
||||
return (
|
||||
<div
|
||||
className="syntax-block"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is trusted
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: plain code while shiki loads
|
||||
return (
|
||||
<code className="block">{code}</code>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user