diff --git a/packages/clawdbot/index.js b/packages/clawdbot/index.js new file mode 100644 index 0000000000000000000000000000000000000000..bada56ea3fe11f1df795e7e498acafbfc3f6d675 --- /dev/null +++ b/packages/clawdbot/index.js @@ -0,0 +1 @@ +export * from "openclaw"; diff --git a/packages/clawdbot/package.json b/packages/clawdbot/package.json new file mode 100644 index 0000000000000000000000000000000000000000..c11cff167ec33473e0f6b1a3099589842a5d6336 --- /dev/null +++ b/packages/clawdbot/package.json @@ -0,0 +1,19 @@ +{ + "name": "clawdbot", + "version": "2026.1.27-beta.1", + "description": "Compatibility shim that forwards to openclaw", + "bin": { + "clawdbot": "./bin/clawdbot.js" + }, + "type": "module", + "exports": { + ".": "./index.js", + "./cli-entry": "./bin/clawdbot.js" + }, + "scripts": { + "postinstall": "node ./scripts/postinstall.js" + }, + "dependencies": { + "openclaw": "workspace:*" + } +} diff --git a/packages/clawdbot/scripts/postinstall.js b/packages/clawdbot/scripts/postinstall.js new file mode 100644 index 0000000000000000000000000000000000000000..d0410ea0f8f27177133537704cf794bda677bb81 --- /dev/null +++ b/packages/clawdbot/scripts/postinstall.js @@ -0,0 +1 @@ +console.warn("clawdbot renamed -> openclaw"); diff --git a/packages/moltbot/index.js b/packages/moltbot/index.js new file mode 100644 index 0000000000000000000000000000000000000000..bada56ea3fe11f1df795e7e498acafbfc3f6d675 --- /dev/null +++ b/packages/moltbot/index.js @@ -0,0 +1 @@ +export * from "openclaw"; diff --git a/packages/moltbot/package.json b/packages/moltbot/package.json new file mode 100644 index 0000000000000000000000000000000000000000..8acf8148871e8e87bedb9072a24e92afd02d36db --- /dev/null +++ b/packages/moltbot/package.json @@ -0,0 +1,19 @@ +{ + "name": "moltbot", + "version": "2026.1.27-beta.1", + "description": "Compatibility shim that forwards to openclaw", + "bin": { + "moltbot": "./bin/moltbot.js" + }, + "type": "module", + "exports": { + ".": "./index.js", + "./cli-entry": "./bin/moltbot.js" + }, + "scripts": { + "postinstall": "node ./scripts/postinstall.js" + }, + "dependencies": { + "openclaw": "workspace:*" + } +} diff --git a/packages/moltbot/scripts/postinstall.js b/packages/moltbot/scripts/postinstall.js new file mode 100644 index 0000000000000000000000000000000000000000..e3c006cd004617b27fbdbce51869042dba57315d --- /dev/null +++ b/packages/moltbot/scripts/postinstall.js @@ -0,0 +1 @@ +console.warn("moltbot renamed -> openclaw"); diff --git a/patches/.gitkeep b/patches/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/scripts/auth-monitor.sh b/scripts/auth-monitor.sh new file mode 100644 index 0000000000000000000000000000000000000000..93c41c2b68dd15c95140b16d0aa2301b5e976f95 --- /dev/null +++ b/scripts/auth-monitor.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Auth Expiry Monitor +# Run via cron or systemd timer to get proactive notifications +# before Claude Code auth expires. +# +# Suggested cron: */30 * * * * /home/admin/openclaw/scripts/auth-monitor.sh +# +# Environment variables: +# NOTIFY_PHONE - Phone number to send OpenClaw notification (e.g., +1234567890) +# NOTIFY_NTFY - ntfy.sh topic for push notifications (e.g., openclaw-alerts) +# WARN_HOURS - Hours before expiry to warn (default: 2) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLAUDE_CREDS="$HOME/.claude/.credentials.json" +STATE_FILE="$HOME/.openclaw/auth-monitor-state" + +# Configuration +WARN_HOURS="${WARN_HOURS:-2}" +NOTIFY_PHONE="${NOTIFY_PHONE:-}" +NOTIFY_NTFY="${NOTIFY_NTFY:-}" + +# State tracking to avoid spam +mkdir -p "$(dirname "$STATE_FILE")" +LAST_NOTIFIED=$(cat "$STATE_FILE" 2>/dev/null || echo "0") +NOW=$(date +%s) + +# Only notify once per hour max +MIN_INTERVAL=3600 + +send_notification() { + local message="$1" + local priority="${2:-default}" + + echo "$(date '+%Y-%m-%d %H:%M:%S') - $message" + + # Check if we notified recently + if [ $((NOW - LAST_NOTIFIED)) -lt $MIN_INTERVAL ]; then + echo "Skipping notification (sent recently)" + return + fi + + # Send via OpenClaw if phone configured and auth still valid + if [ -n "$NOTIFY_PHONE" ]; then + # Check if we can still use openclaw + if "$SCRIPT_DIR/claude-auth-status.sh" simple 2>/dev/null | grep -q "OK\|EXPIRING"; then + echo "Sending via OpenClaw to $NOTIFY_PHONE..." + openclaw send --to "$NOTIFY_PHONE" --message "$message" 2>/dev/null || true + fi + fi + + # Send via ntfy.sh if configured + if [ -n "$NOTIFY_NTFY" ]; then + echo "Sending via ntfy.sh to $NOTIFY_NTFY..." + curl -s -o /dev/null \ + -H "Title: OpenClaw Auth Alert" \ + -H "Priority: $priority" \ + -H "Tags: warning,key" \ + -d "$message" \ + "https://ntfy.sh/$NOTIFY_NTFY" || true + fi + + # Update state + echo "$NOW" > "$STATE_FILE" +} + +# Check auth status +if [ ! -f "$CLAUDE_CREDS" ]; then + send_notification "Claude Code credentials missing! Run: claude setup-token" "high" + exit 1 +fi + +EXPIRES_AT=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS") +NOW_MS=$((NOW * 1000)) +DIFF_MS=$((EXPIRES_AT - NOW_MS)) +HOURS_LEFT=$((DIFF_MS / 3600000)) +MINS_LEFT=$(((DIFF_MS % 3600000) / 60000)) + +if [ "$DIFF_MS" -lt 0 ]; then + send_notification "Claude Code auth EXPIRED! OpenClaw is down. Run: ssh l36 '~/openclaw/scripts/mobile-reauth.sh'" "urgent" + exit 1 +elif [ "$HOURS_LEFT" -lt "$WARN_HOURS" ]; then + send_notification "Claude Code auth expires in ${HOURS_LEFT}h ${MINS_LEFT}m. Consider re-auth soon." "high" + exit 0 +else + echo "$(date '+%Y-%m-%d %H:%M:%S') - Auth OK: ${HOURS_LEFT}h ${MINS_LEFT}m remaining" + exit 0 +fi diff --git a/scripts/bench-model.ts b/scripts/bench-model.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0e5246bbd49271207e474b73448e144b0688cb7 --- /dev/null +++ b/scripts/bench-model.ts @@ -0,0 +1,145 @@ +import { completeSimple, getModel, type Model } from "@mariozechner/pi-ai"; + +type Usage = { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + totalTokens?: number; +}; + +type RunResult = { + durationMs: number; + usage?: Usage; +}; + +const DEFAULT_PROMPT = "Reply with a single word: ok. No punctuation or extra text."; +const DEFAULT_RUNS = 10; + +function parseArg(flag: string): string | undefined { + const idx = process.argv.indexOf(flag); + if (idx === -1) { + return undefined; + } + return process.argv[idx + 1]; +} + +function parseRuns(raw: string | undefined): number { + if (!raw) { + return DEFAULT_RUNS; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_RUNS; + } + return Math.floor(parsed); +} + +function median(values: number[]): number { + if (values.length === 0) { + return 0; + } + const sorted = [...values].toSorted((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + return Math.round((sorted[mid - 1] + sorted[mid]) / 2); + } + return sorted[mid]; +} + +async function runModel(opts: { + label: string; + model: Model; + apiKey: string; + runs: number; + prompt: string; +}): Promise { + const results: RunResult[] = []; + for (let i = 0; i < opts.runs; i += 1) { + const started = Date.now(); + const res = await completeSimple( + opts.model, + { + messages: [ + { + role: "user", + content: opts.prompt, + timestamp: Date.now(), + }, + ], + }, + { apiKey: opts.apiKey, maxTokens: 64 }, + ); + const durationMs = Date.now() - started; + results.push({ durationMs, usage: res.usage }); + console.log(`${opts.label} run ${i + 1}/${opts.runs}: ${durationMs}ms`); + } + return results; +} + +async function main(): Promise { + const runs = parseRuns(parseArg("--runs")); + const prompt = parseArg("--prompt") ?? DEFAULT_PROMPT; + + const anthropicKey = process.env.ANTHROPIC_API_KEY?.trim(); + const minimaxKey = process.env.MINIMAX_API_KEY?.trim(); + if (!anthropicKey) { + throw new Error("Missing ANTHROPIC_API_KEY in environment."); + } + if (!minimaxKey) { + throw new Error("Missing MINIMAX_API_KEY in environment."); + } + + const minimaxBaseUrl = process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/v1"; + const minimaxModelId = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1"; + + const minimaxModel: Model<"openai-completions"> = { + id: minimaxModelId, + name: `MiniMax ${minimaxModelId}`, + api: "openai-completions", + provider: "minimax", + baseUrl: minimaxBaseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + }; + const opusModel = getModel("anthropic", "claude-opus-4-5"); + + console.log(`Prompt: ${prompt}`); + console.log(`Runs: ${runs}`); + console.log(""); + + const minimaxResults = await runModel({ + label: "minimax", + model: minimaxModel, + apiKey: minimaxKey, + runs, + prompt, + }); + const opusResults = await runModel({ + label: "opus", + model: opusModel, + apiKey: anthropicKey, + runs, + prompt, + }); + + const summarize = (label: string, results: RunResult[]) => { + const durations = results.map((r) => r.durationMs); + const med = median(durations); + const min = Math.min(...durations); + const max = Math.max(...durations); + return { label, med, min, max }; + }; + + const summary = [summarize("minimax", minimaxResults), summarize("opus", opusResults)]; + console.log(""); + console.log("Summary (ms):"); + for (const row of summary) { + console.log(`${row.label.padEnd(7)} median=${row.med} min=${row.min} max=${row.max}`); + } +} + +await main(); diff --git a/scripts/build-and-run-mac.sh b/scripts/build-and-run-mac.sh new file mode 100644 index 0000000000000000000000000000000000000000..3734606a83ea70637bf910e37a1430fbcbb2fa98 --- /dev/null +++ b/scripts/build-and-run-mac.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/../apps/macos" + +BUILD_PATH=".build-local" +PRODUCT="OpenClaw" +BIN="$BUILD_PATH/debug/$PRODUCT" + +printf "\n▶️ Building $PRODUCT (debug, build path: $BUILD_PATH)\n" +swift build -c debug --product "$PRODUCT" --build-path "$BUILD_PATH" + +printf "\n⏹ Stopping existing $PRODUCT...\n" +killall -q "$PRODUCT" 2>/dev/null || true + +printf "\n🚀 Launching $BIN ...\n" +nohup "$BIN" >/tmp/openclaw.log 2>&1 & +PID=$! +printf "Started $PRODUCT (PID $PID). Logs: /tmp/openclaw.log\n" diff --git a/scripts/build-docs-list.mjs b/scripts/build-docs-list.mjs new file mode 100644 index 0000000000000000000000000000000000000000..a827b67e821aa27cfa573a6f51d9bc1e35c3163f --- /dev/null +++ b/scripts/build-docs-list.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const binDir = path.join(root, "bin"); +const binPath = path.join(binDir, "docs-list"); + +fs.mkdirSync(binDir, { recursive: true }); + +const wrapper = `#!/usr/bin/env node\nimport { spawnSync } from "node:child_process";\nimport path from "node:path";\nimport { fileURLToPath } from "node:url";\n\nconst here = path.dirname(fileURLToPath(import.meta.url));\nconst script = path.join(here, "..", "scripts", "docs-list.js");\n\nconst result = spawnSync(process.execPath, [script], { stdio: "inherit" });\nprocess.exit(result.status ?? 1);\n`; + +fs.writeFileSync(binPath, wrapper, { mode: 0o755 }); diff --git a/scripts/build_icon.sh b/scripts/build_icon.sh new file mode 100644 index 0000000000000000000000000000000000000000..696e373b55f1c8b66ac2f766f8cb78498f488f90 --- /dev/null +++ b/scripts/build_icon.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Render the macOS .icon bundle to a padded .icns like Trimmy's pipeline. +# Defaults target the OpenClaw assets so you can just run the script from repo root. + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +ICON_FILE=${1:-"$ROOT_DIR/apps/macos/Icon.icon"} +BASENAME=${2:-OpenClaw} +OUT_ROOT=${3:-"$ROOT_DIR/apps/macos/build/icon"} +XCODE_APP=${XCODE_APP:-/Applications/Xcode.app} +# Where the final .icns should live; override DEST_ICNS to change. +DEST_ICNS=${DEST_ICNS:-"$ROOT_DIR/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns"} + +ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/ictool" +if [[ ! -x "$ICTOOL" ]]; then + ICTOOL="$XCODE_APP/Contents/Applications/Icon Composer.app/Contents/Executables/icontool" +fi +if [[ ! -x "$ICTOOL" ]]; then + echo "ictool/icontool not found. Set XCODE_APP if Xcode is elsewhere." >&2 + exit 1 +fi + +ICONSET_DIR="$OUT_ROOT/${BASENAME}.iconset" +TMP_DIR="$OUT_ROOT/tmp" +mkdir -p "$ICONSET_DIR" "$TMP_DIR" + +MASTER_ART="$TMP_DIR/icon_art_824.png" +MASTER_1024="$TMP_DIR/icon_1024.png" + +# Render inner art (no margin) with macOS Default appearance +"$ICTOOL" "$ICON_FILE" \ + --export-preview macOS Default 824 824 1 -45 "$MASTER_ART" + +# Pad to 1024x1024 with transparent border +sips --padToHeightWidth 1024 1024 "$MASTER_ART" --out "$MASTER_1024" >/dev/null + +# Generate required sizes +sizes=(16 32 64 128 256 512 1024) +for sz in "${sizes[@]}"; do + out="$ICONSET_DIR/icon_${sz}x${sz}.png" + sips -z "$sz" "$sz" "$MASTER_1024" --out "$out" >/dev/null + if [[ "$sz" -ne 1024 ]]; then + dbl=$((sz*2)) + out2="$ICONSET_DIR/icon_${sz}x${sz}@2x.png" + sips -z "$dbl" "$dbl" "$MASTER_1024" --out "$out2" >/dev/null + fi +done + +# 512x512@2x already covered by 1024; ensure it exists +cp "$MASTER_1024" "$ICONSET_DIR/icon_512x512@2x.png" + +iconutil -c icns "$ICONSET_DIR" -o "$OUT_ROOT/${BASENAME}.icns" + +mkdir -p "$(dirname "$DEST_ICNS")" +cp "$OUT_ROOT/${BASENAME}.icns" "$DEST_ICNS" + +echo "Icon.icns generated at $DEST_ICNS" diff --git a/scripts/bundle-a2ui.sh b/scripts/bundle-a2ui.sh new file mode 100644 index 0000000000000000000000000000000000000000..3936858309d485a892140eddbd8124f1e8dfdbaf --- /dev/null +++ b/scripts/bundle-a2ui.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +on_error() { + echo "A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle" >&2 + echo "If this persists, verify pnpm deps and try again." >&2 +} +trap on_error ERR + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash" +OUTPUT_FILE="$ROOT_DIR/src/canvas-host/a2ui/a2ui.bundle.js" +A2UI_RENDERER_DIR="$ROOT_DIR/vendor/a2ui/renderers/lit" +A2UI_APP_DIR="$ROOT_DIR/apps/shared/OpenClawKit/Tools/CanvasA2UI" + +# Docker builds exclude vendor/apps via .dockerignore. +# In that environment we must keep the prebuilt bundle. +if [[ ! -d "$A2UI_RENDERER_DIR" || ! -d "$A2UI_APP_DIR" ]]; then + echo "A2UI sources missing; keeping prebuilt bundle." + exit 0 +fi + +INPUT_PATHS=( + "$ROOT_DIR/package.json" + "$ROOT_DIR/pnpm-lock.yaml" + "$A2UI_RENDERER_DIR" + "$A2UI_APP_DIR" +) + +compute_hash() { + ROOT_DIR="$ROOT_DIR" node --input-type=module - "${INPUT_PATHS[@]}" <<'NODE' +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +const rootDir = process.env.ROOT_DIR ?? process.cwd(); +const inputs = process.argv.slice(2); +const files = []; + +async function walk(entryPath) { + const st = await fs.stat(entryPath); + if (st.isDirectory()) { + const entries = await fs.readdir(entryPath); + for (const entry of entries) { + await walk(path.join(entryPath, entry)); + } + return; + } + files.push(entryPath); +} + +for (const input of inputs) { + await walk(input); +} + +function normalize(p) { + return p.split(path.sep).join("/"); +} + +files.sort((a, b) => normalize(a).localeCompare(normalize(b))); + +const hash = createHash("sha256"); +for (const filePath of files) { + const rel = normalize(path.relative(rootDir, filePath)); + hash.update(rel); + hash.update("\0"); + hash.update(await fs.readFile(filePath)); + hash.update("\0"); +} + +process.stdout.write(hash.digest("hex")); +NODE +} + +current_hash="$(compute_hash)" +if [[ -f "$HASH_FILE" ]]; then + previous_hash="$(cat "$HASH_FILE")" + if [[ "$previous_hash" == "$current_hash" && -f "$OUTPUT_FILE" ]]; then + echo "A2UI bundle up to date; skipping." + exit 0 + fi +fi + +pnpm -s exec tsc -p "$A2UI_RENDERER_DIR/tsconfig.json" +rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs" + +echo "$current_hash" > "$HASH_FILE" diff --git a/scripts/canvas-a2ui-copy.ts b/scripts/canvas-a2ui-copy.ts new file mode 100644 index 0000000000000000000000000000000000000000..238bc3b912d6dc232494692436265f20546e3178 --- /dev/null +++ b/scripts/canvas-a2ui-copy.ts @@ -0,0 +1,40 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export function getA2uiPaths(env = process.env) { + const srcDir = env.OPENCLAW_A2UI_SRC_DIR ?? path.join(repoRoot, "src", "canvas-host", "a2ui"); + const outDir = env.OPENCLAW_A2UI_OUT_DIR ?? path.join(repoRoot, "dist", "canvas-host", "a2ui"); + return { srcDir, outDir }; +} + +export async function copyA2uiAssets({ srcDir, outDir }: { srcDir: string; outDir: string }) { + const skipMissing = process.env.OPENCLAW_A2UI_SKIP_MISSING === "1"; + try { + await fs.stat(path.join(srcDir, "index.html")); + await fs.stat(path.join(srcDir, "a2ui.bundle.js")); + } catch (err) { + const message = 'Missing A2UI bundle assets. Run "pnpm canvas:a2ui:bundle" and retry.'; + if (skipMissing) { + console.warn(`${message} Skipping copy (OPENCLAW_A2UI_SKIP_MISSING=1).`); + return; + } + throw new Error(message, { cause: err }); + } + await fs.mkdir(path.dirname(outDir), { recursive: true }); + await fs.cp(srcDir, outDir, { recursive: true }); +} + +async function main() { + const { srcDir, outDir } = getA2uiPaths(); + await copyA2uiAssets({ srcDir, outDir }); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main().catch((err) => { + console.error(String(err)); + process.exit(1); + }); +} diff --git a/scripts/changelog-to-html.sh b/scripts/changelog-to-html.sh new file mode 100644 index 0000000000000000000000000000000000000000..b5ec591310ffe73a8b6f65c4a07eec284adb5c6a --- /dev/null +++ b/scripts/changelog-to-html.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION=${1:-} +CHANGELOG_FILE=${2:-} + +if [[ -z "$VERSION" ]]; then + echo "Usage: $0 [changelog_file]" >&2 + exit 1 +fi + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +if [[ -z "$CHANGELOG_FILE" ]]; then + if [[ -f "$SCRIPT_DIR/../CHANGELOG.md" ]]; then + CHANGELOG_FILE="$SCRIPT_DIR/../CHANGELOG.md" + elif [[ -f "CHANGELOG.md" ]]; then + CHANGELOG_FILE="CHANGELOG.md" + elif [[ -f "../CHANGELOG.md" ]]; then + CHANGELOG_FILE="../CHANGELOG.md" + else + echo "Error: Could not find CHANGELOG.md" >&2 + exit 1 + fi +fi + +if [[ ! -f "$CHANGELOG_FILE" ]]; then + echo "Error: Changelog file '$CHANGELOG_FILE' not found" >&2 + exit 1 +fi + +extract_version_section() { + local version=$1 + local file=$2 + awk -v version="$version" ' + BEGIN { found=0 } + /^## / { + if ($0 ~ "^##[[:space:]]+" version "([[:space:]].*|$)") { found=1; next } + if (found) { exit } + } + found { print } + ' "$file" +} + +markdown_to_html() { + local text=$1 + text=$(echo "$text" | sed 's/^##### \(.*\)$/
\1<\/h5>/') + text=$(echo "$text" | sed 's/^#### \(.*\)$/

\1<\/h4>/') + text=$(echo "$text" | sed 's/^### \(.*\)$/

\1<\/h3>/') + text=$(echo "$text" | sed 's/^## \(.*\)$/

\1<\/h2>/') + text=$(echo "$text" | sed 's/^- \*\*\([^*]*\)\*\*\(.*\)$/
  • \1<\/strong>\2<\/li>/') + text=$(echo "$text" | sed 's/^- \([^*].*\)$/
  • \1<\/li>/') + text=$(echo "$text" | sed 's/\*\*\([^*]*\)\*\*/\1<\/strong>/g') + text=$(echo "$text" | sed 's/`\([^`]*\)`/\1<\/code>/g') + text=$(echo "$text" | sed 's/\[\([^]]*\)\](\([^)]*\))/\1<\/a>/g') + echo "$text" +} + +version_content=$(extract_version_section "$VERSION" "$CHANGELOG_FILE") +if [[ -z "$version_content" ]]; then + echo "

    OpenClaw $VERSION

    " + echo "

    Latest OpenClaw update.

    " + echo "

    View full changelog

    " + exit 0 +fi + +echo "

    OpenClaw $VERSION

    " + +in_list=false +while IFS= read -r line; do + if [[ "$line" =~ ^- ]]; then + if [[ "$in_list" == false ]]; then + echo "
      " + in_list=true + fi + markdown_to_html "$line" + else + if [[ "$in_list" == true ]]; then + echo "
    " + in_list=false + fi + if [[ -n "$line" ]]; then + markdown_to_html "$line" + fi + fi +done <<< "$version_content" + +if [[ "$in_list" == true ]]; then + echo "" +fi + +echo "

    View full changelog

    " diff --git a/scripts/check-ts-max-loc.ts b/scripts/check-ts-max-loc.ts new file mode 100644 index 0000000000000000000000000000000000000000..88b9a0d477e21ee13abc6bf85535ce7ff2012e53 --- /dev/null +++ b/scripts/check-ts-max-loc.ts @@ -0,0 +1,80 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; + +type ParsedArgs = { + maxLines: number; +}; + +function parseArgs(argv: string[]): ParsedArgs { + let maxLines = 500; + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === "--max") { + const next = argv[index + 1]; + if (!next || Number.isNaN(Number(next))) { + throw new Error("Missing/invalid --max value"); + } + maxLines = Number(next); + index++; + continue; + } + } + + return { maxLines }; +} + +function gitLsFilesAll(): string[] { + // Include untracked files too so local refactors don’t “pass” by accident. + const stdout = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], { + encoding: "utf8", + }); + return stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +async function countLines(filePath: string): Promise { + const content = await readFile(filePath, "utf8"); + // Count physical lines. Keeps the rule simple + predictable. + return content.split("\n").length; +} + +async function main() { + // Makes `... | head` safe. + process.stdout.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EPIPE") { + process.exit(0); + } + throw error; + }); + + const { maxLines } = parseArgs(process.argv.slice(2)); + const files = gitLsFilesAll() + .filter((filePath) => existsSync(filePath)) + .filter((filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx")); + + const results = await Promise.all( + files.map(async (filePath) => ({ filePath, lines: await countLines(filePath) })), + ); + + const offenders = results + .filter((result) => result.lines > maxLines) + .toSorted((a, b) => b.lines - a.lines); + + if (!offenders.length) { + return; + } + + // Minimal, grep-friendly output. + for (const offender of offenders) { + // eslint-disable-next-line no-console + console.log(`${offender.lines}\t${offender.filePath}`); + } + + process.exitCode = 1; +} + +await main(); diff --git a/scripts/claude-auth-status.sh b/scripts/claude-auth-status.sh new file mode 100644 index 0000000000000000000000000000000000000000..64babcf71b93841fea67e1afd484535e600d1d7f --- /dev/null +++ b/scripts/claude-auth-status.sh @@ -0,0 +1,280 @@ +#!/bin/bash +# Claude Code Authentication Status Checker +# Checks both Claude Code and OpenClaw auth status + +set -euo pipefail + +CLAUDE_CREDS="$HOME/.claude/.credentials.json" +OPENCLAW_AUTH="$HOME/.openclaw/agents/main/agent/auth-profiles.json" + +# Colors for terminal output +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +# Output mode: "full" (default), "json", or "simple" +OUTPUT_MODE="${1:-full}" + +fetch_models_status_json() { + openclaw models status --json 2>/dev/null || true +} + +STATUS_JSON="$(fetch_models_status_json)" +USE_JSON=0 +if [ -n "$STATUS_JSON" ]; then + USE_JSON=1 +fi + +calc_status_from_expires() { + local expires_at="$1" + if ! [[ "$expires_at" =~ ^-?[0-9]+$ ]]; then + expires_at=0 + fi + local now_ms=$(( $(date +%s) * 1000 )) + local diff_ms=$((expires_at - now_ms)) + local hours=$((diff_ms / 3600000)) + local mins=$(((diff_ms % 3600000) / 60000)) + + if [ "$expires_at" -le 0 ]; then + echo "MISSING" + return 1 + elif [ "$diff_ms" -lt 0 ]; then + echo "EXPIRED" + return 1 + elif [ "$diff_ms" -lt 3600000 ]; then + echo "EXPIRING:${mins}m" + return 2 + else + echo "OK:${hours}h${mins}m" + return 0 + fi +} + +json_expires_for_claude_cli() { + echo "$STATUS_JSON" | jq -r ' + [.auth.oauth.profiles[] + | select(.provider == "anthropic" and (.type == "oauth" or .type == "token")) + | .expiresAt // 0] + | max // 0 + ' 2>/dev/null || echo "0" +} + +json_expires_for_anthropic_any() { + echo "$STATUS_JSON" | jq -r ' + [.auth.oauth.profiles[] + | select(.provider == "anthropic" and .type == "oauth") + | .expiresAt // 0] + | max // 0 + ' 2>/dev/null || echo "0" +} + +json_best_anthropic_profile() { + echo "$STATUS_JSON" | jq -r ' + [.auth.oauth.profiles[] + | select(.provider == "anthropic" and .type == "oauth") + | {id: .profileId, exp: (.expiresAt // 0)}] + | sort_by(.exp) | reverse | .[0].id // "none" + ' 2>/dev/null || echo "none" +} + +json_anthropic_api_key_count() { + echo "$STATUS_JSON" | jq -r ' + [.auth.providers[] | select(.provider == "anthropic") | .profiles.apiKey] + | max // 0 + ' 2>/dev/null || echo "0" +} + +check_claude_code_auth() { + if [ "$USE_JSON" -eq 1 ]; then + local expires_at + expires_at=$(json_expires_for_claude_cli) + calc_status_from_expires "$expires_at" + return $? + fi + + if [ ! -f "$CLAUDE_CREDS" ]; then + echo "MISSING" + return 1 + fi + + local expires_at + expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0") + calc_status_from_expires "$expires_at" +} + +check_openclaw_auth() { + if [ "$USE_JSON" -eq 1 ]; then + local api_keys + api_keys=$(json_anthropic_api_key_count) + if ! [[ "$api_keys" =~ ^[0-9]+$ ]]; then + api_keys=0 + fi + local expires_at + expires_at=$(json_expires_for_anthropic_any) + + if [ "$expires_at" -le 0 ] && [ "$api_keys" -gt 0 ]; then + echo "OK:static" + return 0 + fi + + calc_status_from_expires "$expires_at" + return $? + fi + + if [ ! -f "$OPENCLAW_AUTH" ]; then + echo "MISSING" + return 1 + fi + + local expires + expires=$(jq -r ' + [.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires] + | max // 0 + ' "$OPENCLAW_AUTH" 2>/dev/null || echo "0") + + calc_status_from_expires "$expires" +} + +# JSON output mode +if [ "$OUTPUT_MODE" = "json" ]; then + claude_status=$(check_claude_code_auth 2>/dev/null || true) + openclaw_status=$(check_openclaw_auth 2>/dev/null || true) + + claude_expires=0 + openclaw_expires=0 + if [ "$USE_JSON" -eq 1 ]; then + claude_expires=$(json_expires_for_claude_cli) + openclaw_expires=$(json_expires_for_anthropic_any) + else + claude_expires=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0") + openclaw_expires=$(jq -r '.profiles["anthropic:default"].expires // 0' "$OPENCLAW_AUTH" 2>/dev/null || echo "0") + fi + + jq -n \ + --arg cs "$claude_status" \ + --arg ce "$claude_expires" \ + --arg bs "$openclaw_status" \ + --arg be "$openclaw_expires" \ + '{ + claude_code: {status: $cs, expires_at_ms: ($ce | tonumber)}, + openclaw: {status: $bs, expires_at_ms: ($be | tonumber)}, + needs_reauth: (($cs | startswith("EXPIRED") or startswith("EXPIRING") or startswith("MISSING")) or ($bs | startswith("EXPIRED") or startswith("EXPIRING") or startswith("MISSING"))) + }' + exit 0 +fi + +# Simple output mode (for scripts/widgets) +if [ "$OUTPUT_MODE" = "simple" ]; then + claude_status=$(check_claude_code_auth 2>/dev/null || true) + openclaw_status=$(check_openclaw_auth 2>/dev/null || true) + + if [[ "$claude_status" == EXPIRED* ]] || [[ "$claude_status" == MISSING* ]]; then + echo "CLAUDE_EXPIRED" + exit 1 + elif [[ "$openclaw_status" == EXPIRED* ]] || [[ "$openclaw_status" == MISSING* ]]; then + echo "OPENCLAW_EXPIRED" + exit 1 + elif [[ "$claude_status" == EXPIRING* ]]; then + echo "CLAUDE_EXPIRING" + exit 2 + elif [[ "$openclaw_status" == EXPIRING* ]]; then + echo "OPENCLAW_EXPIRING" + exit 2 + else + echo "OK" + exit 0 + fi +fi + +# Full output mode (default) +echo "=== Claude Code Auth Status ===" +echo "" + +# Claude Code credentials +echo "Claude Code (~/.claude/.credentials.json):" +if [ "$USE_JSON" -eq 1 ]; then + expires_at=$(json_expires_for_claude_cli) +else + expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0") +fi + +if [ -f "$CLAUDE_CREDS" ]; then + sub_type=$(jq -r '.claudeAiOauth.subscriptionType // "unknown"' "$CLAUDE_CREDS" 2>/dev/null || echo "unknown") + rate_tier=$(jq -r '.claudeAiOauth.rateLimitTier // "unknown"' "$CLAUDE_CREDS" 2>/dev/null || echo "unknown") + echo " Subscription: $sub_type" + echo " Rate tier: $rate_tier" +fi + +if [ "$expires_at" -le 0 ]; then + echo -e " Status: ${RED}NOT FOUND${NC}" + echo " Action needed: Run 'claude setup-token'" +else + now_ms=$(( $(date +%s) * 1000 )) + diff_ms=$((expires_at - now_ms)) + hours=$((diff_ms / 3600000)) + mins=$(((diff_ms % 3600000) / 60000)) + + if [ "$diff_ms" -lt 0 ]; then + echo -e " Status: ${RED}EXPIRED${NC}" + echo " Action needed: Run 'claude setup-token' or re-authenticate" + elif [ "$diff_ms" -lt 3600000 ]; then + echo -e " Status: ${YELLOW}EXPIRING SOON (${mins}m remaining)${NC}" + echo " Consider running: claude setup-token" + else + echo -e " Status: ${GREEN}OK${NC}" + echo " Expires: $(date -d @$((expires_at/1000))) (${hours}h ${mins}m)" + fi +fi + +echo "" +echo "OpenClaw Auth (~/.openclaw/agents/main/agent/auth-profiles.json):" +if [ "$USE_JSON" -eq 1 ]; then + best_profile=$(json_best_anthropic_profile) + expires=$(json_expires_for_anthropic_any) + api_keys=$(json_anthropic_api_key_count) +else + best_profile=$(jq -r ' + .profiles | to_entries + | map(select(.value.provider == "anthropic")) + | sort_by(.value.expires) | reverse + | .[0].key // "none" + ' "$OPENCLAW_AUTH" 2>/dev/null || echo "none") + expires=$(jq -r ' + [.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires] + | max // 0 + ' "$OPENCLAW_AUTH" 2>/dev/null || echo "0") + api_keys=0 +fi + +echo " Profile: $best_profile" + +if [ "$expires" -le 0 ] && [ "$api_keys" -gt 0 ]; then + echo -e " Status: ${GREEN}OK${NC} (API key)" +elif [ "$expires" -le 0 ]; then + echo -e " Status: ${RED}NOT FOUND${NC}" + echo " Note: Run 'openclaw doctor --yes' to sync from Claude Code" +else + now_ms=$(( $(date +%s) * 1000 )) + diff_ms=$((expires - now_ms)) + hours=$((diff_ms / 3600000)) + mins=$(((diff_ms % 3600000) / 60000)) + + if [ "$diff_ms" -lt 0 ]; then + echo -e " Status: ${RED}EXPIRED${NC}" + echo " Note: Run 'openclaw doctor --yes' to sync from Claude Code" + elif [ "$diff_ms" -lt 3600000 ]; then + echo -e " Status: ${YELLOW}EXPIRING SOON (${mins}m remaining)${NC}" + else + echo -e " Status: ${GREEN}OK${NC}" + echo " Expires: $(date -d @$((expires/1000))) (${hours}h ${mins}m)" + fi +fi + +echo "" +echo "=== Service Status ===" +if systemctl --user is-active openclaw >/dev/null 2>&1; then + echo -e "OpenClaw service: ${GREEN}running${NC}" +else + echo -e "OpenClaw service: ${RED}NOT running${NC}" +fi diff --git a/scripts/clawlog.sh b/scripts/clawlog.sh new file mode 100644 index 0000000000000000000000000000000000000000..06dda1085ca6bc64c23c8e6e2f387958ac98d05b --- /dev/null +++ b/scripts/clawlog.sh @@ -0,0 +1,309 @@ +#!/bin/bash + +# VibeTunnel Logging Utility +# Simplifies access to VibeTunnel logs using macOS unified logging system + +set -euo pipefail + +# Configuration +SUBSYSTEM="ai.openclaw" +DEFAULT_LEVEL="info" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to handle sudo password errors +handle_sudo_error() { + echo -e "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW}⚠️ Password Required for Log Access${NC}" + echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n" + echo -e "clawlog needs to use sudo to show complete log data (Apple hides sensitive info by default)." + echo -e "\nTo avoid password prompts, configure passwordless sudo for the log command:" + echo -e "See: ${BLUE}apple/docs/logging-private-fix.md${NC}\n" + echo -e "Quick fix:" + echo -e " 1. Run: ${GREEN}sudo visudo${NC}" + echo -e " 2. Add: ${GREEN}$(whoami) ALL=(ALL) NOPASSWD: /usr/bin/log${NC}" + echo -e " 3. Save and exit (:wq)\n" + echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n" + exit 1 +} + +# Default values +STREAM_MODE=false +TIME_RANGE="5m" # Default to last 5 minutes +CATEGORY="" +LOG_LEVEL="$DEFAULT_LEVEL" +SEARCH_TEXT="" +OUTPUT_FILE="" +ERRORS_ONLY=false +SERVER_ONLY=false +TAIL_LINES=50 # Default number of lines to show +SHOW_TAIL=true +SHOW_HELP=false + +# Function to show usage +show_usage() { + cat << EOF +clawlog - OpenClaw Logging Utility + +USAGE: + clawlog [OPTIONS] + +DESCRIPTION: + View OpenClaw logs with full details (bypasses Apple's privacy redaction). + Requires sudo access configured for /usr/bin/log command. + +LOG FLOW ARCHITECTURE: + OpenClaw logs flow through the macOS unified log (subsystem: ai.openclaw). + +LOG CATEGORIES (examples): + • voicewake - Voice wake detection/test harness + • gateway - Gateway process manager + • xpc - XPC service calls + • notifications - Notification helper + • screenshot - Screenshotter + • shell - ShellExecutor + +QUICK START: + clawlog -n 100 Show last 100 lines from all components + clawlog -f Follow logs in real-time + clawlog -e Show only errors + clawlog -c ServerManager Show logs from ServerManager only + +OPTIONS: + -h, --help Show this help message + -f, --follow Stream logs continuously (like tail -f) + -n, --lines NUM Number of lines to show (default: 50) + -l, --last TIME Time range to search (default: 5m) + Examples: 5m, 1h, 2d, 1w + -c, --category CAT Filter by category (e.g., ServerManager, SessionService) + -e, --errors Show only error messages + -d, --debug Show debug level logs (more verbose) + -s, --search TEXT Search for specific text in log messages + -o, --output FILE Export logs to file + --server Show only server output logs + --all Show all logs without tail limit + --list-categories List all available log categories + --json Output in JSON format + +EXAMPLES: + clawlog Show last 50 lines from past 5 minutes (default) + clawlog -f Stream logs continuously + clawlog -n 100 Show last 100 lines + clawlog -e Show only recent errors + clawlog -l 30m -n 200 Show last 200 lines from past 30 minutes + clawlog -c ServerManager Show recent ServerManager logs + clawlog -s "fail" Search for "fail" in recent logs + clawlog --server -e Show recent server errors + clawlog -f -d Stream debug logs continuously + +CATEGORIES: + Common categories include: + - ServerManager - Server lifecycle and configuration + - SessionService - Terminal session management + - TerminalManager - Terminal spawning and control + - GitRepository - Git integration features + - ScreencapService - Screen capture functionality + - WebRTCManager - WebRTC connections + - UnixSocket - Unix socket communication + - WindowTracker - Window tracking and focus + - NgrokService - Ngrok tunnel management + - ServerOutput - Node.js server output + +TIME FORMATS: + - 5m = 5 minutes - 1h = 1 hour + - 2d = 2 days - 1w = 1 week + +EOF +} + +# Function to list categories +list_categories() { + echo -e "${BLUE}Fetching VibeTunnel log categories from the last hour...${NC}\n" + + # Get unique categories from recent logs + log show --predicate "subsystem == \"$SUBSYSTEM\"" --last 1h 2>/dev/null | \ + grep -E "category: \"[^\"]+\"" | \ + sed -E 's/.*category: "([^"]+)".*/\1/' | \ + sort | uniq | \ + while read -r cat; do + echo " • $cat" + done + + echo -e "\n${YELLOW}Note: Only categories with recent activity are shown${NC}" +} + +# Show help if no arguments provided +if [[ $# -eq 0 ]]; then + show_usage + exit 0 +fi + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_usage + exit 0 + ;; + -f|--follow) + STREAM_MODE=true + SHOW_TAIL=false + shift + ;; + -n|--lines) + TAIL_LINES="$2" + shift 2 + ;; + -l|--last) + TIME_RANGE="$2" + shift 2 + ;; + -c|--category) + CATEGORY="$2" + shift 2 + ;; + -e|--errors) + ERRORS_ONLY=true + shift + ;; + -d|--debug) + LOG_LEVEL="debug" + shift + ;; + -s|--search) + SEARCH_TEXT="$2" + shift 2 + ;; + -o|--output) + OUTPUT_FILE="$2" + shift 2 + ;; + --server) + SERVER_ONLY=true + CATEGORY="ServerOutput" + shift + ;; + --list-categories) + list_categories + exit 0 + ;; + --json) + STYLE_ARGS="--style json" + shift + ;; + --all) + SHOW_TAIL=false + shift + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + echo "Use -h or --help for usage information" + exit 1 + ;; + esac +done + +# Build the predicate +PREDICATE="subsystem == \"$SUBSYSTEM\"" + +# Add category filter if specified +if [[ -n "$CATEGORY" ]]; then + PREDICATE="$PREDICATE AND category == \"$CATEGORY\"" +fi + +# Add error filter if specified +if [[ "$ERRORS_ONLY" == true ]]; then + PREDICATE="$PREDICATE AND (eventType == \"error\" OR messageType == \"error\" OR eventMessage CONTAINS \"ERROR\" OR eventMessage CONTAINS \"[31m\")" +fi + +# Add search filter if specified +if [[ -n "$SEARCH_TEXT" ]]; then + PREDICATE="$PREDICATE AND eventMessage CONTAINS[c] \"$SEARCH_TEXT\"" +fi + +# Build the command - always use sudo with --info to show private data +if [[ "$STREAM_MODE" == true ]]; then + # Streaming mode + CMD="sudo log stream --predicate '$PREDICATE' --level $LOG_LEVEL --info" + + echo -e "${GREEN}Streaming VibeTunnel logs continuously...${NC}" + echo -e "${YELLOW}Press Ctrl+C to stop${NC}\n" +else + # Show mode + CMD="sudo log show --predicate '$PREDICATE'" + + # Add log level for show command + if [[ "$LOG_LEVEL" == "debug" ]]; then + CMD="$CMD --debug" + else + CMD="$CMD --info" + fi + + # Add time range + CMD="$CMD --last $TIME_RANGE" + + if [[ "$SHOW_TAIL" == true ]]; then + echo -e "${GREEN}Showing last $TAIL_LINES log lines from the past $TIME_RANGE${NC}" + else + echo -e "${GREEN}Showing all logs from the past $TIME_RANGE${NC}" + fi + + # Show applied filters + if [[ "$ERRORS_ONLY" == true ]]; then + echo -e "${RED}Filter: Errors only${NC}" + fi + if [[ -n "$CATEGORY" ]]; then + echo -e "${BLUE}Category: $CATEGORY${NC}" + fi + if [[ -n "$SEARCH_TEXT" ]]; then + echo -e "${YELLOW}Search: \"$SEARCH_TEXT\"${NC}" + fi + echo "" # Empty line for readability +fi + +# Add style arguments if specified +if [[ -n "${STYLE_ARGS:-}" ]]; then + CMD="$CMD $STYLE_ARGS" +fi + +# Execute the command +if [[ -n "$OUTPUT_FILE" ]]; then + # First check if sudo works without password for the log command + if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then + handle_sudo_error + fi + + echo -e "${BLUE}Exporting logs to: $OUTPUT_FILE${NC}\n" + if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then + eval "$CMD" 2>&1 | tail -n "$TAIL_LINES" > "$OUTPUT_FILE" + else + eval "$CMD" > "$OUTPUT_FILE" 2>&1 + fi + + # Check if file was created and has content + if [[ -s "$OUTPUT_FILE" ]]; then + LINE_COUNT=$(wc -l < "$OUTPUT_FILE" | tr -d ' ') + echo -e "${GREEN}✓ Exported $LINE_COUNT lines to $OUTPUT_FILE${NC}" + else + echo -e "${YELLOW}⚠ No logs found matching the criteria${NC}" + fi +else + # Run interactively + # First check if sudo works without password for the log command + if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then + handle_sudo_error + fi + + if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then + # Apply tail for non-streaming mode + eval "$CMD" 2>&1 | tail -n "$TAIL_LINES" + echo -e "\n${YELLOW}Showing last $TAIL_LINES lines. Use --all or -n to see more.${NC}" + else + eval "$CMD" + fi +fi diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json new file mode 100644 index 0000000000000000000000000000000000000000..d652938a6067fdc8ebceb8d7b36280d3c3ea3e7b --- /dev/null +++ b/scripts/clawtributors-map.json @@ -0,0 +1,39 @@ +{ + "ensureLogins": [ + "odrobnik", + "alphonse-arianee", + "aaronn", + "ronak-guliani", + "cpojer", + "carlulsoe", + "jdrhyne", + "latitudeki5223", + "longmaba", + "manmal", + "thesash", + "rhjoh", + "ysqander", + "atalovesyou", + "0xJonHoldsCrypto", + "hougangdev" + ], + "seedCommit": "d6863f87", + "placeholderAvatar": "assets/avatar-placeholder.svg", + "displayName": { + "jdrhyne": "Jonathan D. Rhyne (DJ-D)" + }, + "nameToLogin": { + "peter steinberger": "steipete", + "eng. juan combetto": "omniwired", + "mariano belinky": "mbelinky", + "vasanth rao naik sabavat": "vsabavat", + "tu nombre real": "nachx639", + "django navarro": "djangonavarro220" + }, + "emailToLogin": { + "steipete@gmail.com": "steipete", + "sbarrios93@gmail.com": "sebslight", + "rltorres26+github@gmail.com": "RandyVentures", + "hixvac@gmail.com": "VACInc" + } +} diff --git a/scripts/codesign-mac-app.sh b/scripts/codesign-mac-app.sh new file mode 100644 index 0000000000000000000000000000000000000000..d43987c7a28832f7dad1d0dd27e67878e4baf48d --- /dev/null +++ b/scripts/codesign-mac-app.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_BUNDLE="${1:-dist/OpenClaw.app}" +IDENTITY="${SIGN_IDENTITY:-}" +TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}" +DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}" +SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}" +ENT_TMP_BASE=$(mktemp -t openclaw-entitlements-base.XXXXXX) +ENT_TMP_APP_BASE=$(mktemp -t openclaw-entitlements-app-base.XXXXXX) +ENT_TMP_RUNTIME=$(mktemp -t openclaw-entitlements-runtime.XXXXXX) + +if [[ "${APP_BUNDLE}" == "--help" || "${APP_BUNDLE}" == "-h" ]]; then + cat <<'HELP' +Usage: scripts/codesign-mac-app.sh [app-bundle] + +Env: + SIGN_IDENTITY="Apple Development: Your Name (TEAMID)" + ALLOW_ADHOC_SIGNING=1 + CODESIGN_TIMESTAMP=auto|on|off + DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround + SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit +HELP + exit 0 +fi + +if [ ! -d "$APP_BUNDLE" ]; then + echo "App bundle not found: $APP_BUNDLE" >&2 + exit 1 +fi + +select_identity() { + local preferred available first + + # Prefer a Developer ID Application cert. + preferred="$(security find-identity -p codesigning -v 2>/dev/null \ + | awk -F'\"' '/Developer ID Application/ { print $2; exit }')" + + if [ -n "$preferred" ]; then + echo "$preferred" + return + fi + + # Next, try Apple Distribution. + preferred="$(security find-identity -p codesigning -v 2>/dev/null \ + | awk -F'\"' '/Apple Distribution/ { print $2; exit }')" + if [ -n "$preferred" ]; then + echo "$preferred" + return + fi + + # Then, try Apple Development. + preferred="$(security find-identity -p codesigning -v 2>/dev/null \ + | awk -F'\"' '/Apple Development/ { print $2; exit }')" + if [ -n "$preferred" ]; then + echo "$preferred" + return + fi + + # Fallback to the first valid signing identity. + available="$(security find-identity -p codesigning -v 2>/dev/null \ + | sed -n 's/.*\"\\(.*\\)\"/\\1/p')" + + if [ -n "$available" ]; then + first="$(printf '%s\n' "$available" | head -n1)" + echo "$first" + return + fi + + return 1 +} + +if [ -z "$IDENTITY" ]; then + if ! IDENTITY="$(select_identity)"; then + if [[ "${ALLOW_ADHOC_SIGNING:-}" == "1" ]]; then + echo "WARN: No signing identity found. Falling back to ad-hoc signing (-)." >&2 + echo " !!! WARNING: Ad-hoc signed apps do NOT persist TCC permissions (Accessibility, etc) !!!" >&2 + echo " !!! You will need to re-grant permissions every time you restart the app. !!!" >&2 + IDENTITY="-" + else + echo "ERROR: No signing identity found. Set SIGN_IDENTITY to a valid codesigning certificate." >&2 + echo " Alternatively, set ALLOW_ADHOC_SIGNING=1 to fallback to ad-hoc signing (limitations apply)." >&2 + exit 1 + fi + fi +fi + +echo "Using signing identity: $IDENTITY" +if [[ "$IDENTITY" == "-" ]]; then + cat <<'WARN' >&2 + +================================================================================ +!!! AD-HOC SIGNING IN USE - PERMISSIONS WILL NOT STICK (macOS RESTRICTION) !!! + +macOS ties permissions to the code signature, bundle ID, and app path. +Ad-hoc signing generates a new signature every build, so macOS treats the app +as a different binary and will forget permissions (prompts may vanish). + +For correct permission behavior you MUST sign with a real Apple Development or +Developer ID certificate. + +If prompts disappear: remove the app entry in System Settings -> Privacy & Security, +relaunch the app, and re-grant. Some permissions only reappear after a full +macOS restart. +================================================================================ + +WARN +fi + +timestamp_arg="--timestamp=none" +case "$TIMESTAMP_MODE" in + 1|on|yes|true) + timestamp_arg="--timestamp" + ;; + 0|off|no|false) + timestamp_arg="--timestamp=none" + ;; + auto) + if [[ "$IDENTITY" == *"Developer ID Application"* ]]; then + timestamp_arg="--timestamp" + fi + ;; + *) + echo "ERROR: Unknown CODESIGN_TIMESTAMP value: $TIMESTAMP_MODE (use auto|on|off)" >&2 + exit 1 + ;; +esac +if [[ "$IDENTITY" == "-" ]]; then + timestamp_arg="--timestamp=none" +fi + +options_args=() +if [[ "$IDENTITY" != "-" ]]; then + options_args=("--options" "runtime") +fi +timestamp_args=("$timestamp_arg") + +cat > "$ENT_TMP_BASE" <<'PLIST' + + + + + com.apple.security.automation.apple-events + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + + +PLIST + +cat > "$ENT_TMP_APP_BASE" <<'PLIST' + + + + + com.apple.security.automation.apple-events + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + com.apple.security.personal-information.location + + + +PLIST + +cat > "$ENT_TMP_RUNTIME" <<'PLIST' + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + +PLIST + +if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then + /usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \ + /usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE" + echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)." +fi + +APP_ENTITLEMENTS="$ENT_TMP_APP_BASE" + +# clear extended attributes to avoid stale signatures +xattr -cr "$APP_BUNDLE" 2>/dev/null || true + +sign_item() { + local target="$1" + local entitlements="$2" + codesign --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --entitlements "$entitlements" --sign "$IDENTITY" "$target" +} + +sign_plain_item() { + local target="$1" + codesign --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --sign "$IDENTITY" "$target" +} + +team_id_for() { + codesign -dv --verbose=4 "$1" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2; exit}' +} + +verify_team_ids() { + if [[ "$SKIP_TEAM_ID_CHECK" == "1" ]]; then + echo "Note: skipping Team ID audit (SKIP_TEAM_ID_CHECK=1)." + return 0 + fi + + local expected + expected="$(team_id_for "$APP_BUNDLE" || true)" + if [[ -z "$expected" ]]; then + echo "WARN: TeamIdentifier missing on app bundle; skipping Team ID audit." + return 0 + fi + + local mismatches=() + while IFS= read -r -d '' f; do + if /usr/bin/file "$f" | /usr/bin/grep -q "Mach-O"; then + local team + team="$(team_id_for "$f" || true)" + if [[ -z "$team" ]]; then + team="not set" + fi + if [[ "$expected" == "not set" ]]; then + if [[ "$team" != "not set" ]]; then + mismatches+=("$f (TeamIdentifier=$team)") + fi + elif [[ "$team" != "$expected" ]]; then + mismatches+=("$f (TeamIdentifier=$team)") + fi + fi + done < <(find "$APP_BUNDLE" -type f -print0) + + if [[ "${#mismatches[@]}" -gt 0 ]]; then + echo "ERROR: Team ID mismatch detected (expected: $expected)" + for entry in "${mismatches[@]}"; do + echo " - $entry" + done + echo "Hint: re-sign embedded frameworks or set DISABLE_LIBRARY_VALIDATION=1 for dev builds." + exit 1 + fi +} + +# Sign main binary +if [ -f "$APP_BUNDLE/Contents/MacOS/OpenClaw" ]; then + echo "Signing main binary"; sign_item "$APP_BUNDLE/Contents/MacOS/OpenClaw" "$APP_ENTITLEMENTS" +fi + +# Sign Sparkle deeply if present +SPARKLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework" +if [ -d "$SPARKLE" ]; then + echo "Signing Sparkle framework and helpers" + find "$SPARKLE" -type f -print0 | while IFS= read -r -d '' f; do + if /usr/bin/file "$f" | /usr/bin/grep -q "Mach-O"; then + sign_plain_item "$f" + fi + done + sign_plain_item "$SPARKLE/Versions/B/Sparkle" + sign_plain_item "$SPARKLE/Versions/B/Autoupdate" + sign_plain_item "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater" + sign_plain_item "$SPARKLE/Versions/B/Updater.app" + sign_plain_item "$SPARKLE/Versions/B/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" + sign_plain_item "$SPARKLE/Versions/B/XPCServices/Downloader.xpc" + sign_plain_item "$SPARKLE/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer" + sign_plain_item "$SPARKLE/Versions/B/XPCServices/Installer.xpc" + sign_plain_item "$SPARKLE/Versions/B" + sign_plain_item "$SPARKLE" +fi + +# Sign any other embedded frameworks/dylibs +if [ -d "$APP_BUNDLE/Contents/Frameworks" ]; then + find "$APP_BUNDLE/Contents/Frameworks" \( -name "*.framework" -o -name "*.dylib" \) ! -path "*Sparkle.framework*" -print0 | while IFS= read -r -d '' f; do + echo "Signing framework: $f"; sign_plain_item "$f" + done +fi + +# Finally sign the bundle +sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS" + +verify_team_ids + +rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_RUNTIME" +echo "Codesign complete for $APP_BUNDLE" diff --git a/scripts/committer b/scripts/committer new file mode 100644 index 0000000000000000000000000000000000000000..f73810583fa8d99f11be61cd0e52f7fd419638c9 --- /dev/null +++ b/scripts/committer @@ -0,0 +1,117 @@ +#!/usr/bin/env bash + +set -euo pipefail +# Disable glob expansion to handle brackets in file paths +set -f +usage() { + printf 'Usage: %s [--force] "commit message" "file" ["file" ...]\n' "$(basename "$0")" >&2 + exit 2 +} + +if [ "$#" -lt 2 ]; then + usage +fi + +force_delete_lock=false +if [ "${1:-}" = "--force" ]; then + force_delete_lock=true + shift +fi + +if [ "$#" -lt 2 ]; then + usage +fi + +commit_message=$1 +shift + +if [[ "$commit_message" != *[![:space:]]* ]]; then + printf 'Error: commit message must not be empty\n' >&2 + exit 1 +fi + +if [ -e "$commit_message" ]; then + printf 'Error: first argument looks like a file path ("%s"); provide the commit message first\n' "$commit_message" >&2 + exit 1 +fi + +if [ "$#" -eq 0 ]; then + usage +fi + +files=("$@") + +# Disallow "." because it stages the entire repository and defeats the helper's safety guardrails. +for file in "${files[@]}"; do + if [ "$file" = "." ]; then + printf 'Error: "." is not allowed; list specific paths instead\n' >&2 + exit 1 + fi +done + +# Prevent staging node_modules even if a path is forced. +for file in "${files[@]}"; do + case "$file" in + *node_modules* | */node_modules | */node_modules/* | node_modules) + printf 'Error: node_modules paths are not allowed: %s\n' "$file" >&2 + exit 1 + ;; + esac +done + +last_commit_error='' + +run_git_commit() { + local stderr_log + stderr_log=$(mktemp) + if git commit -m "$commit_message" -- "${files[@]}" 2> >(tee "$stderr_log" >&2); then + rm -f "$stderr_log" + last_commit_error='' + return 0 + fi + + last_commit_error=$(cat "$stderr_log") + rm -f "$stderr_log" + return 1 +} + +for file in "${files[@]}"; do + if [ ! -e "$file" ]; then + if ! git ls-files --error-unmatch -- "$file" >/dev/null 2>&1; then + printf 'Error: file not found: %s\n' "$file" >&2 + exit 1 + fi + fi +done + +git restore --staged :/ +git add --force -- "${files[@]}" + +if git diff --staged --quiet; then + printf 'Warning: no staged changes detected for: %s\n' "${files[*]}" >&2 + exit 1 +fi + +committed=false +if run_git_commit; then + committed=true +elif [ "$force_delete_lock" = true ]; then + lock_path=$( + printf '%s\n' "$last_commit_error" | + awk -F"'" '/Unable to create .*\.git\/index\.lock/ { print $2; exit }' + ) + + if [ -n "$lock_path" ] && [ -e "$lock_path" ]; then + rm -f "$lock_path" + printf 'Removed stale git lock: %s\n' "$lock_path" >&2 + if run_git_commit; then + committed=true + fi + fi +fi + +if [ "$committed" = false ]; then + exit 1 +fi + +printf 'Committed "%s" with %d files\n' "$commit_message" "${#files[@]}" diff --git a/scripts/copy-hook-metadata.ts b/scripts/copy-hook-metadata.ts new file mode 100644 index 0000000000000000000000000000000000000000..be44f6932709851e6578838f05d2b52a1671e6fa --- /dev/null +++ b/scripts/copy-hook-metadata.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env tsx +/** + * Copy HOOK.md files from src/hooks/bundled to dist/hooks/bundled + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); + +const srcBundled = path.join(projectRoot, "src", "hooks", "bundled"); +const distBundled = path.join(projectRoot, "dist", "hooks", "bundled"); + +function copyHookMetadata() { + if (!fs.existsSync(srcBundled)) { + console.warn("[copy-hook-metadata] Source directory not found:", srcBundled); + return; + } + + if (!fs.existsSync(distBundled)) { + fs.mkdirSync(distBundled, { recursive: true }); + } + + const entries = fs.readdirSync(srcBundled, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const hookName = entry.name; + const srcHookDir = path.join(srcBundled, hookName); + const distHookDir = path.join(distBundled, hookName); + const srcHookMd = path.join(srcHookDir, "HOOK.md"); + const distHookMd = path.join(distHookDir, "HOOK.md"); + + if (!fs.existsSync(srcHookMd)) { + console.warn(`[copy-hook-metadata] No HOOK.md found for ${hookName}`); + continue; + } + + if (!fs.existsSync(distHookDir)) { + fs.mkdirSync(distHookDir, { recursive: true }); + } + + fs.copyFileSync(srcHookMd, distHookMd); + console.log(`[copy-hook-metadata] Copied ${hookName}/HOOK.md`); + } + + console.log("[copy-hook-metadata] Done"); +} + +copyHookMetadata(); diff --git a/scripts/create-dmg.sh b/scripts/create-dmg.sh new file mode 100644 index 0000000000000000000000000000000000000000..a9f71eb6ca54883259a45c42a764f29ea1a397e0 --- /dev/null +++ b/scripts/create-dmg.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Create a styled DMG containing the app bundle + /Applications symlink. +# +# Usage: +# scripts/create-dmg.sh [output_dmg] +# +# Env: +# DMG_VOLUME_NAME default: CFBundleName (or "OpenClaw") +# DMG_BACKGROUND_PATH default: assets/dmg-background.png +# DMG_BACKGROUND_SMALL default: assets/dmg-background-small.png (recommended) +# DMG_WINDOW_BOUNDS default: "400 100 900 420" (500x320) +# DMG_ICON_SIZE default: 128 +# DMG_APP_POS default: "125 160" +# DMG_APPS_POS default: "375 160" +# SKIP_DMG_STYLE=1 skip Finder styling +# DMG_EXTRA_SECTORS extra sectors to keep when shrinking RW image (default: 2048) + +APP_PATH="${1:-}" +OUT_PATH="${2:-}" + +if [[ -z "$APP_PATH" ]]; then + echo "Usage: $0 [output_dmg]" >&2 + exit 1 +fi +if [[ ! -d "$APP_PATH" ]]; then + echo "Error: App not found: $APP_PATH" >&2 + exit 1 +fi + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/dist" +mkdir -p "$BUILD_DIR" + +APP_NAME=$(/usr/libexec/PlistBuddy -c "Print CFBundleName" "$APP_PATH/Contents/Info.plist" 2>/dev/null || echo "OpenClaw") +VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP_PATH/Contents/Info.plist" 2>/dev/null || echo "0.0.0") + +DMG_NAME="${APP_NAME}-${VERSION}.dmg" +DMG_VOLUME_NAME="${DMG_VOLUME_NAME:-$APP_NAME}" +DMG_BACKGROUND_SMALL="${DMG_BACKGROUND_SMALL:-$ROOT_DIR/assets/dmg-background-small.png}" +DMG_BACKGROUND_PATH="${DMG_BACKGROUND_PATH:-$ROOT_DIR/assets/dmg-background.png}" + +DMG_WINDOW_BOUNDS="${DMG_WINDOW_BOUNDS:-400 100 900 420}" +DMG_ICON_SIZE="${DMG_ICON_SIZE:-128}" +DMG_APP_POS="${DMG_APP_POS:-125 160}" +DMG_APPS_POS="${DMG_APPS_POS:-375 160}" +DMG_EXTRA_SECTORS="${DMG_EXTRA_SECTORS:-2048}" + +to_applescript_list4() { + local raw="$1" + echo "$raw" | awk '{ printf "%s, %s, %s, %s", $1, $2, $3, $4 }' +} + +to_applescript_pair() { + local raw="$1" + echo "$raw" | awk '{ printf "%s, %s", $1, $2 }' +} + +if [[ -z "$OUT_PATH" ]]; then + OUT_PATH="$BUILD_DIR/$DMG_NAME" +fi + +echo "Creating DMG: $OUT_PATH" + +# Cleanup stuck volumes. +for vol in "/Volumes/$DMG_VOLUME_NAME"* "/Volumes/$APP_NAME"*; do + if [[ -d "$vol" ]]; then + hdiutil detach "$vol" -force 2>/dev/null || true + sleep 1 + fi +done + +DMG_TEMP="$(mktemp -d /tmp/openclaw-dmg.XXXXXX)" +trap 'hdiutil detach "/Volumes/'"$DMG_VOLUME_NAME"'" -force 2>/dev/null || true; rm -rf "$DMG_TEMP" 2>/dev/null || true' EXIT + +cp -R "$APP_PATH" "$DMG_TEMP/" +ln -s /Applications "$DMG_TEMP/Applications" + +APP_SIZE_MB=$(du -sm "$APP_PATH" | awk '{print $1}') +DMG_SIZE_MB=$((APP_SIZE_MB + 80)) + +DMG_RW_PATH="${OUT_PATH%.dmg}-rw.dmg" +rm -f "$DMG_RW_PATH" "$OUT_PATH" + +hdiutil create \ + -volname "$DMG_VOLUME_NAME" \ + -srcfolder "$DMG_TEMP" \ + -ov \ + -format UDRW \ + -size "${DMG_SIZE_MB}m" \ + "$DMG_RW_PATH" + +MOUNT_POINT="/Volumes/$DMG_VOLUME_NAME" +if [[ -d "$MOUNT_POINT" ]]; then + hdiutil detach "$MOUNT_POINT" -force 2>/dev/null || true + sleep 2 +fi +hdiutil attach "$DMG_RW_PATH" -mountpoint "$MOUNT_POINT" -nobrowse + +if [[ "${SKIP_DMG_STYLE:-0}" != "1" ]]; then + mkdir -p "$MOUNT_POINT/.background" + if [[ -f "$DMG_BACKGROUND_SMALL" ]]; then + cp "$DMG_BACKGROUND_SMALL" "$MOUNT_POINT/.background/background.png" + elif [[ -f "$DMG_BACKGROUND_PATH" ]]; then + cp "$DMG_BACKGROUND_PATH" "$MOUNT_POINT/.background/background.png" + else + echo "WARN: DMG background missing: $DMG_BACKGROUND_SMALL / $DMG_BACKGROUND_PATH" >&2 + fi + + # Volume icon: reuse the app icon if available. + ICON_SRC="$ROOT_DIR/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns" + if [[ -f "$ICON_SRC" ]]; then + cp "$ICON_SRC" "$MOUNT_POINT/.VolumeIcon.icns" + if command -v SetFile >/dev/null 2>&1; then + SetFile -a C "$MOUNT_POINT" 2>/dev/null || true + fi + fi + + osascript </dev/null; then + break + fi + if [[ "$i" == "3" ]]; then + hdiutil detach "$MOUNT_POINT" -force 2>/dev/null || true + fi + sleep 2 +done + +hdiutil resize -limits "$DMG_RW_PATH" >/tmp/openclaw-dmg-limits.txt 2>/dev/null || true +MIN_SECTORS="$(tail -n 1 /tmp/openclaw-dmg-limits.txt 2>/dev/null | awk '{print $1}')" +rm -f /tmp/openclaw-dmg-limits.txt +if [[ "$MIN_SECTORS" =~ ^[0-9]+$ ]] && [[ "$DMG_EXTRA_SECTORS" =~ ^[0-9]+$ ]]; then + TARGET_SECTORS=$((MIN_SECTORS + DMG_EXTRA_SECTORS)) + echo "Shrinking RW image: min sectors=$MIN_SECTORS (+$DMG_EXTRA_SECTORS) -> $TARGET_SECTORS" + hdiutil resize -sectors "$TARGET_SECTORS" "$DMG_RW_PATH" >/dev/null 2>&1 || true +fi + +hdiutil convert "$DMG_RW_PATH" -format ULMO -o "$OUT_PATH" -ov +rm -f "$DMG_RW_PATH" + +hdiutil verify "$OUT_PATH" >/dev/null +echo "✅ DMG ready: $OUT_PATH" diff --git a/scripts/debug-claude-usage.ts b/scripts/debug-claude-usage.ts new file mode 100644 index 0000000000000000000000000000000000000000..556360c394ee3a006e443b3b7c1b36a28f75534c --- /dev/null +++ b/scripts/debug-claude-usage.ts @@ -0,0 +1,391 @@ +import { execFileSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +type Args = { + agentId: string; + reveal: boolean; + sessionKey?: string; +}; + +const mask = (value: string) => { + const compact = value.trim(); + if (!compact) { + return "missing"; + } + const edge = compact.length >= 12 ? 6 : 4; + return `${compact.slice(0, edge)}…${compact.slice(-edge)}`; +}; + +const parseArgs = (): Args => { + const args = process.argv.slice(2); + let agentId = "main"; + let reveal = false; + let sessionKey: string | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--agent" && args[i + 1]) { + agentId = String(args[++i]).trim() || "main"; + continue; + } + if (arg === "--reveal") { + reveal = true; + continue; + } + if (arg === "--session-key" && args[i + 1]) { + sessionKey = String(args[++i]).trim() || undefined; + continue; + } + } + + return { agentId, reveal, sessionKey }; +}; + +const loadAuthProfiles = (agentId: string) => { + const stateRoot = + process.env.OPENCLAW_STATE_DIR?.trim() || + process.env.CLAWDBOT_STATE_DIR?.trim() || + path.join(os.homedir(), ".openclaw"); + const authPath = path.join(stateRoot, "agents", agentId, "agent", "auth-profiles.json"); + if (!fs.existsSync(authPath)) { + throw new Error(`Missing: ${authPath}`); + } + const store = JSON.parse(fs.readFileSync(authPath, "utf8")) as { + profiles?: Record; + }; + return { authPath, store }; +}; + +const pickAnthropicTokens = (store: { + profiles?: Record; +}): Array<{ profileId: string; token: string }> => { + const profiles = store.profiles ?? {}; + const found: Array<{ profileId: string; token: string }> = []; + for (const [id, cred] of Object.entries(profiles)) { + if (cred?.provider !== "anthropic") { + continue; + } + const token = cred.type === "token" ? cred.token?.trim() : undefined; + if (token) { + found.push({ profileId: id, token }); + } + } + return found; +}; + +const fetchAnthropicOAuthUsage = async (token: string) => { + const res = await fetch("https://api.anthropic.com/api/oauth/usage", { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + "anthropic-version": "2023-06-01", + "anthropic-beta": "oauth-2025-04-20", + "User-Agent": "openclaw-debug", + }, + }); + const text = await res.text(); + return { status: res.status, contentType: res.headers.get("content-type"), text }; +}; + +const readClaudeCliKeychain = (): { + accessToken: string; + expiresAt?: number; + scopes?: string[]; +} | null => { + if (process.platform !== "darwin") { + return null; + } + try { + const raw = execFileSync( + "security", + ["find-generic-password", "-s", "Claude Code-credentials", "-w"], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }, + ); + const parsed = JSON.parse(raw.trim()) as Record; + const oauth = parsed?.claudeAiOauth as Record | undefined; + if (!oauth || typeof oauth !== "object") { + return null; + } + const accessToken = oauth.accessToken; + if (typeof accessToken !== "string" || !accessToken.trim()) { + return null; + } + const expiresAt = typeof oauth.expiresAt === "number" ? oauth.expiresAt : undefined; + const scopes = Array.isArray(oauth.scopes) + ? oauth.scopes.filter((v): v is string => typeof v === "string") + : undefined; + return { accessToken, expiresAt, scopes }; + } catch { + return null; + } +}; + +const chromeServiceNameForPath = (cookiePath: string): string => { + if (cookiePath.includes("/Arc/")) { + return "Arc Safe Storage"; + } + if (cookiePath.includes("/BraveSoftware/")) { + return "Brave Safe Storage"; + } + if (cookiePath.includes("/Microsoft Edge/")) { + return "Microsoft Edge Safe Storage"; + } + if (cookiePath.includes("/Chromium/")) { + return "Chromium Safe Storage"; + } + return "Chrome Safe Storage"; +}; + +const readKeychainPassword = (service: string): string | null => { + try { + const out = execFileSync("security", ["find-generic-password", "-w", "-s", service], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5000, + }); + const pw = out.trim(); + return pw ? pw : null; + } catch { + return null; + } +}; + +const decryptChromeCookieValue = (encrypted: Buffer, service: string): string | null => { + if (encrypted.length < 4) { + return null; + } + const prefix = encrypted.subarray(0, 3).toString("utf8"); + if (prefix !== "v10" && prefix !== "v11") { + return null; + } + + const password = readKeychainPassword(service); + if (!password) { + return null; + } + + const key = crypto.pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1"); + const iv = Buffer.alloc(16, 0x20); + const data = encrypted.subarray(3); + + try { + const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv); + decipher.setAutoPadding(true); + const decrypted = Buffer.concat([decipher.update(data), decipher.final()]); + const text = decrypted.toString("utf8").trim(); + return text ? text : null; + } catch { + return null; + } +}; + +const queryChromeCookieDb = (cookieDb: string): string | null => { + try { + const out = execFileSync( + "sqlite3", + [ + "-readonly", + cookieDb, + ` + SELECT + COALESCE(NULLIF(value,''), hex(encrypted_value)) + FROM cookies + WHERE (host_key LIKE '%claude.ai%' OR host_key = '.claude.ai') + AND name = 'sessionKey' + LIMIT 1; + `, + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }, + ).trim(); + if (!out) { + return null; + } + if (out.startsWith("sk-ant-")) { + return out; + } + const hex = out.replace(/[^0-9A-Fa-f]/g, ""); + if (!hex) { + return null; + } + const buf = Buffer.from(hex, "hex"); + const service = chromeServiceNameForPath(cookieDb); + const decrypted = decryptChromeCookieValue(buf, service); + return decrypted && decrypted.startsWith("sk-ant-") ? decrypted : null; + } catch { + return null; + } +}; + +const queryFirefoxCookieDb = (cookieDb: string): string | null => { + try { + const out = execFileSync( + "sqlite3", + [ + "-readonly", + cookieDb, + ` + SELECT value + FROM moz_cookies + WHERE (host LIKE '%claude.ai%' OR host = '.claude.ai') + AND name = 'sessionKey' + LIMIT 1; + `, + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }, + ).trim(); + return out && out.startsWith("sk-ant-") ? out : null; + } catch { + return null; + } +}; + +const findClaudeSessionKey = (): { sessionKey: string; source: string } | null => { + if (process.platform !== "darwin") { + return null; + } + + const firefoxRoot = path.join( + os.homedir(), + "Library", + "Application Support", + "Firefox", + "Profiles", + ); + if (fs.existsSync(firefoxRoot)) { + for (const entry of fs.readdirSync(firefoxRoot)) { + const db = path.join(firefoxRoot, entry, "cookies.sqlite"); + if (!fs.existsSync(db)) { + continue; + } + const value = queryFirefoxCookieDb(db); + if (value) { + return { sessionKey: value, source: `firefox:${db}` }; + } + } + } + + const chromeCandidates = [ + path.join(os.homedir(), "Library", "Application Support", "Google", "Chrome"), + path.join(os.homedir(), "Library", "Application Support", "Chromium"), + path.join(os.homedir(), "Library", "Application Support", "Arc"), + path.join(os.homedir(), "Library", "Application Support", "BraveSoftware", "Brave-Browser"), + path.join(os.homedir(), "Library", "Application Support", "Microsoft Edge"), + ]; + + for (const root of chromeCandidates) { + if (!fs.existsSync(root)) { + continue; + } + const profiles = fs + .readdirSync(root) + .filter((name) => name === "Default" || name.startsWith("Profile ")); + for (const profile of profiles) { + const db = path.join(root, profile, "Cookies"); + if (!fs.existsSync(db)) { + continue; + } + const value = queryChromeCookieDb(db); + if (value) { + return { sessionKey: value, source: `chromium:${db}` }; + } + } + } + + return null; +}; + +const fetchClaudeWebUsage = async (sessionKey: string) => { + const headers = { + Cookie: `sessionKey=${sessionKey}`, + Accept: "application/json", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + }; + const orgRes = await fetch("https://claude.ai/api/organizations", { headers }); + const orgText = await orgRes.text(); + if (!orgRes.ok) { + return { ok: false as const, step: "organizations", status: orgRes.status, body: orgText }; + } + const orgs = JSON.parse(orgText) as Array<{ uuid?: string }>; + const orgId = orgs?.[0]?.uuid; + if (!orgId) { + return { ok: false as const, step: "organizations", status: 200, body: orgText }; + } + + const usageRes = await fetch(`https://claude.ai/api/organizations/${orgId}/usage`, { headers }); + const usageText = await usageRes.text(); + return usageRes.ok + ? { ok: true as const, orgId, body: usageText } + : { ok: false as const, step: "usage", status: usageRes.status, body: usageText }; +}; + +const main = async () => { + const opts = parseArgs(); + const { authPath, store } = loadAuthProfiles(opts.agentId); + console.log(`Auth file: ${authPath}`); + + const keychain = readClaudeCliKeychain(); + if (keychain) { + console.log( + `Claude Code CLI keychain: accessToken=${opts.reveal ? keychain.accessToken : mask(keychain.accessToken)} scopes=${keychain.scopes?.join(",") ?? "(unknown)"}`, + ); + const oauth = await fetchAnthropicOAuthUsage(keychain.accessToken); + console.log( + `OAuth usage (keychain): HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`, + ); + console.log(oauth.text.slice(0, 200).replace(/\s+/g, " ").trim()); + } else { + console.log("Claude Code CLI keychain: missing/unreadable"); + } + + const anthropic = pickAnthropicTokens(store); + if (anthropic.length === 0) { + console.log("Auth profiles: no Anthropic token profiles found"); + } else { + for (const entry of anthropic) { + console.log( + `Auth profiles: ${entry.profileId} token=${opts.reveal ? entry.token : mask(entry.token)}`, + ); + const oauth = await fetchAnthropicOAuthUsage(entry.token); + console.log( + `OAuth usage (${entry.profileId}): HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`, + ); + console.log(oauth.text.slice(0, 200).replace(/\s+/g, " ").trim()); + } + } + + const sessionKey = + opts.sessionKey?.trim() || + process.env.CLAUDE_AI_SESSION_KEY?.trim() || + process.env.CLAUDE_WEB_SESSION_KEY?.trim() || + findClaudeSessionKey()?.sessionKey; + const source = opts.sessionKey + ? "--session-key" + : process.env.CLAUDE_AI_SESSION_KEY || process.env.CLAUDE_WEB_SESSION_KEY + ? "env" + : (findClaudeSessionKey()?.source ?? "auto"); + + if (!sessionKey) { + console.log( + "Claude web: no sessionKey found (try --session-key or export CLAUDE_AI_SESSION_KEY)", + ); + return; + } + + console.log( + `Claude web: sessionKey=${opts.reveal ? sessionKey : mask(sessionKey)} (source: ${source})`, + ); + const web = await fetchClaudeWebUsage(sessionKey); + if (!web.ok) { + console.log(`Claude web: ${web.step} HTTP ${web.status}`); + console.log(String(web.body).slice(0, 400).replace(/\s+/g, " ").trim()); + return; + } + console.log(`Claude web: org=${web.orgId} OK`); + console.log(web.body.slice(0, 400).replace(/\s+/g, " ").trim()); +}; + +await main(); diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..f02ce5f6bee0c2fa2a40e0839a2c920dd1f3f653 --- /dev/null +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -0,0 +1,20 @@ +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /repo +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY scripts/postinstall.js ./scripts/postinstall.js +RUN corepack enable \ + && pnpm install --frozen-lockfile + +COPY . . +COPY scripts/docker/cleanup-smoke/run.sh /usr/local/bin/openclaw-cleanup-smoke +RUN chmod +x /usr/local/bin/openclaw-cleanup-smoke + +ENTRYPOINT ["/usr/local/bin/openclaw-cleanup-smoke"] diff --git a/scripts/docker/cleanup-smoke/run.sh b/scripts/docker/cleanup-smoke/run.sh new file mode 100644 index 0000000000000000000000000000000000000000..d466dcc7f467b1a0c21f50bb368690b1484ff2ad --- /dev/null +++ b/scripts/docker/cleanup-smoke/run.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /repo + +export OPENCLAW_STATE_DIR="/tmp/openclaw-test" +export OPENCLAW_CONFIG_PATH="${OPENCLAW_STATE_DIR}/openclaw.json" + +echo "==> Seed state" +mkdir -p "${OPENCLAW_STATE_DIR}/credentials" +mkdir -p "${OPENCLAW_STATE_DIR}/agents/main/sessions" +echo '{}' >"${OPENCLAW_CONFIG_PATH}" +echo 'creds' >"${OPENCLAW_STATE_DIR}/credentials/marker.txt" +echo 'session' >"${OPENCLAW_STATE_DIR}/agents/main/sessions/sessions.json" + +echo "==> Reset (config+creds+sessions)" +pnpm openclaw reset --scope config+creds+sessions --yes --non-interactive + +test ! -f "${OPENCLAW_CONFIG_PATH}" +test ! -d "${OPENCLAW_STATE_DIR}/credentials" +test ! -d "${OPENCLAW_STATE_DIR}/agents/main/sessions" + +echo "==> Recreate minimal config" +mkdir -p "${OPENCLAW_STATE_DIR}/credentials" +echo '{}' >"${OPENCLAW_CONFIG_PATH}" + +echo "==> Uninstall (state only)" +pnpm openclaw uninstall --state --yes --non-interactive + +test ! -d "${OPENCLAW_STATE_DIR}" + +echo "OK" diff --git a/scripts/docker/install-sh-e2e/Dockerfile b/scripts/docker/install-sh-e2e/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..3b887c4203094b637e2eecaa94032dbbf975fb44 --- /dev/null +++ b/scripts/docker/install-sh-e2e/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +COPY run.sh /usr/local/bin/openclaw-install-e2e +RUN chmod +x /usr/local/bin/openclaw-install-e2e + +ENTRYPOINT ["/usr/local/bin/openclaw-install-e2e"] diff --git a/scripts/docker/install-sh-e2e/run.sh b/scripts/docker/install-sh-e2e/run.sh new file mode 100644 index 0000000000000000000000000000000000000000..dfd31957fbaa248b6f7be407682784c278a11752 --- /dev/null +++ b/scripts/docker/install-sh-e2e/run.sh @@ -0,0 +1,531 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTALL_URL="${OPENCLAW_INSTALL_URL:-${CLAWDBOT_INSTALL_URL:-https://openclaw.bot/install.sh}}" +MODELS_MODE="${OPENCLAW_E2E_MODELS:-${CLAWDBOT_E2E_MODELS:-both}}" # both|openai|anthropic +INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-${CLAWDBOT_INSTALL_TAG:-latest}}" +E2E_PREVIOUS_VERSION="${OPENCLAW_INSTALL_E2E_PREVIOUS:-${CLAWDBOT_INSTALL_E2E_PREVIOUS:-}}" +SKIP_PREVIOUS="${OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS:-${CLAWDBOT_INSTALL_E2E_SKIP_PREVIOUS:-0}}" +OPENAI_API_KEY="${OPENAI_API_KEY:-}" +ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" +ANTHROPIC_API_TOKEN="${ANTHROPIC_API_TOKEN:-}" + +if [[ "$MODELS_MODE" != "both" && "$MODELS_MODE" != "openai" && "$MODELS_MODE" != "anthropic" ]]; then + echo "ERROR: OPENCLAW_E2E_MODELS must be one of: both|openai|anthropic" >&2 + exit 2 +fi + +if [[ "$MODELS_MODE" == "both" ]]; then + if [[ -z "$OPENAI_API_KEY" ]]; then + echo "ERROR: OPENCLAW_E2E_MODELS=both requires OPENAI_API_KEY." >&2 + exit 2 + fi + if [[ -z "$ANTHROPIC_API_TOKEN" && -z "$ANTHROPIC_API_KEY" ]]; then + echo "ERROR: OPENCLAW_E2E_MODELS=both requires ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY." >&2 + exit 2 + fi +elif [[ "$MODELS_MODE" == "openai" && -z "$OPENAI_API_KEY" ]]; then + echo "ERROR: OPENCLAW_E2E_MODELS=openai requires OPENAI_API_KEY." >&2 + exit 2 +elif [[ "$MODELS_MODE" == "anthropic" && -z "$ANTHROPIC_API_TOKEN" && -z "$ANTHROPIC_API_KEY" ]]; then + echo "ERROR: OPENCLAW_E2E_MODELS=anthropic requires ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY." >&2 + exit 2 +fi + +echo "==> Resolve npm versions" +EXPECTED_VERSION="$(npm view "openclaw@${INSTALL_TAG}" version)" +if [[ -z "$EXPECTED_VERSION" || "$EXPECTED_VERSION" == "undefined" || "$EXPECTED_VERSION" == "null" ]]; then + echo "ERROR: unable to resolve openclaw@${INSTALL_TAG} version" >&2 + exit 2 +fi +if [[ -n "$E2E_PREVIOUS_VERSION" ]]; then + PREVIOUS_VERSION="$E2E_PREVIOUS_VERSION" +else + PREVIOUS_VERSION="$(node - <<'NODE' +const { execSync } = require("node:child_process"); +const versions = JSON.parse(execSync("npm view openclaw versions --json", { encoding: "utf8" })); +if (!Array.isArray(versions) || versions.length === 0) process.exit(1); +process.stdout.write(versions.length >= 2 ? versions[versions.length - 2] : versions[0]); +NODE + )" +fi +echo "expected=$EXPECTED_VERSION previous=$PREVIOUS_VERSION" + +if [[ "$SKIP_PREVIOUS" == "1" ]]; then + echo "==> Skip preinstall previous (OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS=1)" +else + echo "==> Preinstall previous (forces installer upgrade path; avoids read() prompt)" + npm install -g "openclaw@${PREVIOUS_VERSION}" +fi + +echo "==> Run official installer one-liner" +if [[ "$INSTALL_TAG" == "beta" ]]; then + OPENCLAW_BETA=1 CLAWDBOT_BETA=1 curl -fsSL "$INSTALL_URL" | bash +elif [[ "$INSTALL_TAG" != "latest" ]]; then + OPENCLAW_VERSION="$INSTALL_TAG" CLAWDBOT_VERSION="$INSTALL_TAG" curl -fsSL "$INSTALL_URL" | bash +else + curl -fsSL "$INSTALL_URL" | bash +fi + +echo "==> Verify installed version" +INSTALLED_VERSION="$(openclaw --version 2>/dev/null | head -n 1 | tr -d '\r')" +echo "installed=$INSTALLED_VERSION expected=$EXPECTED_VERSION" +if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then + echo "ERROR: expected openclaw@$EXPECTED_VERSION, got openclaw@$INSTALLED_VERSION" >&2 + exit 1 +fi + +set_image_model() { + local profile="$1" + shift + local candidate + for candidate in "$@"; do + if openclaw --profile "$profile" models set-image "$candidate" >/dev/null 2>&1; then + echo "$candidate" + return 0 + fi + done + echo "ERROR: could not set an image model (tried: $*)" >&2 + return 1 +} + +set_agent_model() { + local profile="$1" + local candidate + shift + for candidate in "$@"; do + if openclaw --profile "$profile" models set "$candidate" >/dev/null 2>&1; then + echo "$candidate" + return 0 + fi + done + echo "ERROR: could not set agent model (tried: $*)" >&2 + return 1 +} + +write_png_lr_rg() { + local out="$1" + node - <<'NODE' "$out" +const fs = require("node:fs"); +const zlib = require("node:zlib"); + +const out = process.argv[2]; +const width = 96; +const height = 64; + +const crcTable = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + table[i] = c >>> 0; + } + return table; +})(); +function crc32(buf) { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function chunk(type, data) { + const typeBuf = Buffer.from(type, "ascii"); + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); + return Buffer.concat([len, typeBuf, data, crcBuf]); +} + +const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const ihdr = Buffer.alloc(13); +ihdr.writeUInt32BE(width, 0); +ihdr.writeUInt32BE(height, 4); +ihdr[8] = 8; // bit depth +ihdr[9] = 2; // color type: truecolor +ihdr[10] = 0; // compression +ihdr[11] = 0; // filter +ihdr[12] = 0; // interlace + +const rows = []; +for (let y = 0; y < height; y++) { + const row = Buffer.alloc(1 + width * 3); + row[0] = 0; // filter: none + for (let x = 0; x < width; x++) { + const i = 1 + x * 3; + const left = x < width / 2; + row[i + 0] = left ? 255 : 0; + row[i + 1] = left ? 0 : 255; + row[i + 2] = 0; + } + rows.push(row); +} +const raw = Buffer.concat(rows); +const idat = zlib.deflateSync(raw, { level: 9 }); + +const png = Buffer.concat([ + sig, + chunk("IHDR", ihdr), + chunk("IDAT", idat), + chunk("IEND", Buffer.alloc(0)), +]); +fs.writeFileSync(out, png); +NODE +} + +run_agent_turn() { + local profile="$1" + local session_id="$2" + local prompt="$3" + local out_json="$4" + openclaw --profile "$profile" agent \ + --session-id "$session_id" \ + --message "$prompt" \ + --thinking off \ + --json >"$out_json" +} + +assert_agent_json_has_text() { + local path="$1" + node - <<'NODE' "$path" +const fs = require("node:fs"); +const p = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const payloads = + Array.isArray(p?.result?.payloads) ? p.result.payloads : + Array.isArray(p?.payloads) ? p.payloads : + []; +const texts = payloads.map((x) => String(x?.text ?? "").trim()).filter(Boolean); +if (texts.length === 0) process.exit(1); +NODE +} + +assert_agent_json_ok() { + local json_path="$1" + local expect_provider="$2" + node - <<'NODE' "$json_path" "$expect_provider" +const fs = require("node:fs"); +const jsonPath = process.argv[2]; +const expectProvider = process.argv[3]; +const p = JSON.parse(fs.readFileSync(jsonPath, "utf8")); + +if (typeof p?.status === "string" && p.status !== "ok" && p.status !== "accepted") { + console.error(`ERROR: gateway status=${p.status}`); + process.exit(1); +} + +const result = p?.result ?? p; +const payloads = Array.isArray(result?.payloads) ? result.payloads : []; +const anyError = payloads.some((pl) => pl && pl.isError === true); +const combinedText = payloads.map((pl) => String(pl?.text ?? "")).filter(Boolean).join("\n").trim(); +if (anyError) { + console.error(`ERROR: agent returned error payload: ${combinedText}`); + process.exit(1); +} +if (/rate_limit_error/i.test(combinedText) || /^429\\b/.test(combinedText)) { + console.error(`ERROR: agent rate limited: ${combinedText}`); + process.exit(1); +} + +const meta = result?.meta; +const provider = + (typeof meta?.agentMeta?.provider === "string" && meta.agentMeta.provider.trim()) || + (typeof meta?.provider === "string" && meta.provider.trim()) || + ""; +if (expectProvider && provider && provider !== expectProvider) { + console.error(`ERROR: expected provider=${expectProvider}, got provider=${provider}`); + process.exit(1); +} +NODE +} + +extract_matching_text() { + local path="$1" + local expected="$2" + node - <<'NODE' "$path" "$expected" +const fs = require("node:fs"); +const p = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const expected = String(process.argv[3] ?? ""); +const payloads = + Array.isArray(p?.result?.payloads) ? p.result.payloads : + Array.isArray(p?.payloads) ? p.payloads : + []; +const texts = payloads.map((x) => String(x?.text ?? "").trim()).filter(Boolean); +const match = texts.find((text) => text === expected); +process.stdout.write(match ?? texts[0] ?? ""); +NODE +} + +assert_session_used_tools() { + local jsonl="$1" + shift + node - <<'NODE' "$jsonl" "$@" +const fs = require("node:fs"); +const jsonl = process.argv[2]; +const required = new Set(process.argv.slice(3)); + +const raw = fs.readFileSync(jsonl, "utf8"); +const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean); +const seen = new Set(); + +const toolTypes = new Set([ + "tool_use", + "tool_result", + "tool", + "tool-call", + "tool_call", + "tooluse", + "tool-use", + "toolresult", + "tool-result", +]); +function walk(node, parent) { + if (!node) return; + if (Array.isArray(node)) { + for (const item of node) walk(item, node); + return; + } + if (typeof node !== "object") return; + const obj = node; + const t = typeof obj.type === "string" ? obj.type : null; + if (t && (toolTypes.has(t) || /tool/i.test(t))) { + const name = + typeof obj.name === "string" ? obj.name : + typeof obj.toolName === "string" ? obj.toolName : + typeof obj.tool_name === "string" ? obj.tool_name : + (obj.tool && typeof obj.tool.name === "string") ? obj.tool.name : + null; + if (name) seen.add(name); + } + if (typeof obj.name === "string" && typeof obj.input === "object" && obj.input) { + // Many tool-use blocks look like { type: "...", name: "exec", input: {...} } + // but some transcripts omit/rename type. + seen.add(obj.name); + } + // OpenAI-ish tool call shapes. + if (Array.isArray(obj.tool_calls)) { + for (const c of obj.tool_calls) { + const fn = c?.function; + if (fn && typeof fn.name === "string") seen.add(fn.name); + } + } + if (obj.function && typeof obj.function.name === "string") seen.add(obj.function.name); + for (const v of Object.values(obj)) walk(v, obj); +} + +for (const line of lines) { + try { + const entry = JSON.parse(line); + walk(entry, null); + } catch { + // ignore unparsable lines + } +} + +const missing = [...required].filter((t) => !seen.has(t)); +if (missing.length > 0) { + console.error(`Missing tools in transcript: ${missing.join(", ")}`); + console.error(`Seen tools: ${[...seen].sort().join(", ")}`); + console.error("Transcript head:"); + console.error(lines.slice(0, 5).join("\n")); + process.exit(1); +} +NODE +} + +run_profile() { + local profile="$1" + local port="$2" + local workspace="$3" + local agent_model_provider="$4" # "openai"|"anthropic" + + echo "==> Onboard ($profile)" + if [[ "$agent_model_provider" == "openai" ]]; then + openclaw --profile "$profile" onboard \ + --non-interactive \ + --accept-risk \ + --flow quickstart \ + --auth-choice openai-api-key \ + --openai-api-key "$OPENAI_API_KEY" \ + --gateway-port "$port" \ + --gateway-bind loopback \ + --gateway-auth token \ + --workspace "$workspace" \ + --skip-health + elif [[ -n "$ANTHROPIC_API_TOKEN" ]]; then + openclaw --profile "$profile" onboard \ + --non-interactive \ + --accept-risk \ + --flow quickstart \ + --auth-choice token \ + --token-provider anthropic \ + --token "$ANTHROPIC_API_TOKEN" \ + --gateway-port "$port" \ + --gateway-bind loopback \ + --gateway-auth token \ + --workspace "$workspace" \ + --skip-health + else + openclaw --profile "$profile" onboard \ + --non-interactive \ + --accept-risk \ + --flow quickstart \ + --auth-choice apiKey \ + --anthropic-api-key "$ANTHROPIC_API_KEY" \ + --gateway-port "$port" \ + --gateway-bind loopback \ + --gateway-auth token \ + --workspace "$workspace" \ + --skip-health + fi + + echo "==> Verify workspace identity files ($profile)" + test -f "$workspace/AGENTS.md" + test -f "$workspace/IDENTITY.md" + test -f "$workspace/USER.md" + test -f "$workspace/SOUL.md" + test -f "$workspace/TOOLS.md" + + echo "==> Configure models ($profile)" + local agent_model + local image_model + if [[ "$agent_model_provider" == "openai" ]]; then + agent_model="$(set_agent_model "$profile" \ + "openai/gpt-4.1-mini" \ + "openai/gpt-4.1" \ + "openai/gpt-4o-mini" \ + "openai/gpt-4o")" + image_model="$(set_image_model "$profile" \ + "openai/gpt-4.1" \ + "openai/gpt-4o-mini" \ + "openai/gpt-4o" \ + "openai/gpt-4.1-mini")" + else + agent_model="$(set_agent_model "$profile" \ + "anthropic/claude-opus-4-5" \ + "claude-opus-4-5")" + image_model="$(set_image_model "$profile" \ + "anthropic/claude-opus-4-5" \ + "claude-opus-4-5")" + fi + echo "model=$agent_model" + echo "imageModel=$image_model" + + echo "==> Prepare tool fixtures ($profile)" + PROOF_TXT="$workspace/proof.txt" + PROOF_COPY="$workspace/copy.txt" + HOSTNAME_TXT="$workspace/hostname.txt" + IMAGE_PNG="$workspace/proof.png" + IMAGE_TXT="$workspace/image.txt" + SESSION_ID="e2e-tools-${profile}" + SESSION_JSONL="/root/.openclaw-${profile}/agents/main/sessions/${SESSION_ID}.jsonl" + + PROOF_VALUE="$(node -e 'console.log(require("node:crypto").randomBytes(16).toString("hex"))')" + echo -n "$PROOF_VALUE" >"$PROOF_TXT" + write_png_lr_rg "$IMAGE_PNG" + EXPECTED_HOSTNAME="$(cat /etc/hostname | tr -d '\r\n')" + + echo "==> Start gateway ($profile)" + GATEWAY_LOG="$workspace/gateway.log" + openclaw --profile "$profile" gateway --port "$port" --bind loopback >"$GATEWAY_LOG" 2>&1 & + GATEWAY_PID="$!" + cleanup_profile() { + if kill -0 "$GATEWAY_PID" 2>/dev/null; then + kill "$GATEWAY_PID" 2>/dev/null || true + wait "$GATEWAY_PID" 2>/dev/null || true + fi + } + trap cleanup_profile EXIT + + echo "==> Wait for health ($profile)" + for _ in $(seq 1 60); do + if openclaw --profile "$profile" health --timeout 2000 --json >/dev/null 2>&1; then + break + fi + sleep 0.25 + done + openclaw --profile "$profile" health --timeout 10000 --json >/dev/null + + echo "==> Agent turns ($profile)" + TURN1_JSON="/tmp/agent-${profile}-1.json" + TURN2_JSON="/tmp/agent-${profile}-2.json" + TURN3_JSON="/tmp/agent-${profile}-3.json" + TURN4_JSON="/tmp/agent-${profile}-4.json" + + run_agent_turn "$profile" "$SESSION_ID" \ + "Use the read tool (not exec) to read proof.txt. Reply with the exact contents only (no extra whitespace)." \ + "$TURN1_JSON" + assert_agent_json_has_text "$TURN1_JSON" + assert_agent_json_ok "$TURN1_JSON" "$agent_model_provider" + local reply1 + reply1="$(extract_matching_text "$TURN1_JSON" "$PROOF_VALUE" | tr -d '\r\n')" + if [[ "$reply1" != "$PROOF_VALUE" ]]; then + echo "ERROR: agent did not read proof.txt correctly ($profile): $reply1" >&2 + exit 1 + fi + + local prompt2 + prompt2=$'Use the write tool (not exec) to write exactly this string into copy.txt:\n'"${reply1}"$'\nThen use the read tool (not exec) to read copy.txt and reply with the exact contents only (no extra whitespace).' + run_agent_turn "$profile" "$SESSION_ID" "$prompt2" "$TURN2_JSON" + assert_agent_json_has_text "$TURN2_JSON" + assert_agent_json_ok "$TURN2_JSON" "$agent_model_provider" + local copy_value + copy_value="$(cat "$PROOF_COPY" 2>/dev/null | tr -d '\r\n' || true)" + if [[ "$copy_value" != "$PROOF_VALUE" ]]; then + echo "ERROR: copy.txt did not match proof.txt ($profile)" >&2 + exit 1 + fi + local reply2 + reply2="$(extract_matching_text "$TURN2_JSON" "$PROOF_VALUE" | tr -d '\r\n')" + if [[ "$reply2" != "$PROOF_VALUE" ]]; then + echo "ERROR: agent did not read copy.txt correctly ($profile): $reply2" >&2 + exit 1 + fi + + local prompt3 + prompt3=$'Use the exec tool to run: cat /etc/hostname\nThen use the write tool to write the exact stdout (trim trailing newline) into hostname.txt. Reply with the hostname only.' + run_agent_turn "$profile" "$SESSION_ID" "$prompt3" "$TURN3_JSON" + assert_agent_json_has_text "$TURN3_JSON" + assert_agent_json_ok "$TURN3_JSON" "$agent_model_provider" + if [[ "$(cat "$HOSTNAME_TXT" 2>/dev/null | tr -d '\r\n' || true)" != "$EXPECTED_HOSTNAME" ]]; then + echo "ERROR: hostname.txt did not match /etc/hostname ($profile)" >&2 + exit 1 + fi + + run_agent_turn "$profile" "$SESSION_ID" \ + "Use the image tool on proof.png. Determine which color is on the left half and which is on the right half. Then use the write tool to write exactly: LEFT=RED RIGHT=GREEN into image.txt. Reply with exactly: LEFT=RED RIGHT=GREEN" \ + "$TURN4_JSON" + assert_agent_json_has_text "$TURN4_JSON" + assert_agent_json_ok "$TURN4_JSON" "$agent_model_provider" + if [[ "$(cat "$IMAGE_TXT" 2>/dev/null | tr -d '\r\n' || true)" != "LEFT=RED RIGHT=GREEN" ]]; then + echo "ERROR: image.txt did not contain expected marker ($profile)" >&2 + exit 1 + fi + local reply4 + reply4="$(extract_matching_text "$TURN4_JSON" "LEFT=RED RIGHT=GREEN")" + if [[ "$reply4" != "LEFT=RED RIGHT=GREEN" ]]; then + echo "ERROR: agent reply did not contain expected marker ($profile): $reply4" >&2 + exit 1 + fi + + echo "==> Verify tool usage via session transcript ($profile)" + # Give the gateway a moment to flush transcripts. + sleep 1 + if [[ ! -f "$SESSION_JSONL" ]]; then + echo "ERROR: missing session transcript ($profile): $SESSION_JSONL" >&2 + ls -la "/root/.openclaw-${profile}/agents/main/sessions" >&2 || true + exit 1 + fi + assert_session_used_tools "$SESSION_JSONL" read write exec image + + cleanup_profile + trap - EXIT +} + +if [[ "$MODELS_MODE" == "openai" || "$MODELS_MODE" == "both" ]]; then + run_profile "e2e-openai" "18789" "/tmp/openclaw-e2e-openai" "openai" +fi + +if [[ "$MODELS_MODE" == "anthropic" || "$MODELS_MODE" == "both" ]]; then + run_profile "e2e-anthropic" "18799" "/tmp/openclaw-e2e-anthropic" "anthropic" +fi + +echo "OK" diff --git a/scripts/docker/install-sh-nonroot/Dockerfile b/scripts/docker/install-sh-nonroot/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..f7c378281cebe152508f54424a3459f8835d7d6a --- /dev/null +++ b/scripts/docker/install-sh-nonroot/Dockerfile @@ -0,0 +1,29 @@ +FROM ubuntu:24.04 + +RUN set -eux; \ + for attempt in 1 2 3; do \ + if apt-get update -o Acquire::Retries=3; then break; fi; \ + echo "apt-get update failed (attempt ${attempt})" >&2; \ + if [ "${attempt}" -eq 3 ]; then exit 1; fi; \ + sleep 3; \ + done; \ + apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + sudo \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -s /bin/bash app \ + && echo "app ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/app + +USER app +WORKDIR /home/app + +ENV NPM_CONFIG_FUND=false +ENV NPM_CONFIG_AUDIT=false + +COPY run.sh /usr/local/bin/openclaw-install-nonroot +RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot + +ENTRYPOINT ["/usr/local/bin/openclaw-install-nonroot"] diff --git a/scripts/docker/install-sh-nonroot/run.sh b/scripts/docker/install-sh-nonroot/run.sh new file mode 100644 index 0000000000000000000000000000000000000000..93da907b3b8ed7ba0065bbbc9add0bea1269fed0 --- /dev/null +++ b/scripts/docker/install-sh-nonroot/run.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTALL_URL="${OPENCLAW_INSTALL_URL:-https://openclaw.bot/install.sh}" +DEFAULT_PACKAGE="openclaw" +PACKAGE_NAME="${OPENCLAW_INSTALL_PACKAGE:-$DEFAULT_PACKAGE}" + +echo "==> Pre-flight: ensure git absent" +if command -v git >/dev/null; then + echo "git is present unexpectedly" >&2 + exit 1 +fi + +echo "==> Run installer (non-root user)" +curl -fsSL "$INSTALL_URL" | bash + +# Ensure PATH picks up user npm prefix +export PATH="$HOME/.npm-global/bin:$PATH" + +echo "==> Verify git installed" +command -v git >/dev/null + +EXPECTED_VERSION="${OPENCLAW_INSTALL_EXPECT_VERSION:-}" +if [[ -n "$EXPECTED_VERSION" ]]; then + LATEST_VERSION="$EXPECTED_VERSION" +else + LATEST_VERSION="$(npm view "$PACKAGE_NAME" version)" +fi +CLI_NAME="$PACKAGE_NAME" +CMD_PATH="$(command -v "$CLI_NAME" || true)" +if [[ -z "$CMD_PATH" && -x "$HOME/.npm-global/bin/$PACKAGE_NAME" ]]; then + CLI_NAME="$PACKAGE_NAME" + CMD_PATH="$HOME/.npm-global/bin/$PACKAGE_NAME" +fi +if [[ -z "$CMD_PATH" ]]; then + echo "$PACKAGE_NAME is not on PATH" >&2 + exit 1 +fi +echo "==> Verify CLI installed: $CLI_NAME" +INSTALLED_VERSION="$("$CMD_PATH" --version 2>/dev/null | head -n 1 | tr -d '\r')" + +echo "cli=$CLI_NAME installed=$INSTALLED_VERSION expected=$LATEST_VERSION" +if [[ "$INSTALLED_VERSION" != "$LATEST_VERSION" ]]; then + echo "ERROR: expected ${CLI_NAME}@${LATEST_VERSION}, got ${CLI_NAME}@${INSTALLED_VERSION}" >&2 + exit 1 +fi + +echo "==> Sanity: CLI runs" +"$CMD_PATH" --help >/dev/null + +echo "OK" diff --git a/scripts/docker/install-sh-smoke/Dockerfile b/scripts/docker/install-sh-smoke/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..ee806c7b602d0ed8cff8857f5fde95b60ebe509c --- /dev/null +++ b/scripts/docker/install-sh-smoke/Dockerfile @@ -0,0 +1,21 @@ +FROM node:22-bookworm-slim + +RUN set -eux; \ + for attempt in 1 2 3; do \ + if apt-get update -o Acquire::Retries=3; then break; fi; \ + echo "apt-get update failed (attempt ${attempt})" >&2; \ + if [ "${attempt}" -eq 3 ]; then exit 1; fi; \ + sleep 3; \ + done; \ + apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + sudo \ + && rm -rf /var/lib/apt/lists/* + +COPY run.sh /usr/local/bin/openclaw-install-smoke +RUN chmod +x /usr/local/bin/openclaw-install-smoke + +ENTRYPOINT ["/usr/local/bin/openclaw-install-smoke"] diff --git a/scripts/docker/install-sh-smoke/run.sh b/scripts/docker/install-sh-smoke/run.sh new file mode 100644 index 0000000000000000000000000000000000000000..7b2cdd5c4823d9712bf456152fe53f499c537bf3 --- /dev/null +++ b/scripts/docker/install-sh-smoke/run.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTALL_URL="${OPENCLAW_INSTALL_URL:-https://openclaw.bot/install.sh}" +SMOKE_PREVIOUS_VERSION="${OPENCLAW_INSTALL_SMOKE_PREVIOUS:-}" +SKIP_PREVIOUS="${OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS:-0}" +DEFAULT_PACKAGE="openclaw" +PACKAGE_NAME="${OPENCLAW_INSTALL_PACKAGE:-$DEFAULT_PACKAGE}" + +echo "==> Resolve npm versions" +LATEST_VERSION="$(npm view "$PACKAGE_NAME" version)" +if [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then + PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION" +else + VERSIONS_JSON="$(npm view "$PACKAGE_NAME" versions --json)" + PREVIOUS_VERSION="$(VERSIONS_JSON="$VERSIONS_JSON" LATEST_VERSION="$LATEST_VERSION" node - <<'NODE' +const raw = process.env.VERSIONS_JSON || "[]"; +const latest = process.env.LATEST_VERSION || ""; +let versions; +try { + versions = JSON.parse(raw); +} catch { + versions = raw ? [raw] : []; +} +if (!Array.isArray(versions)) { + versions = [versions]; +} +if (versions.length === 0) { + process.exit(1); +} +const latestIndex = latest ? versions.lastIndexOf(latest) : -1; +if (latestIndex > 0) { + process.stdout.write(String(versions[latestIndex - 1])); + process.exit(0); +} +process.stdout.write(String(latest || versions[versions.length - 1])); +NODE +)" +fi + +echo "package=$PACKAGE_NAME latest=$LATEST_VERSION previous=$PREVIOUS_VERSION" + +if [[ "$SKIP_PREVIOUS" == "1" ]]; then + echo "==> Skip preinstall previous (OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1)" +else + echo "==> Preinstall previous (forces installer upgrade path)" + npm install -g "${PACKAGE_NAME}@${PREVIOUS_VERSION}" +fi + +echo "==> Run official installer one-liner" +curl -fsSL "$INSTALL_URL" | bash + +echo "==> Verify installed version" +CLI_NAME="$PACKAGE_NAME" +if ! command -v "$CLI_NAME" >/dev/null 2>&1; then + echo "ERROR: $PACKAGE_NAME is not on PATH" >&2 + exit 1 +fi +if [[ -n "${OPENCLAW_INSTALL_LATEST_OUT:-}" ]]; then + printf "%s" "$LATEST_VERSION" > "${OPENCLAW_INSTALL_LATEST_OUT:-}" +fi +INSTALLED_VERSION="$("$CLI_NAME" --version 2>/dev/null | head -n 1 | tr -d '\r')" +echo "cli=$CLI_NAME installed=$INSTALLED_VERSION expected=$LATEST_VERSION" + +if [[ "$INSTALLED_VERSION" != "$LATEST_VERSION" ]]; then + echo "ERROR: expected ${CLI_NAME}@${LATEST_VERSION}, got ${CLI_NAME}@${INSTALLED_VERSION}" >&2 + exit 1 +fi + +echo "==> Sanity: CLI runs" +"$CLI_NAME" --help >/dev/null + +echo "OK" diff --git a/scripts/docs-i18n/glossary.go b/scripts/docs-i18n/glossary.go new file mode 100644 index 0000000000000000000000000000000000000000..6341af56abdaffa726f2fc9f6480e777808cc334 --- /dev/null +++ b/scripts/docs-i18n/glossary.go @@ -0,0 +1,29 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" +) + +type GlossaryEntry struct { + Source string `json:"source"` + Target string `json:"target"` +} + +func LoadGlossary(path string) ([]GlossaryEntry, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + var entries []GlossaryEntry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, fmt.Errorf("glossary parse failed: %w", err) + } + + return entries, nil +} diff --git a/scripts/docs-i18n/go.mod b/scripts/docs-i18n/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..2c851087a48e73180caa15fd0a867c9154400363 --- /dev/null +++ b/scripts/docs-i18n/go.mod @@ -0,0 +1,10 @@ +module github.com/openclaw/openclaw/scripts/docs-i18n + +go 1.22 + +require ( + github.com/joshp123/pi-golang v0.0.4 + github.com/yuin/goldmark v1.7.8 + golang.org/x/net v0.24.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/scripts/docs-i18n/go.sum b/scripts/docs-i18n/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..7b57c1b3db379c9b330a6a12e8d9e79bd09e85ca --- /dev/null +++ b/scripts/docs-i18n/go.sum @@ -0,0 +1,10 @@ +github.com/joshp123/pi-golang v0.0.4 h1:82HISyKNN8bIl2lvAd65462LVCQIsjhaUFQxyQgg5Xk= +github.com/joshp123/pi-golang v0.0.4/go.mod h1:9mHEQkeJELYzubXU3b86/T8yedI/iAOKx0Tz0c41qes= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scripts/docs-i18n/html_translate.go b/scripts/docs-i18n/html_translate.go new file mode 100644 index 0000000000000000000000000000000000000000..ac10e7ccaa7ff1a36207e61ade9bceaea96d75f0 --- /dev/null +++ b/scripts/docs-i18n/html_translate.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "io" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/text" + "golang.org/x/net/html" + "sort" +) + +type htmlReplacement struct { + Start int + Stop int + Value string +} + +func translateHTMLBlocks(ctx context.Context, translator *PiTranslator, body, srcLang, tgtLang string) (string, error) { + source := []byte(body) + r := text.NewReader(source) + md := goldmark.New( + goldmark.WithExtensions(extension.GFM), + ) + doc := md.Parser().Parse(r) + + replacements := make([]htmlReplacement, 0, 8) + + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + block, ok := n.(*ast.HTMLBlock) + if !ok { + return ast.WalkContinue, nil + } + start, stop, ok := htmlBlockSpan(block, source) + if !ok { + return ast.WalkSkipChildren, nil + } + htmlText := string(source[start:stop]) + translated, err := translateHTMLBlock(ctx, translator, htmlText, srcLang, tgtLang) + if err != nil { + return ast.WalkStop, err + } + replacements = append(replacements, htmlReplacement{Start: start, Stop: stop, Value: translated}) + return ast.WalkSkipChildren, nil + }) + + if len(replacements) == 0 { + return body, nil + } + + return applyHTMLReplacements(body, replacements), nil +} + +func htmlBlockSpan(block *ast.HTMLBlock, source []byte) (int, int, bool) { + lines := block.Lines() + if lines.Len() == 0 { + return 0, 0, false + } + start := lines.At(0).Start + stop := lines.At(lines.Len() - 1).Stop + if start >= stop { + return 0, 0, false + } + return start, stop, true +} + +func applyHTMLReplacements(body string, replacements []htmlReplacement) string { + if len(replacements) == 0 { + return body + } + sortHTMLReplacements(replacements) + var out strings.Builder + last := 0 + for _, rep := range replacements { + if rep.Start < last { + continue + } + out.WriteString(body[last:rep.Start]) + out.WriteString(rep.Value) + last = rep.Stop + } + out.WriteString(body[last:]) + return out.String() +} + +func sortHTMLReplacements(replacements []htmlReplacement) { + sort.Slice(replacements, func(i, j int) bool { + return replacements[i].Start < replacements[j].Start + }) +} + +func translateHTMLBlock(ctx context.Context, translator *PiTranslator, htmlText, srcLang, tgtLang string) (string, error) { + tokenizer := html.NewTokenizer(strings.NewReader(htmlText)) + var out strings.Builder + skipDepth := 0 + + for { + tt := tokenizer.Next() + if tt == html.ErrorToken { + if err := tokenizer.Err(); err != nil && err != io.EOF { + return "", err + } + break + } + + raw := string(tokenizer.Raw()) + tok := tokenizer.Token() + + switch tt { + case html.StartTagToken: + out.WriteString(raw) + if isSkipTag(strings.ToLower(tok.Data)) { + skipDepth++ + } + case html.EndTagToken: + out.WriteString(raw) + if isSkipTag(strings.ToLower(tok.Data)) && skipDepth > 0 { + skipDepth-- + } + case html.SelfClosingTagToken: + out.WriteString(raw) + case html.TextToken: + if shouldTranslateHTMLText(skipDepth, raw) { + translated, err := translator.Translate(ctx, raw, srcLang, tgtLang) + if err != nil { + return "", err + } + out.WriteString(translated) + } else { + out.WriteString(raw) + } + default: + out.WriteString(raw) + } + } + + return out.String(), nil +} + +func shouldTranslateHTMLText(skipDepth int, text string) bool { + if strings.TrimSpace(text) == "" { + return false + } + return skipDepth == 0 +} + +func isSkipTag(tag string) bool { + switch tag { + case "code", "pre", "script", "style": + return true + default: + return false + } +} diff --git a/scripts/docs-i18n/main.go b/scripts/docs-i18n/main.go new file mode 100644 index 0000000000000000000000000000000000000000..bd0d6673c670209d70c2db9948b4197a655e406c --- /dev/null +++ b/scripts/docs-i18n/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "flag" + "fmt" + "path/filepath" +) + +func main() { + var ( + targetLang = flag.String("lang", "zh-CN", "target language (e.g., zh-CN)") + sourceLang = flag.String("src", "en", "source language") + docsRoot = flag.String("docs", "docs", "docs root") + tmPath = flag.String("tm", "", "translation memory path") + ) + flag.Parse() + files := flag.Args() + if len(files) == 0 { + fatal(fmt.Errorf("no doc files provided")) + } + + resolvedDocsRoot, err := filepath.Abs(*docsRoot) + if err != nil { + fatal(err) + } + + if *tmPath == "" { + *tmPath = filepath.Join(resolvedDocsRoot, ".i18n", fmt.Sprintf("%s.tm.jsonl", *targetLang)) + } + + glossaryPath := filepath.Join(resolvedDocsRoot, ".i18n", fmt.Sprintf("glossary.%s.json", *targetLang)) + glossary, err := LoadGlossary(glossaryPath) + if err != nil { + fatal(err) + } + + translator, err := NewPiTranslator(*sourceLang, *targetLang, glossary) + if err != nil { + fatal(err) + } + defer translator.Close() + + tm, err := LoadTranslationMemory(*tmPath) + if err != nil { + fatal(err) + } + + for _, file := range files { + if err := processFile(context.Background(), translator, tm, resolvedDocsRoot, file, *sourceLang, *targetLang); err != nil { + fatal(err) + } + } + + if err := tm.Save(); err != nil { + fatal(err) + } +} diff --git a/scripts/docs-i18n/markdown_segments.go b/scripts/docs-i18n/markdown_segments.go new file mode 100644 index 0000000000000000000000000000000000000000..5f77c54beb9e3d90b481987ee02448240adebfbf --- /dev/null +++ b/scripts/docs-i18n/markdown_segments.go @@ -0,0 +1,131 @@ +package main + +import ( + "sort" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/text" +) + +func extractSegments(body, relPath string) ([]Segment, error) { + source := []byte(body) + r := text.NewReader(source) + md := goldmark.New( + goldmark.WithExtensions(extension.GFM), + ) + doc := md.Parser().Parse(r) + + segments := make([]Segment, 0, 128) + skipDepth := 0 + var lastBlock ast.Node + + err := ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + switch n.(type) { + case *ast.CodeBlock, *ast.FencedCodeBlock, *ast.CodeSpan, *ast.HTMLBlock, *ast.RawHTML: + if entering { + skipDepth++ + } else { + skipDepth-- + } + return ast.WalkContinue, nil + } + + if !entering || skipDepth > 0 { + return ast.WalkContinue, nil + } + + textNode, ok := n.(*ast.Text) + if !ok { + return ast.WalkContinue, nil + } + block := blockParent(textNode) + if block == nil { + return ast.WalkContinue, nil + } + textValue := string(textNode.Segment.Value(source)) + if strings.TrimSpace(textValue) == "" { + return ast.WalkContinue, nil + } + + start := textNode.Segment.Start + stop := textNode.Segment.Stop + if len(segments) > 0 && lastBlock == block { + last := &segments[len(segments)-1] + gap := string(source[last.Stop:start]) + if strings.TrimSpace(gap) == "" { + last.Stop = stop + return ast.WalkContinue, nil + } + } + + segments = append(segments, Segment{Start: start, Stop: stop}) + lastBlock = block + return ast.WalkContinue, nil + }) + if err != nil { + return nil, err + } + + filtered := make([]Segment, 0, len(segments)) + for _, seg := range segments { + textValue := string(source[seg.Start:seg.Stop]) + trimmed := strings.TrimSpace(textValue) + if trimmed == "" { + continue + } + textHash := hashText(textValue) + segmentID := segmentID(relPath, textHash) + filtered = append(filtered, Segment{ + Start: seg.Start, + Stop: seg.Stop, + Text: textValue, + TextHash: textHash, + SegmentID: segmentID, + }) + } + + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].Start < filtered[j].Start + }) + + return filtered, nil +} + +func blockParent(n ast.Node) ast.Node { + for node := n.Parent(); node != nil; node = node.Parent() { + if isTranslatableBlock(node) { + return node + } + } + return nil +} + +func isTranslatableBlock(n ast.Node) bool { + switch n.(type) { + case *ast.Paragraph, *ast.Heading, *ast.ListItem: + return true + default: + return false + } +} + +func applyTranslations(body string, segments []Segment) string { + if len(segments) == 0 { + return body + } + var out strings.Builder + last := 0 + for _, seg := range segments { + if seg.Start < last { + continue + } + out.WriteString(body[last:seg.Start]) + out.WriteString(seg.Translated) + last = seg.Stop + } + out.WriteString(body[last:]) + return out.String() +} diff --git a/scripts/docs-i18n/masking.go b/scripts/docs-i18n/masking.go new file mode 100644 index 0000000000000000000000000000000000000000..978f185552b5d291fbc246b4036a0140e28dd328 --- /dev/null +++ b/scripts/docs-i18n/masking.go @@ -0,0 +1,89 @@ +package main + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + inlineCodeRe = regexp.MustCompile("`[^`]+`") + angleLinkRe = regexp.MustCompile(`]+>`) + linkURLRe = regexp.MustCompile(`\[[^\]]*\]\(([^)]+)\)`) + placeholderRe = regexp.MustCompile(`__OC_I18N_\d+__`) +) + +func maskMarkdown(text string, nextPlaceholder func() string, placeholders *[]string, mapping map[string]string) string { + masked := maskMatches(text, inlineCodeRe, nextPlaceholder, placeholders, mapping) + masked = maskMatches(masked, angleLinkRe, nextPlaceholder, placeholders, mapping) + masked = maskLinkURLs(masked, nextPlaceholder, placeholders, mapping) + return masked +} + +func maskMatches(text string, re *regexp.Regexp, nextPlaceholder func() string, placeholders *[]string, mapping map[string]string) string { + matches := re.FindAllStringIndex(text, -1) + if len(matches) == 0 { + return text + } + var out strings.Builder + pos := 0 + for _, span := range matches { + start, end := span[0], span[1] + if start < pos { + continue + } + out.WriteString(text[pos:start]) + placeholder := nextPlaceholder() + mapping[placeholder] = text[start:end] + *placeholders = append(*placeholders, placeholder) + out.WriteString(placeholder) + pos = end + } + out.WriteString(text[pos:]) + return out.String() +} + +func maskLinkURLs(text string, nextPlaceholder func() string, placeholders *[]string, mapping map[string]string) string { + matches := linkURLRe.FindAllStringSubmatchIndex(text, -1) + if len(matches) == 0 { + return text + } + var out strings.Builder + pos := 0 + for _, span := range matches { + fullStart := span[0] + urlStart, urlEnd := span[2], span[3] + if urlStart < 0 || urlEnd < 0 { + continue + } + if fullStart < pos { + continue + } + out.WriteString(text[pos:urlStart]) + placeholder := nextPlaceholder() + mapping[placeholder] = text[urlStart:urlEnd] + *placeholders = append(*placeholders, placeholder) + out.WriteString(placeholder) + pos = urlEnd + } + out.WriteString(text[pos:]) + return out.String() +} + +func unmaskMarkdown(text string, placeholders []string, mapping map[string]string) string { + out := text + for _, placeholder := range placeholders { + original := mapping[placeholder] + out = strings.ReplaceAll(out, placeholder, original) + } + return out +} + +func validatePlaceholders(text string, placeholders []string) error { + for _, placeholder := range placeholders { + if !strings.Contains(text, placeholder) { + return fmt.Errorf("placeholder missing: %s", placeholder) + } + } + return nil +} diff --git a/scripts/docs-i18n/placeholders.go b/scripts/docs-i18n/placeholders.go new file mode 100644 index 0000000000000000000000000000000000000000..c4f771192fc8a7cc7ed86702d12bf565580ced2c --- /dev/null +++ b/scripts/docs-i18n/placeholders.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" +) + +type PlaceholderState struct { + counter int + used map[string]struct{} +} + +func NewPlaceholderState(text string) *PlaceholderState { + used := map[string]struct{}{} + for _, hit := range placeholderRe.FindAllString(text, -1) { + used[hit] = struct{}{} + } + return &PlaceholderState{counter: 900000, used: used} +} + +func (s *PlaceholderState) Next() string { + for { + candidate := fmt.Sprintf("__OC_I18N_%d__", s.counter) + s.counter++ + if _, ok := s.used[candidate]; ok { + continue + } + s.used[candidate] = struct{}{} + return candidate + } +} diff --git a/scripts/docs-i18n/process.go b/scripts/docs-i18n/process.go new file mode 100644 index 0000000000000000000000000000000000000000..0d1e5fa5f9a0871a424ac009b0a6b5aa6bb6f7c3 --- /dev/null +++ b/scripts/docs-i18n/process.go @@ -0,0 +1,205 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +func processFile(ctx context.Context, translator *PiTranslator, tm *TranslationMemory, docsRoot, filePath, srcLang, tgtLang string) error { + absPath, err := filepath.Abs(filePath) + if err != nil { + return err + } + relPath, err := filepath.Rel(docsRoot, absPath) + if err != nil { + return err + } + if relPath == "." || relPath == "" { + return fmt.Errorf("file %s resolves to docs root %s", absPath, docsRoot) + } + if filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) { + return fmt.Errorf("file %s not under docs root %s", absPath, docsRoot) + } + + content, err := os.ReadFile(absPath) + if err != nil { + return err + } + + frontMatter, body := splitFrontMatter(string(content)) + frontData := map[string]any{} + if frontMatter != "" { + if err := yaml.Unmarshal([]byte(frontMatter), &frontData); err != nil { + return fmt.Errorf("frontmatter parse failed for %s: %w", relPath, err) + } + } + + if err := translateFrontMatter(ctx, translator, tm, frontData, relPath, srcLang, tgtLang); err != nil { + return err + } + + body, err = translateHTMLBlocks(ctx, translator, body, srcLang, tgtLang) + if err != nil { + return err + } + + segments, err := extractSegments(body, relPath) + if err != nil { + return err + } + + namespace := cacheNamespace() + for i := range segments { + seg := &segments[i] + seg.CacheKey = cacheKey(namespace, srcLang, tgtLang, seg.SegmentID, seg.TextHash) + if entry, ok := tm.Get(seg.CacheKey); ok { + seg.Translated = entry.Translated + continue + } + translated, err := translator.Translate(ctx, seg.Text, srcLang, tgtLang) + if err != nil { + return fmt.Errorf("translate failed (%s): %w", relPath, err) + } + seg.Translated = translated + entry := TMEntry{ + CacheKey: seg.CacheKey, + SegmentID: seg.SegmentID, + SourcePath: relPath, + TextHash: seg.TextHash, + Text: seg.Text, + Translated: translated, + Provider: providerName, + Model: modelVersion, + SrcLang: srcLang, + TgtLang: tgtLang, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } + tm.Put(entry) + } + + translatedBody := applyTranslations(body, segments) + updatedFront, err := encodeFrontMatter(frontData, relPath, content) + if err != nil { + return err + } + + outputPath := filepath.Join(docsRoot, tgtLang, relPath) + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return err + } + + output := updatedFront + translatedBody + return os.WriteFile(outputPath, []byte(output), 0o644) +} + +func splitFrontMatter(content string) (string, string) { + if !strings.HasPrefix(content, "---") { + return "", content + } + lines := strings.Split(content, "\n") + if len(lines) < 2 { + return "", content + } + endIndex := -1 + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "---" { + endIndex = i + break + } + } + if endIndex == -1 { + return "", content + } + front := strings.Join(lines[1:endIndex], "\n") + body := strings.Join(lines[endIndex+1:], "\n") + if strings.HasPrefix(body, "\n") { + body = body[1:] + } + return front, body +} + +func encodeFrontMatter(frontData map[string]any, relPath string, source []byte) (string, error) { + if len(frontData) == 0 { + return "", nil + } + frontData["x-i18n"] = map[string]any{ + "source_path": relPath, + "source_hash": hashBytes(source), + "provider": providerName, + "model": modelVersion, + "workflow": workflowVersion, + "generated_at": time.Now().UTC().Format(time.RFC3339), + } + encoded, err := yaml.Marshal(frontData) + if err != nil { + return "", err + } + return fmt.Sprintf("---\n%s---\n\n", string(encoded)), nil +} + +func translateFrontMatter(ctx context.Context, translator *PiTranslator, tm *TranslationMemory, data map[string]any, relPath, srcLang, tgtLang string) error { + if len(data) == 0 { + return nil + } + if summary, ok := data["summary"].(string); ok { + translated, err := translateSnippet(ctx, translator, tm, relPath+":frontmatter:summary", summary, srcLang, tgtLang) + if err != nil { + return err + } + data["summary"] = translated + } + if readWhen, ok := data["read_when"].([]any); ok { + translated := make([]any, 0, len(readWhen)) + for idx, item := range readWhen { + textValue, ok := item.(string) + if !ok { + translated = append(translated, item) + continue + } + value, err := translateSnippet(ctx, translator, tm, fmt.Sprintf("%s:frontmatter:read_when:%d", relPath, idx), textValue, srcLang, tgtLang) + if err != nil { + return err + } + translated = append(translated, value) + } + data["read_when"] = translated + } + return nil +} + +func translateSnippet(ctx context.Context, translator *PiTranslator, tm *TranslationMemory, segmentID, textValue, srcLang, tgtLang string) (string, error) { + if strings.TrimSpace(textValue) == "" { + return textValue, nil + } + namespace := cacheNamespace() + textHash := hashText(textValue) + ck := cacheKey(namespace, srcLang, tgtLang, segmentID, textHash) + if entry, ok := tm.Get(ck); ok { + return entry.Translated, nil + } + translated, err := translator.Translate(ctx, textValue, srcLang, tgtLang) + if err != nil { + return "", err + } + entry := TMEntry{ + CacheKey: ck, + SegmentID: segmentID, + SourcePath: segmentID, + TextHash: textHash, + Text: textValue, + Translated: translated, + Provider: providerName, + Model: modelVersion, + SrcLang: srcLang, + TgtLang: tgtLang, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } + tm.Put(entry) + return translated, nil +} diff --git a/scripts/docs-i18n/segment.go b/scripts/docs-i18n/segment.go new file mode 100644 index 0000000000000000000000000000000000000000..1e0a2d8e128f1076670372f44cdd5b9231367bc2 --- /dev/null +++ b/scripts/docs-i18n/segment.go @@ -0,0 +1,11 @@ +package main + +type Segment struct { + Start int + Stop int + Text string + TextHash string + SegmentID string + Translated string + CacheKey string +} diff --git a/scripts/docs-i18n/tm.go b/scripts/docs-i18n/tm.go new file mode 100644 index 0000000000000000000000000000000000000000..5f63ac127bd5651350a3a57108a3f7b5513ec8fa --- /dev/null +++ b/scripts/docs-i18n/tm.go @@ -0,0 +1,126 @@ +package main + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +type TMEntry struct { + CacheKey string `json:"cache_key"` + SegmentID string `json:"segment_id"` + SourcePath string `json:"source_path"` + TextHash string `json:"text_hash"` + Text string `json:"text"` + Translated string `json:"translated"` + Provider string `json:"provider"` + Model string `json:"model"` + SrcLang string `json:"src_lang"` + TgtLang string `json:"tgt_lang"` + UpdatedAt string `json:"updated_at"` +} + +type TranslationMemory struct { + path string + entries map[string]TMEntry +} + +func LoadTranslationMemory(path string) (*TranslationMemory, error) { + tm := &TranslationMemory{path: path, entries: map[string]TMEntry{}} + file, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return tm, nil + } + return nil, err + } + defer file.Close() + + reader := bufio.NewReader(file) + for { + line, err := reader.ReadBytes('\n') + if len(line) > 0 { + trimmed := strings.TrimSpace(string(line)) + if trimmed != "" { + var entry TMEntry + if err := json.Unmarshal([]byte(trimmed), &entry); err != nil { + return nil, fmt.Errorf("translation memory decode failed: %w", err) + } + if entry.CacheKey != "" { + tm.entries[entry.CacheKey] = entry + } + } + } + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, err + } + } + return tm, nil +} + +func (tm *TranslationMemory) Get(cacheKey string) (TMEntry, bool) { + entry, ok := tm.entries[cacheKey] + return entry, ok +} + +func (tm *TranslationMemory) Put(entry TMEntry) { + if entry.CacheKey == "" { + return + } + tm.entries[entry.CacheKey] = entry +} + +func (tm *TranslationMemory) Save() error { + if tm.path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(tm.path), 0o755); err != nil { + return err + } + tmpPath := tm.path + ".tmp" + file, err := os.Create(tmpPath) + if err != nil { + return err + } + + keys := make([]string, 0, len(tm.entries)) + for key := range tm.entries { + keys = append(keys, key) + } + sort.Strings(keys) + + writer := bufio.NewWriter(file) + for _, key := range keys { + entry := tm.entries[key] + payload, err := json.Marshal(entry) + if err != nil { + _ = file.Close() + return err + } + if _, err := writer.Write(payload); err != nil { + _ = file.Close() + return err + } + if _, err := writer.WriteString("\n"); err != nil { + _ = file.Close() + return err + } + } + if err := writer.Flush(); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(tmpPath, tm.path) +} diff --git a/scripts/docs-i18n/translator.go b/scripts/docs-i18n/translator.go new file mode 100644 index 0000000000000000000000000000000000000000..beb30092071e056fe4751093fe3d5d7e631dad80 --- /dev/null +++ b/scripts/docs-i18n/translator.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + + pi "github.com/joshp123/pi-golang" +) + +type PiTranslator struct { + client *pi.OneShotClient +} + +func NewPiTranslator(srcLang, tgtLang string, glossary []GlossaryEntry) (*PiTranslator, error) { + options := pi.DefaultOneShotOptions() + options.AppName = "openclaw-docs-i18n" + options.Mode = pi.ModeDragons + options.Dragons = pi.DragonsOptions{ + Provider: "anthropic", + Model: modelVersion, + Thinking: "high", + } + options.SystemPrompt = translationPrompt(srcLang, tgtLang, glossary) + client, err := pi.StartOneShot(options) + if err != nil { + return nil, err + } + return &PiTranslator{client: client}, nil +} + +func (t *PiTranslator) Translate(ctx context.Context, text, srcLang, tgtLang string) (string, error) { + if t.client == nil { + return "", errors.New("pi client unavailable") + } + prefix, core, suffix := splitWhitespace(text) + if core == "" { + return text, nil + } + state := NewPlaceholderState(core) + placeholders := make([]string, 0, 8) + mapping := map[string]string{} + masked := maskMarkdown(core, state.Next, &placeholders, mapping) + res, err := t.client.Run(ctx, masked) + if err != nil { + return "", err + } + translated := strings.TrimSpace(res.Text) + if err := validatePlaceholders(translated, placeholders); err != nil { + return "", err + } + translated = unmaskMarkdown(translated, placeholders, mapping) + return prefix + translated + suffix, nil +} + +func (t *PiTranslator) Close() { + if t.client != nil { + _ = t.client.Close() + } +} + +func translationPrompt(srcLang, tgtLang string, glossary []GlossaryEntry) string { + srcLabel := srcLang + tgtLabel := tgtLang + if strings.EqualFold(srcLang, "en") { + srcLabel = "English" + } + if strings.EqualFold(tgtLang, "zh-CN") { + tgtLabel = "Simplified Chinese" + } + glossaryBlock := buildGlossaryPrompt(glossary) + return strings.TrimSpace(fmt.Sprintf(`You are a translation function, not a chat assistant. +Translate from %s to %s. + +Rules: +- Output ONLY the translated text. No preamble, no questions, no commentary. +- Preserve Markdown syntax exactly (headings, lists, tables, emphasis). +- Do not translate code spans/blocks, config keys, CLI flags, or env vars. +- Do not alter URLs or anchors. +- Preserve placeholders exactly: __OC_I18N_####__. +- Use neutral technical Chinese; avoid slang or jokes. +- Keep product names in English: OpenClaw, Gateway, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal. + +%s + +If the input is empty, output empty. +If the input contains only placeholders, output it unchanged.`, srcLabel, tgtLabel, glossaryBlock)) +} + +func buildGlossaryPrompt(glossary []GlossaryEntry) string { + if len(glossary) == 0 { + return "" + } + var lines []string + lines = append(lines, "Preferred translations (use when natural):") + for _, entry := range glossary { + if entry.Source == "" || entry.Target == "" { + continue + } + lines = append(lines, fmt.Sprintf("- %s -> %s", entry.Source, entry.Target)) + } + return strings.Join(lines, "\n") +} diff --git a/scripts/docs-i18n/util.go b/scripts/docs-i18n/util.go new file mode 100644 index 0000000000000000000000000000000000000000..4b1453510a5fbaafe940fde3ef2dc76a47e3e747 --- /dev/null +++ b/scripts/docs-i18n/util.go @@ -0,0 +1,81 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "strings" +) + +const ( + workflowVersion = 9 + providerName = "pi" + modelVersion = "claude-opus-4-5" +) + +func cacheNamespace() string { + return fmt.Sprintf("wf=%d|provider=%s|model=%s", workflowVersion, providerName, modelVersion) +} + +func cacheKey(namespace, srcLang, tgtLang, segmentID, textHash string) string { + raw := fmt.Sprintf("%s|%s|%s|%s|%s", namespace, srcLang, tgtLang, segmentID, textHash) + hash := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(hash[:]) +} + +func hashText(text string) string { + normalized := normalizeText(text) + hash := sha256.Sum256([]byte(normalized)) + return hex.EncodeToString(hash[:]) +} + +func hashBytes(data []byte) string { + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]) +} + +func normalizeText(text string) string { + return strings.Join(strings.Fields(strings.TrimSpace(text)), " ") +} + +func segmentID(relPath, textHash string) string { + shortHash := textHash + if len(shortHash) > 16 { + shortHash = shortHash[:16] + } + return fmt.Sprintf("%s:%s", relPath, shortHash) +} + +func splitWhitespace(text string) (string, string, string) { + if text == "" { + return "", "", "" + } + start := 0 + for start < len(text) && isWhitespace(text[start]) { + start++ + } + end := len(text) + for end > start && isWhitespace(text[end-1]) { + end-- + } + return text[:start], text[start:end], text[end:] +} + +func isWhitespace(b byte) bool { + switch b { + case ' ', '\t', '\n', '\r': + return true + default: + return false + } +} + +func fatal(err error) { + if err == nil { + return + } + _, _ = io.WriteString(os.Stderr, err.Error()+"\n") + os.Exit(1) +} diff --git a/scripts/docs-list.js b/scripts/docs-list.js new file mode 100644 index 0000000000000000000000000000000000000000..f6f58cad593f5387fef491d2e2b3f8c10aebe6ed --- /dev/null +++ b/scripts/docs-list.js @@ -0,0 +1,173 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +process.stdout.on("error", (error) => { + if (error?.code === "EPIPE") { + process.exit(0); + } + throw error; +}); + +const DOCS_DIR = join(process.cwd(), "docs"); +if (!existsSync(DOCS_DIR)) { + console.error("docs:list: missing docs directory. Run from repo root."); + process.exit(1); +} +if (!statSync(DOCS_DIR).isDirectory()) { + console.error("docs:list: docs path is not a directory."); + process.exit(1); +} + +const EXCLUDED_DIRS = new Set(["archive", "research"]); + +/** + * @param {unknown[]} values + * @returns {string[]} + */ +function compactStrings(values) { + const result = []; + for (const value of values) { + if (value === null || value === undefined) { + continue; + } + const normalized = + typeof value === "string" + ? value.trim() + : typeof value === "number" || typeof value === "boolean" + ? String(value).trim() + : null; + + if (normalized?.length > 0) { + result.push(normalized); + } + } + return result; +} + +/** + * @param {string} dir + * @param {string} base + * @returns {string[]} + */ +function walkMarkdownFiles(dir, base = dir) { + const entries = readdirSync(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (entry.name.startsWith(".")) { + continue; + } + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (EXCLUDED_DIRS.has(entry.name)) { + continue; + } + files.push(...walkMarkdownFiles(fullPath, base)); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + files.push(relative(base, fullPath)); + } + } + return files.toSorted((a, b) => a.localeCompare(b)); +} + +/** + * @param {string} fullPath + * @returns {{ summary: string | null; readWhen: string[]; error?: string }} + */ +function extractMetadata(fullPath) { + const content = readFileSync(fullPath, "utf8"); + + if (!content.startsWith("---")) { + return { summary: null, readWhen: [], error: "missing front matter" }; + } + + const endIndex = content.indexOf("\n---", 3); + if (endIndex === -1) { + return { summary: null, readWhen: [], error: "unterminated front matter" }; + } + + const frontMatter = content.slice(3, endIndex).trim(); + const lines = frontMatter.split("\n"); + + let summaryLine = null; + const readWhen = []; + let collectingField = null; + + for (const rawLine of lines) { + const line = rawLine.trim(); + + if (line.startsWith("summary:")) { + summaryLine = line; + collectingField = null; + continue; + } + + if (line.startsWith("read_when:")) { + collectingField = "read_when"; + const inline = line.slice("read_when:".length).trim(); + if (inline.startsWith("[") && inline.endsWith("]")) { + try { + const parsed = JSON.parse(inline.replace(/'/g, '"')); + if (Array.isArray(parsed)) { + readWhen.push(...compactStrings(parsed)); + } + } catch { + // ignore malformed inline arrays + } + } + continue; + } + + if (collectingField === "read_when") { + if (line.startsWith("- ")) { + const hint = line.slice(2).trim(); + if (hint) { + readWhen.push(hint); + } + } else if (line === "") { + // allow blank lines inside the list + } else { + collectingField = null; + } + } + } + + if (!summaryLine) { + return { summary: null, readWhen, error: "summary key missing" }; + } + + const summaryValue = summaryLine.slice("summary:".length).trim(); + const normalized = summaryValue + .replace(/^['"]|['"]$/g, "") + .replace(/\s+/g, " ") + .trim(); + + if (!normalized) { + return { summary: null, readWhen, error: "summary is empty" }; + } + + return { summary: normalized, readWhen }; +} + +console.log("Listing all markdown files in docs folder:"); + +const markdownFiles = walkMarkdownFiles(DOCS_DIR); + +for (const relativePath of markdownFiles) { + const fullPath = join(DOCS_DIR, relativePath); + const { summary, readWhen, error } = extractMetadata(fullPath); + if (summary) { + console.log(`${relativePath} - ${summary}`); + if (readWhen.length > 0) { + console.log(` Read when: ${readWhen.join("; ")}`); + } + } else { + const reason = error ? ` - [${error}]` : ""; + console.log(`${relativePath}${reason}`); + } +} + +console.log( + '\nReminder: keep docs up to date as behavior changes. When your task matches any "Read when" hint above (React hooks, cache directives, database work, tests, etc.), read that doc before coding, and suggest new coverage when it is missing.', +); diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..0ae4a50638a50cddbbaba2effa2e1b5fb78054fc --- /dev/null +++ b/scripts/e2e/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-bookworm + +RUN corepack enable + +WORKDIR /app + +ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning" + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts vitest.e2e.config.ts ./ +COPY src ./src +COPY test ./test +COPY scripts ./scripts +COPY docs ./docs +COPY skills ./skills +COPY patches ./patches +COPY ui ./ui +COPY extensions/memory-core ./extensions/memory-core + +RUN pnpm install --frozen-lockfile +RUN pnpm build +RUN pnpm ui:build + +CMD ["bash"] diff --git a/scripts/e2e/Dockerfile.qr-import b/scripts/e2e/Dockerfile.qr-import new file mode 100644 index 0000000000000000000000000000000000000000..c2370044db44adb7dc9b817f5fb5f81e843f49d3 --- /dev/null +++ b/scripts/e2e/Dockerfile.qr-import @@ -0,0 +1,9 @@ +FROM node:22-bookworm + +RUN corepack enable + +WORKDIR /app + +COPY . . + +RUN pnpm install --frozen-lockfile diff --git a/scripts/e2e/doctor-install-switch-docker.sh b/scripts/e2e/doctor-install-switch-docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..2a0b0c4f4ab8e6929a156c3beb1a8371a9dbdcde --- /dev/null +++ b/scripts/e2e/doctor-install-switch-docker.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE_NAME="openclaw-doctor-install-switch-e2e" + +echo "Building Docker image..." +docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" + +echo "Running doctor install switch E2E..." +docker run --rm -t "$IMAGE_NAME" bash -lc ' + set -euo pipefail + + # Keep logs focused; the npm global install step can emit noisy deprecation warnings. + export npm_config_loglevel=error + export npm_config_fund=false + export npm_config_audit=false + + # Stub systemd/loginctl so doctor + daemon flows work in Docker. + export PATH="/tmp/openclaw-bin:$PATH" + mkdir -p /tmp/openclaw-bin + + cat > /tmp/openclaw-bin/systemctl <<"SYSTEMCTL" +#!/usr/bin/env bash +set -euo pipefail + +args=("$@") +if [[ "${args[0]:-}" == "--user" ]]; then + args=("${args[@]:1}") +fi +cmd="${args[0]:-}" +case "$cmd" in + status) + exit 0 + ;; + is-enabled) + unit="${args[1]:-}" + unit_path="$HOME/.config/systemd/user/${unit}" + if [ -f "$unit_path" ]; then + exit 0 + fi + exit 1 + ;; + show) + echo "ActiveState=inactive" + echo "SubState=dead" + echo "MainPID=0" + echo "ExecMainStatus=0" + echo "ExecMainCode=0" + exit 0 + ;; + *) + exit 0 + ;; +esac +SYSTEMCTL + chmod +x /tmp/openclaw-bin/systemctl + + cat > /tmp/openclaw-bin/loginctl <<"LOGINCTL" +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$*" == *"show-user"* ]]; then + echo "Linger=yes" + exit 0 +fi +if [[ "$*" == *"enable-linger"* ]]; then + exit 0 +fi +exit 0 +LOGINCTL + chmod +x /tmp/openclaw-bin/loginctl + + # Install the npm-global variant from the local /app source. + # `npm pack` can emit script output; keep only the tarball name. + pkg_tgz="$(npm pack --silent /app | tail -n 1 | tr -d '\r')" + if [ ! -f "/app/$pkg_tgz" ]; then + echo "npm pack failed (expected /app/$pkg_tgz)" + exit 1 + fi + npm install -g --prefix /tmp/npm-prefix "/app/$pkg_tgz" + + npm_bin="/tmp/npm-prefix/bin/openclaw" + npm_entry="/tmp/npm-prefix/lib/node_modules/openclaw/openclaw.mjs" + git_entry="/app/openclaw.mjs" + + assert_entrypoint() { + local unit_path="$1" + local expected="$2" + local exec_line="" + exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true) + if [ -z "$exec_line" ]; then + echo "Missing ExecStart in $unit_path" + exit 1 + fi + exec_line="${exec_line#ExecStart=}" + entrypoint=$(echo "$exec_line" | awk "{print \$2}") + entrypoint="${entrypoint%\"}" + entrypoint="${entrypoint#\"}" + if [ "$entrypoint" != "$expected" ]; then + echo "Expected entrypoint $expected, got $entrypoint" + exit 1 + fi + } + + # Each flow: install service with one variant, run doctor from the other, + # and verify ExecStart entrypoint switches accordingly. + run_flow() { + local name="$1" + local install_cmd="$2" + local install_expected="$3" + local doctor_cmd="$4" + local doctor_expected="$5" + + echo "== Flow: $name ==" + home_dir=$(mktemp -d "/tmp/openclaw-switch-${name}.XXXXXX") + export HOME="$home_dir" + export USER="testuser" + + eval "$install_cmd" + + unit_path="$HOME/.config/systemd/user/openclaw-gateway.service" + if [ ! -f "$unit_path" ]; then + echo "Missing unit file: $unit_path" + exit 1 + fi + assert_entrypoint "$unit_path" "$install_expected" + + eval "$doctor_cmd" + + assert_entrypoint "$unit_path" "$doctor_expected" + } + + run_flow \ + "npm-to-git" \ + "$npm_bin daemon install --force" \ + "$npm_entry" \ + "node $git_entry doctor --repair --force" \ + "$git_entry" + + run_flow \ + "git-to-npm" \ + "node $git_entry daemon install --force" \ + "$git_entry" \ + "$npm_bin doctor --repair --force" \ + "$npm_entry" +' diff --git a/scripts/e2e/gateway-network-docker.sh b/scripts/e2e/gateway-network-docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..e0faf0115e6c6da9db17b397d899495853d72738 --- /dev/null +++ b/scripts/e2e/gateway-network-docker.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE_NAME="openclaw-gateway-network-e2e" + +PORT="18789" +TOKEN="e2e-$(date +%s)-$$" +NET_NAME="openclaw-net-e2e-$$" +GW_NAME="openclaw-gateway-e2e-$$" + +cleanup() { + docker rm -f "$GW_NAME" >/dev/null 2>&1 || true + docker network rm "$NET_NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "Building Docker image..." +docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" + +echo "Creating Docker network..." +docker network create "$NET_NAME" >/dev/null + +echo "Starting gateway container..." + docker run --rm -d \ + --name "$GW_NAME" \ + --network "$NET_NAME" \ + -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ + -e "OPENCLAW_SKIP_CHANNELS=1" \ + -e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \ + -e "OPENCLAW_SKIP_CRON=1" \ + -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ + "$IMAGE_NAME" \ + bash -lc "node dist/index.js gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1" + +echo "Waiting for gateway to come up..." +for _ in $(seq 1 20); do + if docker exec "$GW_NAME" bash -lc "grep -q \"listening on ws://\" /tmp/gateway-net-e2e.log"; then + break + fi + sleep 0.5 +done + +docker exec "$GW_NAME" bash -lc "tail -n 50 /tmp/gateway-net-e2e.log" + +echo "Running client container (connect + health)..." +docker run --rm \ + --network "$NET_NAME" \ + -e "GW_URL=ws://$GW_NAME:$PORT" \ + -e "GW_TOKEN=$TOKEN" \ + "$IMAGE_NAME" \ + bash -lc "node - <<'NODE' +import { WebSocket } from \"ws\"; +import { PROTOCOL_VERSION } from \"./dist/gateway/protocol/index.js\"; + +const url = process.env.GW_URL; +const token = process.env.GW_TOKEN; +if (!url || !token) throw new Error(\"missing GW_URL/GW_TOKEN\"); + +const ws = new WebSocket(url); +await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(\"ws open timeout\")), 5000); + ws.once(\"open\", () => { + clearTimeout(t); + resolve(); + }); +}); + +function onceFrame(filter, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(\"timeout\")), timeoutMs); + const handler = (data) => { + const obj = JSON.parse(String(data)); + if (!filter(obj)) return; + clearTimeout(t); + ws.off(\"message\", handler); + resolve(obj); + }; + ws.on(\"message\", handler); + }); +} + +ws.send( + JSON.stringify({ + type: \"req\", + id: \"c1\", + method: \"connect\", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: \"test\", + displayName: \"docker-net-e2e\", + version: \"dev\", + platform: process.platform, + mode: \"test\", + }, + caps: [], + auth: { token }, + }, + }), + ); + const connectRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"c1\"); + if (!connectRes.ok) throw new Error(\"connect failed: \" + (connectRes.error?.message ?? \"unknown\")); + + ws.send(JSON.stringify({ type: \"req\", id: \"h1\", method: \"health\" })); + const healthRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"h1\", 10000); + if (!healthRes.ok) throw new Error(\"health failed: \" + (healthRes.error?.message ?? \"unknown\")); + if (healthRes.payload?.ok !== true) throw new Error(\"unexpected health payload\"); + + ws.close(); + console.log(\"ok\"); +NODE" + +echo "OK" diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..6e9a74ced643bfd65cd48a6763d550e35ca34d41 --- /dev/null +++ b/scripts/e2e/onboard-docker.sh @@ -0,0 +1,545 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE_NAME="openclaw-onboard-e2e" + +echo "Building Docker image..." +docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" + +echo "Running onboarding E2E..." +docker run --rm -t "$IMAGE_NAME" bash -lc ' + set -euo pipefail + trap "" PIPE + export TERM=xterm-256color + ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui" + + # Provide a minimal trash shim to avoid noisy "missing trash" logs in containers. + export PATH="/tmp/openclaw-bin:$PATH" + mkdir -p /tmp/openclaw-bin + cat > /tmp/openclaw-bin/trash <<'"'"'TRASH'"'"' +#!/usr/bin/env bash +set -euo pipefail +trash_dir="$HOME/.Trash" +mkdir -p "$trash_dir" +for target in "$@"; do + [ -e "$target" ] || continue + base="$(basename "$target")" + dest="$trash_dir/$base" + if [ -e "$dest" ]; then + dest="$trash_dir/${base}-$(date +%s)-$$" + fi + mv "$target" "$dest" +done +TRASH + chmod +x /tmp/openclaw-bin/trash + + send() { + local payload="$1" + local delay="${2:-0.4}" + # Let prompts render before sending keystrokes. + sleep "$delay" + printf "%b" "$payload" >&3 2>/dev/null || true + } + + wait_for_log() { + local needle="$1" + local timeout_s="${2:-45}" + local needle_compact + needle_compact="$(printf "%s" "$needle" | tr -cd "[:alnum:]")" + local start_s + start_s="$(date +%s)" + while true; do + if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then + if grep -a -F -q "$needle" "$WIZARD_LOG_PATH"; then + return 0 + fi + if NEEDLE=\"$needle_compact\" node --input-type=module -e " + import fs from \"node:fs\"; + const file = process.env.WIZARD_LOG_PATH; + const needle = process.env.NEEDLE ?? \"\"; + let text = \"\"; + try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } + if (text.length > 20000) text = text.slice(-20000); + const stripAnsi = (value) => value.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\"); + const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z0-9]+/g, \"\"); + const haystack = compact(text); + const compactNeedle = compact(needle); + if (!compactNeedle) process.exit(1); + process.exit(haystack.includes(compactNeedle) ? 0 : 1); + "; then + return 0 + fi + fi + if [ $(( $(date +%s) - start_s )) -ge "$timeout_s" ]; then + echo "Timeout waiting for log: $needle" + if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then + tail -n 140 "$WIZARD_LOG_PATH" || true + fi + return 1 + fi + sleep 0.2 + done + } + + start_gateway() { + node dist/index.js gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 & + GATEWAY_PID="$!" + } + + wait_for_gateway() { + for _ in $(seq 1 20); do + if node --input-type=module -e " + import net from 'node:net'; + const socket = net.createConnection({ host: '127.0.0.1', port: 18789 }); + const timeout = setTimeout(() => { + socket.destroy(); + process.exit(1); + }, 500); + socket.on('connect', () => { + clearTimeout(timeout); + socket.end(); + process.exit(0); + }); + socket.on('error', () => { + clearTimeout(timeout); + process.exit(1); + }); + " >/dev/null 2>&1; then + return 0 + fi + if [ -f /tmp/gateway-e2e.log ] && grep -E -q "listening on ws://[^ ]+:18789" /tmp/gateway-e2e.log; then + if [ -n "${GATEWAY_PID:-}" ] && kill -0 "$GATEWAY_PID" 2>/dev/null; then + return 0 + fi + fi + sleep 1 + done + echo "Gateway failed to start" + cat /tmp/gateway-e2e.log || true + return 1 + } + + stop_gateway() { + local gw_pid="$1" + if [ -n "$gw_pid" ]; then + kill "$gw_pid" 2>/dev/null || true + wait "$gw_pid" || true + fi + } + + run_wizard_cmd() { + local case_name="$1" + local home_dir="$2" + local command="$3" + local send_fn="$4" + local with_gateway="${5:-false}" + local validate_fn="${6:-}" + + echo "== Wizard case: $case_name ==" + export HOME="$home_dir" + mkdir -p "$HOME" + + input_fifo="$(mktemp -u "/tmp/openclaw-onboard-${case_name}.XXXXXX")" + mkfifo "$input_fifo" + local log_path="/tmp/openclaw-onboard-${case_name}.log" + WIZARD_LOG_PATH="$log_path" + export WIZARD_LOG_PATH + # Run under script to keep an interactive TTY for clack prompts. + script -q -f -c "$command" "$log_path" < "$input_fifo" & + wizard_pid=$! + exec 3> "$input_fifo" + + local gw_pid="" + if [ "$with_gateway" = "true" ]; then + start_gateway + gw_pid="$GATEWAY_PID" + wait_for_gateway + fi + + "$send_fn" + + if ! wait "$wizard_pid"; then + wizard_status=$? + exec 3>&- + rm -f "$input_fifo" + stop_gateway "$gw_pid" + echo "Wizard exited with status $wizard_status" + if [ -f "$log_path" ]; then + tail -n 160 "$log_path" || true + fi + exit "$wizard_status" + fi + exec 3>&- + rm -f "$input_fifo" + stop_gateway "$gw_pid" + if [ -n "$validate_fn" ]; then + "$validate_fn" "$log_path" + fi + } + + run_wizard() { + local case_name="$1" + local home_dir="$2" + local send_fn="$3" + local validate_fn="${4:-}" + + # Default onboarding command wrapper. + run_wizard_cmd "$case_name" "$home_dir" "node dist/index.js onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn" + } + + make_home() { + mktemp -d "/tmp/openclaw-e2e-$1.XXXXXX" + } + + assert_file() { + local file_path="$1" + if [ ! -f "$file_path" ]; then + echo "Missing file: $file_path" + exit 1 + fi + } + + assert_dir() { + local dir_path="$1" + if [ ! -d "$dir_path" ]; then + echo "Missing dir: $dir_path" + exit 1 + fi + } + + select_skip_hooks() { + # Hooks multiselect: pick "Skip for now". + wait_for_log "Enable hooks?" 60 || true + send $'"'"' \r'"'"' 0.6 + } + + send_local_basic() { + # Risk acknowledgement (default is "No"). + wait_for_log "Continue?" 60 + send $'"'"'y\r'"'"' 0.6 + # Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI. + if wait_for_log "Where will the Gateway run?" 20; then + send $'"'"'\r'"'"' 0.5 + fi + select_skip_hooks + } + + send_reset_config_only() { + # Risk acknowledgement (default is "No"). + wait_for_log "Continue?" 40 || true + send $'"'"'y\r'"'"' 0.8 + # Select reset flow for existing config. + wait_for_log "Config handling" 40 || true + send $'"'"'\e[B'"'"' 0.3 + send $'"'"'\e[B'"'"' 0.3 + send $'"'"'\r'"'"' 0.4 + # Reset scope -> Config only (default). + wait_for_log "Reset scope" 40 || true + send $'"'"'\r'"'"' 0.4 + select_skip_hooks + } + + send_channels_flow() { + # Configure channels via configure wizard. + # Prompts are interactive; notes are not. Use conservative delays to stay in sync. + # Where will the Gateway run? -> Local (default) + send $'"'"'\r'"'"' 1.2 + # Channels mode -> Configure/link (default) + send $'"'"'\r'"'"' 1.5 + # Select a channel -> Finished (last option; clack wraps on Up) + send $'"'"'\e[A\r'"'"' 2.0 + # Keep stdin open until wizard exits. + send "" 2.5 + } + + send_skills_flow() { + # Select skills section and skip optional installs. + wait_for_log "Where will the Gateway run?" 60 || true + send $'"'"'\r'"'"' 0.6 + # Configure skills now? -> No + wait_for_log "Configure skills now?" 60 || true + send $'"'"'n\r'"'"' 0.8 + send "" 1.0 + } + + run_case_local_basic() { + local home_dir + home_dir="$(make_home local-basic)" + export HOME="$home_dir" + mkdir -p "$HOME" + node dist/index.js onboard \ + --non-interactive \ + --accept-risk \ + --flow quickstart \ + --mode local \ + --skip-channels \ + --skip-skills \ + --skip-daemon \ + --skip-ui \ + --skip-health + + # Assert config + workspace scaffolding. + workspace_dir="$HOME/openclaw" + config_path="$HOME/.openclaw/openclaw.json" + sessions_dir="$HOME/.openclaw/agents/main/sessions" + + assert_file "$config_path" + assert_dir "$sessions_dir" + for file in AGENTS.md BOOTSTRAP.md IDENTITY.md SOUL.md TOOLS.md USER.md; do + assert_file "$workspace_dir/$file" + done + + CONFIG_PATH="$config_path" WORKSPACE_DIR="$workspace_dir" node --input-type=module - <<'"'"'NODE'"'"' +import fs from "node:fs"; +import JSON5 from "json5"; + +const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8")); +const expectedWorkspace = process.env.WORKSPACE_DIR; +const errors = []; + +if (cfg?.agents?.defaults?.workspace !== expectedWorkspace) { + errors.push( + `agents.defaults.workspace mismatch (got ${cfg?.agents?.defaults?.workspace ?? "unset"})`, + ); +} +if (cfg?.gateway?.mode !== "local") { + errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`); +} +if (cfg?.gateway?.bind !== "loopback") { + errors.push(`gateway.bind mismatch (got ${cfg?.gateway?.bind ?? "unset"})`); +} +if ((cfg?.gateway?.tailscale?.mode ?? "off") !== "off") { + errors.push( + `gateway.tailscale.mode mismatch (got ${cfg?.gateway?.tailscale?.mode ?? "unset"})`, + ); +} +if (!cfg?.wizard?.lastRunAt) { + errors.push("wizard.lastRunAt missing"); +} +if (!cfg?.wizard?.lastRunVersion) { + errors.push("wizard.lastRunVersion missing"); +} +if (cfg?.wizard?.lastRunCommand !== "onboard") { + errors.push( + `wizard.lastRunCommand mismatch (got ${cfg?.wizard?.lastRunCommand ?? "unset"})`, + ); +} +if (cfg?.wizard?.lastRunMode !== "local") { + errors.push( + `wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`, + ); +} + +if (errors.length > 0) { + console.error(errors.join("\n")); + process.exit(1); +} +NODE + + } + + run_case_remote_non_interactive() { + local home_dir + home_dir="$(make_home remote-non-interactive)" + export HOME="$home_dir" + mkdir -p "$HOME" + # Smoke test non-interactive remote config write. + node dist/index.js onboard --non-interactive --accept-risk \ + --mode remote \ + --remote-url ws://gateway.local:18789 \ + --remote-token remote-token \ + --skip-skills \ + --skip-health + + config_path="$HOME/.openclaw/openclaw.json" + assert_file "$config_path" + + CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"' +import fs from "node:fs"; +import JSON5 from "json5"; + +const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8")); +const errors = []; + +if (cfg?.gateway?.mode !== "remote") { + errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`); +} +if (cfg?.gateway?.remote?.url !== "ws://gateway.local:18789") { + errors.push(`gateway.remote.url mismatch (got ${cfg?.gateway?.remote?.url ?? "unset"})`); +} +if (cfg?.gateway?.remote?.token !== "remote-token") { + errors.push(`gateway.remote.token mismatch (got ${cfg?.gateway?.remote?.token ?? "unset"})`); +} +if (cfg?.wizard?.lastRunMode !== "remote") { + errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`); +} + +if (errors.length > 0) { + console.error(errors.join("\n")); + process.exit(1); +} +NODE + } + + run_case_reset() { + local home_dir + home_dir="$(make_home reset-config)" + export HOME="$home_dir" + mkdir -p "$HOME/.openclaw" + # Seed a remote config to exercise reset path. + cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"' +{ + "agents": { "defaults": { "workspace": "/root/old" } }, + "gateway": { + "mode": "remote", + "remote": { "url": "ws://old.example:18789", "token": "old-token" } + } +} +JSON + + node dist/index.js onboard \ + --non-interactive \ + --accept-risk \ + --flow quickstart \ + --mode local \ + --reset \ + --skip-channels \ + --skip-skills \ + --skip-daemon \ + --skip-ui \ + --skip-health + + config_path="$HOME/.openclaw/openclaw.json" + assert_file "$config_path" + + CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"' +import fs from "node:fs"; +import JSON5 from "json5"; + +const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8")); +const errors = []; + +if (cfg?.gateway?.mode !== "local") { + errors.push(`gateway.mode mismatch (got ${cfg?.gateway?.mode ?? "unset"})`); +} +if (cfg?.gateway?.remote?.url) { + errors.push(`gateway.remote.url should be cleared (got ${cfg?.gateway?.remote?.url})`); +} +if (cfg?.wizard?.lastRunMode !== "local") { + errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`); +} + +if (errors.length > 0) { + console.error(errors.join("\n")); + process.exit(1); +} +NODE + } + + run_case_channels() { + local home_dir + home_dir="$(make_home channels)" + # Channels-only configure flow. + run_wizard_cmd channels "$home_dir" "node dist/index.js configure --section channels" send_channels_flow + + config_path="$HOME/.openclaw/openclaw.json" + assert_file "$config_path" + + CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"' +import fs from "node:fs"; +import JSON5 from "json5"; + +const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8")); +const errors = []; + + if (cfg?.telegram?.botToken) { + errors.push(`telegram.botToken should be unset (got ${cfg?.telegram?.botToken})`); + } + if (cfg?.discord?.token) { + errors.push(`discord.token should be unset (got ${cfg?.discord?.token})`); + } + if (cfg?.slack?.botToken || cfg?.slack?.appToken) { + errors.push( + `slack tokens should be unset (got bot=${cfg?.slack?.botToken ?? "unset"}, app=${cfg?.slack?.appToken ?? "unset"})`, + ); + } + if (cfg?.wizard?.lastRunCommand !== "configure") { + errors.push( + `wizard.lastRunCommand mismatch (got ${cfg?.wizard?.lastRunCommand ?? "unset"})`, + ); + } + +if (errors.length > 0) { + console.error(errors.join("\n")); + process.exit(1); +} +NODE + } + + run_case_skills() { + local home_dir + home_dir="$(make_home skills)" + export HOME="$home_dir" + mkdir -p "$HOME/.openclaw" + # Seed skills config to ensure it survives the wizard. + cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"' +{ + "skills": { + "allowBundled": ["__none__"], + "install": { "nodeManager": "bun" } + } +} +JSON + + run_wizard_cmd skills "$home_dir" "node dist/index.js configure --section skills" send_skills_flow + + config_path="$HOME/.openclaw/openclaw.json" + assert_file "$config_path" + + CONFIG_PATH="$config_path" node --input-type=module - <<'"'"'NODE'"'"' +import fs from "node:fs"; +import JSON5 from "json5"; + +const cfg = JSON5.parse(fs.readFileSync(process.env.CONFIG_PATH, "utf-8")); +const errors = []; + +if (cfg?.skills?.install?.nodeManager !== "bun") { + errors.push(`skills.install.nodeManager mismatch (got ${cfg?.skills?.install?.nodeManager ?? "unset"})`); +} +if (!Array.isArray(cfg?.skills?.allowBundled) || cfg.skills.allowBundled[0] !== "__none__") { + errors.push("skills.allowBundled missing"); +} +if (cfg?.wizard?.lastRunMode !== "local") { + errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`); +} + +if (errors.length > 0) { + console.error(errors.join("\n")); + process.exit(1); +} +NODE + } + + assert_log_not_contains() { + local file_path="$1" + local needle="$2" + if grep -q "$needle" "$file_path"; then + echo "Unexpected log output: $needle" + exit 1 + fi + } + + validate_local_basic_log() { + local log_path="$1" + assert_log_not_contains "$log_path" "systemctl --user unavailable" + } + + run_case_local_basic + run_case_remote_non_interactive + run_case_reset + run_case_channels + run_case_skills +' + +echo "E2E complete." diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..5b0a9c5ae470b559eb286750edf6172543c054d6 --- /dev/null +++ b/scripts/e2e/plugins-docker.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE_NAME="openclaw-plugins-e2e" + +echo "Building Docker image..." +docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" + +echo "Running plugins Docker E2E..." +docker run --rm -t "$IMAGE_NAME" bash -lc ' + set -euo pipefail + + home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX") + export HOME="$home_dir" + mkdir -p "$HOME/.openclaw/extensions" + + cat > "$HOME/.openclaw/extensions/demo-plugin.js" <<'"'"'JS'"'"' +module.exports = { + id: "demo-plugin", + name: "Demo Plugin", + description: "Docker E2E demo plugin", + register(api) { + api.registerTool(() => null, { name: "demo_tool" }); + api.registerGatewayMethod("demo.ping", async () => ({ ok: true })); + api.registerCli(() => {}, { commands: ["demo"] }); + api.registerService({ id: "demo-service", start: () => {} }); + }, +}; +JS + + node dist/index.js plugins list --json > /tmp/plugins.json + + node - <<'"'"'NODE'"'"' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin"); +if (!plugin) throw new Error("plugin not found"); +if (plugin.status !== "loaded") { + throw new Error(`unexpected status: ${plugin.status}`); +} + +const assertIncludes = (list, value, label) => { + if (!Array.isArray(list) || !list.includes(value)) { + throw new Error(`${label} missing: ${value}`); + } +}; + +assertIncludes(plugin.toolNames, "demo_tool", "tool"); +assertIncludes(plugin.gatewayMethods, "demo.ping", "gateway method"); +assertIncludes(plugin.cliCommands, "demo", "cli command"); +assertIncludes(plugin.services, "demo-service", "service"); + +const diagErrors = (data.diagnostics || []).filter((diag) => diag.level === "error"); +if (diagErrors.length > 0) { + throw new Error(`diagnostics errors: ${diagErrors.map((diag) => diag.message).join("; ")}`); +} + +console.log("ok"); +NODE + + echo "Testing tgz install flow..." + pack_dir="$(mktemp -d "/tmp/openclaw-plugin-pack.XXXXXX")" + mkdir -p "$pack_dir/package" + cat > "$pack_dir/package/package.json" <<'"'"'JSON'"'"' +{ + "name": "@openclaw/demo-plugin-tgz", + "version": "0.0.1", + "openclaw": { "extensions": ["./index.js"] } +} +JSON + cat > "$pack_dir/package/index.js" <<'"'"'JS'"'"' +module.exports = { + id: "demo-plugin-tgz", + name: "Demo Plugin TGZ", + register(api) { + api.registerGatewayMethod("demo.tgz", async () => ({ ok: true })); + }, +}; +JS + tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package + + node dist/index.js plugins install /tmp/demo-plugin-tgz.tgz + node dist/index.js plugins list --json > /tmp/plugins2.json + + node - <<'"'"'NODE'"'"' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins2.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-tgz"); +if (!plugin) throw new Error("tgz plugin not found"); +if (plugin.status !== "loaded") { + throw new Error(`unexpected status: ${plugin.status}`); +} +if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("demo.tgz")) { + throw new Error("expected gateway method demo.tgz"); +} +console.log("ok"); +NODE + + echo "Testing install from local folder (plugins.load.paths)..." + dir_plugin="$(mktemp -d "/tmp/openclaw-plugin-dir.XXXXXX")" + cat > "$dir_plugin/package.json" <<'"'"'JSON'"'"' +{ + "name": "@openclaw/demo-plugin-dir", + "version": "0.0.1", + "openclaw": { "extensions": ["./index.js"] } +} +JSON + cat > "$dir_plugin/index.js" <<'"'"'JS'"'"' +module.exports = { + id: "demo-plugin-dir", + name: "Demo Plugin DIR", + register(api) { + api.registerGatewayMethod("demo.dir", async () => ({ ok: true })); + }, +}; +JS + + node dist/index.js plugins install "$dir_plugin" + node dist/index.js plugins list --json > /tmp/plugins3.json + + node - <<'"'"'NODE'"'"' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins3.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-dir"); +if (!plugin) throw new Error("dir plugin not found"); +if (plugin.status !== "loaded") { + throw new Error(`unexpected status: ${plugin.status}`); +} +if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("demo.dir")) { + throw new Error("expected gateway method demo.dir"); +} +console.log("ok"); +NODE + + echo "Testing install from npm spec (file:)..." + file_pack_dir="$(mktemp -d "/tmp/openclaw-plugin-filepack.XXXXXX")" + mkdir -p "$file_pack_dir/package" + cat > "$file_pack_dir/package/package.json" <<'"'"'JSON'"'"' +{ + "name": "@openclaw/demo-plugin-file", + "version": "0.0.1", + "openclaw": { "extensions": ["./index.js"] } +} +JSON + cat > "$file_pack_dir/package/index.js" <<'"'"'JS'"'"' +module.exports = { + id: "demo-plugin-file", + name: "Demo Plugin FILE", + register(api) { + api.registerGatewayMethod("demo.file", async () => ({ ok: true })); + }, +}; +JS + + node dist/index.js plugins install "file:$file_pack_dir/package" + node dist/index.js plugins list --json > /tmp/plugins4.json + + node - <<'"'"'NODE'"'"' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins4.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-file"); +if (!plugin) throw new Error("file plugin not found"); +if (plugin.status !== "loaded") { + throw new Error(`unexpected status: ${plugin.status}`); +} +if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("demo.file")) { + throw new Error("expected gateway method demo.file"); +} +console.log("ok"); +NODE +' + +echo "OK" diff --git a/scripts/e2e/qr-import-docker.sh b/scripts/e2e/qr-import-docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..746c4169e8157b7e1ccfe247f82a322e699c3d8d --- /dev/null +++ b/scripts/e2e/qr-import-docker.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE_NAME="${OPENCLAW_QR_SMOKE_IMAGE:-${CLAWDBOT_QR_SMOKE_IMAGE:-openclaw-qr-smoke}}" + +echo "Building Docker image..." +docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile.qr-import" "$ROOT_DIR" + +echo "Running qrcode-terminal import smoke..." +docker run --rm -t "$IMAGE_NAME" node -e "import('qrcode-terminal').then((m)=>m.default.generate('qr-smoke',{small:true}))" diff --git a/scripts/firecrawl-compare.ts b/scripts/firecrawl-compare.ts new file mode 100644 index 0000000000000000000000000000000000000000..58a8d96995a40788e07191858ef3330db92fbb1e --- /dev/null +++ b/scripts/firecrawl-compare.ts @@ -0,0 +1,139 @@ +import { extractReadableContent, fetchFirecrawlContent } from "../src/agents/tools/web-tools.js"; + +const DEFAULT_URLS = [ + "https://en.wikipedia.org/wiki/Web_scraping", + "https://news.ycombinator.com/", + "https://www.apple.com/iphone/", + "https://www.nytimes.com/", + "https://www.reddit.com/r/javascript/", +]; + +const urls = process.argv.slice(2); +const targets = urls.length > 0 ? urls : DEFAULT_URLS; +const apiKey = process.env.FIRECRAWL_API_KEY; +const baseUrl = process.env.FIRECRAWL_BASE_URL ?? "https://api.firecrawl.dev"; + +const userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; +const timeoutMs = 30_000; + +function truncate(value: string, max = 180): string { + if (!value) { + return ""; + } + return value.length > max ? `${value.slice(0, max)}…` : value; +} + +async function fetchHtml(url: string): Promise<{ + ok: boolean; + status: number; + contentType: string; + finalUrl: string; + body: string; +}> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: { Accept: "*/*", "User-Agent": userAgent }, + signal: controller.signal, + }); + const contentType = res.headers.get("content-type") ?? "application/octet-stream"; + const body = await res.text(); + return { + ok: res.ok, + status: res.status, + contentType, + finalUrl: res.url || url, + body, + }; + } finally { + clearTimeout(timer); + } +} + +async function run() { + if (!apiKey) { + console.log("FIRECRAWL_API_KEY not set. Firecrawl comparisons will be skipped."); + } + + for (const url of targets) { + console.log(`\n=== ${url}`); + let localStatus = "skipped"; + let localTitle = ""; + let localText = ""; + let localError: string | undefined; + + try { + const res = await fetchHtml(url); + if (!res.ok) { + localStatus = `http ${res.status}`; + } else if (!res.contentType.includes("text/html")) { + localStatus = `non-html (${res.contentType})`; + } else { + const readable = await extractReadableContent({ + html: res.body, + url: res.finalUrl, + extractMode: "markdown", + }); + if (readable?.text) { + localStatus = "readability"; + localTitle = readable.title ?? ""; + localText = readable.text; + } else { + localStatus = "readability-empty"; + } + } + } catch (error) { + localStatus = "error"; + localError = error instanceof Error ? error.message : String(error); + } + + console.log(`local: ${localStatus} len=${localText.length} title=${truncate(localTitle, 80)}`); + if (localError) { + console.log(`local error: ${localError}`); + } + if (localText) { + console.log(`local sample: ${truncate(localText)}`); + } + + if (apiKey) { + try { + const firecrawl = await fetchFirecrawlContent({ + url, + extractMode: "markdown", + apiKey, + baseUrl, + onlyMainContent: true, + maxAgeMs: 172_800_000, + proxy: "auto", + storeInCache: true, + timeoutSeconds: 60, + }); + console.log( + `firecrawl: ok len=${firecrawl.text.length} title=${truncate( + firecrawl.title ?? "", + 80, + )} status=${firecrawl.status ?? "n/a"}`, + ); + if (firecrawl.warning) { + console.log(`firecrawl warning: ${firecrawl.warning}`); + } + if (firecrawl.text) { + console.log(`firecrawl sample: ${truncate(firecrawl.text)}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`firecrawl: error ${message}`); + } + } + } + + process.exit(0); +} + +run().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/format-staged.js b/scripts/format-staged.js new file mode 100644 index 0000000000000000000000000000000000000000..4c5249dd898eb68feffb3d835d0a0041773fde4a --- /dev/null +++ b/scripts/format-staged.js @@ -0,0 +1,149 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const OXFMT_EXTENSIONS = new Set([".cjs", ".js", ".json", ".jsonc", ".jsx", ".mjs", ".ts", ".tsx"]); + +function getRepoRoot() { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, ".."); +} + +function runGitCommand(args, options = {}) { + return spawnSync("git", args, { + cwd: options.cwd, + encoding: "utf-8", + stdio: options.stdio ?? "pipe", + }); +} + +function splitNullDelimited(value) { + if (!value) { + return []; + } + const text = String(value); + return text.split("\0").filter(Boolean); +} + +function normalizeGitPath(filePath) { + return filePath.replace(/\\/g, "/"); +} + +function filterOxfmtTargets(paths) { + return paths + .map(normalizeGitPath) + .filter( + (filePath) => + (filePath.startsWith("src/") || filePath.startsWith("test/")) && + OXFMT_EXTENSIONS.has(path.posix.extname(filePath)), + ); +} + +function findPartiallyStagedFiles(stagedFiles, unstagedFiles) { + const unstaged = new Set(unstagedFiles.map(normalizeGitPath)); + return stagedFiles.filter((filePath) => unstaged.has(normalizeGitPath(filePath))); +} + +function filterOutPartialTargets(targets, partialTargets) { + if (partialTargets.length === 0) { + return targets; + } + const partial = new Set(partialTargets.map(normalizeGitPath)); + return targets.filter((filePath) => !partial.has(normalizeGitPath(filePath))); +} + +function resolveOxfmtCommand(repoRoot) { + const binName = process.platform === "win32" ? "oxfmt.cmd" : "oxfmt"; + const local = path.join(repoRoot, "node_modules", ".bin", binName); + if (fs.existsSync(local)) { + return { command: local, args: [] }; + } + + const result = spawnSync("oxfmt", ["--version"], { stdio: "ignore" }); + if (result.status === 0) { + return { command: "oxfmt", args: [] }; + } + + return null; +} + +function getGitPaths(args, repoRoot) { + const result = runGitCommand(args, { cwd: repoRoot }); + if (result.status !== 0) { + return []; + } + return splitNullDelimited(result.stdout ?? ""); +} + +function formatFiles(repoRoot, oxfmt, files) { + const result = spawnSync(oxfmt.command, ["--write", ...oxfmt.args, ...files], { + cwd: repoRoot, + stdio: "inherit", + }); + return result.status === 0; +} + +function stageFiles(repoRoot, files) { + if (files.length === 0) { + return true; + } + const result = runGitCommand(["add", "--", ...files], { cwd: repoRoot, stdio: "inherit" }); + return result.status === 0; +} + +function main() { + const repoRoot = getRepoRoot(); + const staged = getGitPaths( + ["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"], + repoRoot, + ); + const targets = filterOxfmtTargets(staged); + if (targets.length === 0) { + return; + } + + const unstaged = getGitPaths(["diff", "--name-only", "-z"], repoRoot); + const partial = findPartiallyStagedFiles(targets, unstaged); + if (partial.length > 0) { + process.stderr.write("[pre-commit] Skipping partially staged files:\n"); + for (const filePath of partial) { + process.stderr.write(`- ${filePath}\n`); + } + process.stderr.write("Stage full files to format them automatically.\n"); + } + + const filteredTargets = filterOutPartialTargets(targets, partial); + if (filteredTargets.length === 0) { + return; + } + + const oxfmt = resolveOxfmtCommand(repoRoot); + if (!oxfmt) { + process.stderr.write("[pre-commit] oxfmt not found; skipping format.\n"); + return; + } + + if (!formatFiles(repoRoot, oxfmt, filteredTargets)) { + process.exitCode = 1; + return; + } + + if (!stageFiles(repoRoot, filteredTargets)) { + process.exitCode = 1; + } +} + +export { + filterOxfmtTargets, + filterOutPartialTargets, + findPartiallyStagedFiles, + getRepoRoot, + normalizeGitPath, + resolveOxfmtCommand, + splitNullDelimited, +}; + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/ios-team-id.sh b/scripts/ios-team-id.sh new file mode 100644 index 0000000000000000000000000000000000000000..d7ae4ded4fb554f5605c05776e92e8fc0745361f --- /dev/null +++ b/scripts/ios-team-id.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +team_id="$(defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers 2>/dev/null | grep -Eo '[A-Z0-9]{10}' | head -n1 || true)" + +if [[ -z "$team_id" ]]; then + team_id="$(security find-identity -p codesigning -v 2>/dev/null | grep -Eo '\\([A-Z0-9]{10}\\)' | head -n1 | tr -d '()' || true)" +fi + +if [[ -z "$team_id" ]]; then + echo "No Apple Team ID found. Open Xcode or install signing certificates first." >&2 + exit 1 +fi + +echo "$team_id" diff --git a/scripts/make_appcast.sh b/scripts/make_appcast.sh new file mode 100644 index 0000000000000000000000000000000000000000..437c68e8bebb3e840c0069a9c7fe104ea18406c3 --- /dev/null +++ b/scripts/make_appcast.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +ZIP=${1:?"Usage: $0 OpenClaw-.zip"} +FEED_URL=${2:-"https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml"} +PRIVATE_KEY_FILE=${SPARKLE_PRIVATE_KEY_FILE:-} +if [[ -z "$PRIVATE_KEY_FILE" ]]; then + echo "Set SPARKLE_PRIVATE_KEY_FILE to your ed25519 private key (Sparkle)." >&2 + exit 1 +fi +if [[ ! -f "$ZIP" ]]; then + echo "Zip not found: $ZIP" >&2 + exit 1 +fi + +ZIP_DIR=$(cd "$(dirname "$ZIP")" && pwd) +ZIP_NAME=$(basename "$ZIP") +ZIP_BASE="${ZIP_NAME%.zip}" +VERSION=${SPARKLE_RELEASE_VERSION:-} +if [[ -z "$VERSION" ]]; then + if [[ "$ZIP_NAME" =~ ^OpenClaw-([0-9]+(\.[0-9]+){1,2}([-.][^.]*)?)\.zip$ ]]; then + VERSION="${BASH_REMATCH[1]}" + else + echo "Could not infer version from $ZIP_NAME; set SPARKLE_RELEASE_VERSION." >&2 + exit 1 + fi +fi + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" + if [[ "${KEEP_SPARKLE_NOTES:-0}" != "1" ]]; then + rm -f "$NOTES_HTML" + fi +} +trap cleanup EXIT +cp -f "$ZIP" "$TMP_DIR/$ZIP_NAME" +if [[ -f "$ROOT/appcast.xml" ]]; then + cp -f "$ROOT/appcast.xml" "$TMP_DIR/appcast.xml" +fi + +NOTES_HTML="${ZIP_DIR}/${ZIP_BASE}.html" +if [[ -x "$ROOT/scripts/changelog-to-html.sh" ]]; then + "$ROOT/scripts/changelog-to-html.sh" "$VERSION" >"$NOTES_HTML" +else + echo "Missing scripts/changelog-to-html.sh; cannot generate HTML release notes." >&2 + exit 1 +fi +cp -f "$NOTES_HTML" "$TMP_DIR/${ZIP_BASE}.html" + +DOWNLOAD_URL_PREFIX=${SPARKLE_DOWNLOAD_URL_PREFIX:-"https://github.com/openclaw/openclaw/releases/download/v${VERSION}/"} + +export PATH="$ROOT/apps/macos/.build/artifacts/sparkle/Sparkle/bin:$PATH" +if ! command -v generate_appcast >/dev/null; then + echo "generate_appcast not found in PATH. Build Sparkle tools via SwiftPM." >&2 + exit 1 +fi + +generate_appcast \ + --ed-key-file "$PRIVATE_KEY_FILE" \ + --download-url-prefix "$DOWNLOAD_URL_PREFIX" \ + --embed-release-notes \ + --link "$FEED_URL" \ + "$TMP_DIR" + +cp -f "$TMP_DIR/appcast.xml" "$ROOT/appcast.xml" + +echo "Appcast generated (appcast.xml). Upload alongside $ZIP at $FEED_URL" diff --git a/scripts/mobile-reauth.sh b/scripts/mobile-reauth.sh new file mode 100644 index 0000000000000000000000000000000000000000..a44878374204fb45daaa5f7d8860e31a7878abfb --- /dev/null +++ b/scripts/mobile-reauth.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# Mobile-friendly Claude Code re-authentication +# Designed for use via SSH from Termux +# +# This script handles the authentication flow in a way that works +# from a mobile device by: +# 1. Checking if auth is needed +# 2. Running claude setup-token for long-lived auth +# 3. Outputting URLs that can be easily opened on phone + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +echo "=== Claude Code Mobile Re-Auth ===" +echo "" + +# Check current auth status +echo "Checking auth status..." +AUTH_STATUS=$("$SCRIPT_DIR/claude-auth-status.sh" simple 2>/dev/null || echo "ERROR") + +case "$AUTH_STATUS" in + OK) + echo -e "${GREEN}Auth is valid!${NC}" + "$SCRIPT_DIR/claude-auth-status.sh" full + exit 0 + ;; + CLAUDE_EXPIRING|OPENCLAW_EXPIRING|CLAWDBOT_EXPIRING) + echo -e "${YELLOW}Auth is expiring soon.${NC}" + echo "" + ;; + *) + echo -e "${RED}Auth needs refresh.${NC}" + echo "" + ;; +esac + +echo "Starting long-lived token setup..." +echo "" +echo -e "${CYAN}Instructions:${NC}" +echo "1. Open this URL on your phone:" +echo "" +echo -e " ${CYAN}https://console.anthropic.com/settings/api-keys${NC}" +echo "" +echo "2. Sign in if needed" +echo "3. Create a new API key or use existing 'Claude Code' key" +echo "4. Copy the key (starts with sk-ant-...)" +echo "5. When prompted below, paste the key" +echo "" +echo "Press Enter when ready to continue..." +read -r + +# Run setup-token interactively +echo "" +echo "Running 'claude setup-token'..." +echo "(Follow the prompts and paste your API key when asked)" +echo "" + +if claude setup-token; then + echo "" + echo -e "${GREEN}Authentication successful!${NC}" + echo "" + "$SCRIPT_DIR/claude-auth-status.sh" full + + # Restart openclaw service if running + if systemctl --user is-active openclaw >/dev/null 2>&1; then + echo "" + echo "Restarting openclaw service..." + systemctl --user restart openclaw + echo -e "${GREEN}Service restarted.${NC}" + fi +else + echo "" + echo -e "${RED}Authentication failed.${NC}" + echo "Please try again or check the Claude Code documentation." + exit 1 +fi diff --git a/scripts/notarize-mac-artifact.sh b/scripts/notarize-mac-artifact.sh new file mode 100644 index 0000000000000000000000000000000000000000..8befd5f781c0b54eb3544eea9c29c091717eaa82 --- /dev/null +++ b/scripts/notarize-mac-artifact.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Notarize a macOS artifact (zip/dmg/pkg) and optionally staple the app bundle. +# +# Usage: +# STAPLE_APP_PATH=dist/OpenClaw.app scripts/notarize-mac-artifact.sh +# +# Auth (pick one): +# NOTARYTOOL_PROFILE keychain profile created via `xcrun notarytool store-credentials` +# NOTARYTOOL_KEY path to App Store Connect API key (.p8) +# NOTARYTOOL_KEY_ID API key ID +# NOTARYTOOL_ISSUER API issuer ID + +ARTIFACT="${1:-}" +STAPLE_APP_PATH="${STAPLE_APP_PATH:-}" + +if [[ -z "$ARTIFACT" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi +if [[ ! -e "$ARTIFACT" ]]; then + echo "Error: artifact not found: $ARTIFACT" >&2 + exit 1 +fi + +if ! command -v xcrun >/dev/null 2>&1; then + echo "Error: xcrun not found; install Xcode command line tools." >&2 + exit 1 +fi + +auth_args=() +if [[ -n "${NOTARYTOOL_PROFILE:-}" ]]; then + auth_args+=(--keychain-profile "$NOTARYTOOL_PROFILE") +elif [[ -n "${NOTARYTOOL_KEY:-}" && -n "${NOTARYTOOL_KEY_ID:-}" && -n "${NOTARYTOOL_ISSUER:-}" ]]; then + auth_args+=(--key "$NOTARYTOOL_KEY" --key-id "$NOTARYTOOL_KEY_ID" --issuer "$NOTARYTOOL_ISSUER") +else + echo "Error: Notary auth missing. Set NOTARYTOOL_PROFILE or NOTARYTOOL_KEY/NOTARYTOOL_KEY_ID/NOTARYTOOL_ISSUER." >&2 + exit 1 +fi + +echo "🧾 Notarizing: $ARTIFACT" +xcrun notarytool submit "$ARTIFACT" "${auth_args[@]}" --wait + +case "$ARTIFACT" in + *.dmg|*.pkg) + echo "📌 Stapling artifact: $ARTIFACT" + xcrun stapler staple "$ARTIFACT" + xcrun stapler validate "$ARTIFACT" + ;; + *) + ;; +esac + +if [[ -n "$STAPLE_APP_PATH" ]]; then + if [[ -d "$STAPLE_APP_PATH" ]]; then + echo "📌 Stapling app: $STAPLE_APP_PATH" + xcrun stapler staple "$STAPLE_APP_PATH" + xcrun stapler validate "$STAPLE_APP_PATH" + else + echo "Warn: STAPLE_APP_PATH not found: $STAPLE_APP_PATH" >&2 + fi +fi + +echo "✅ Notarization complete" diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh new file mode 100644 index 0000000000000000000000000000000000000000..be1eb09c4f155ec4dbf2ba51bdc4bd7f8081fbef --- /dev/null +++ b/scripts/package-mac-app.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build and bundle OpenClaw into a minimal .app we can open. +# Outputs to dist/OpenClaw.app + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +APP_ROOT="$ROOT_DIR/dist/OpenClaw.app" +BUILD_ROOT="$ROOT_DIR/apps/macos/.build" +PRODUCT="OpenClaw" +BUNDLE_ID="${BUNDLE_ID:-ai.openclaw.mac.debug}" +PKG_VERSION="$(cd "$ROOT_DIR" && node -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")" +BUILD_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +GIT_COMMIT=$(cd "$ROOT_DIR" && git rev-parse --short HEAD 2>/dev/null || echo "unknown") +GIT_BUILD_NUMBER=$(cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null || echo "0") +APP_VERSION="${APP_VERSION:-$PKG_VERSION}" +APP_BUILD="${APP_BUILD:-$GIT_BUILD_NUMBER}" +BUILD_CONFIG="${BUILD_CONFIG:-debug}" +BUILD_ARCHS_VALUE="${BUILD_ARCHS:-$(uname -m)}" +if [[ "${BUILD_ARCHS_VALUE}" == "all" ]]; then + BUILD_ARCHS_VALUE="arm64 x86_64" +fi +IFS=' ' read -r -a BUILD_ARCHS <<< "$BUILD_ARCHS_VALUE" +PRIMARY_ARCH="${BUILD_ARCHS[0]}" +SPARKLE_PUBLIC_ED_KEY="${SPARKLE_PUBLIC_ED_KEY:-AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=}" +SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml}" +AUTO_CHECKS=true +if [[ "$BUNDLE_ID" == *.debug ]]; then + SPARKLE_FEED_URL="" + AUTO_CHECKS=false +fi +if [[ "$AUTO_CHECKS" == "true" && ! "$APP_BUILD" =~ ^[0-9]+$ ]]; then + echo "ERROR: APP_BUILD must be numeric for Sparkle compare (CFBundleVersion). Got: $APP_BUILD" >&2 + exit 1 +fi + +build_path_for_arch() { + echo "$BUILD_ROOT/$1" +} + +bin_for_arch() { + echo "$(build_path_for_arch "$1")/$BUILD_CONFIG/$PRODUCT" +} + +sparkle_framework_for_arch() { + echo "$(build_path_for_arch "$1")/$BUILD_CONFIG/Sparkle.framework" +} + +merge_framework_machos() { + local primary="$1" + local dest="$2" + shift 2 + local others=("$@") + + archs_for() { + /usr/bin/lipo -info "$1" | /usr/bin/sed -E 's/.*are: //; s/.*architecture: //' + } + + arch_in_list() { + local needle="$1" + shift + for item in "$@"; do + if [[ "$item" == "$needle" ]]; then + return 0 + fi + done + return 1 + } + + while IFS= read -r -d '' file; do + if /usr/bin/file "$file" | /usr/bin/grep -q "Mach-O"; then + local rel="${file#$primary/}" + local primary_archs + primary_archs=$(archs_for "$file") + IFS=' ' read -r -a primary_arch_array <<< "$primary_archs" + + local missing_files=() + local tmp_dir + tmp_dir=$(mktemp -d) + for fw in "${others[@]}"; do + local other_file="$fw/$rel" + if [[ ! -f "$other_file" ]]; then + echo "ERROR: Missing $rel in $fw" >&2 + rm -rf "$tmp_dir" + exit 1 + fi + if /usr/bin/file "$other_file" | /usr/bin/grep -q "Mach-O"; then + local other_archs + other_archs=$(archs_for "$other_file") + IFS=' ' read -r -a other_arch_array <<< "$other_archs" + for arch in "${other_arch_array[@]}"; do + if ! arch_in_list "$arch" "${primary_arch_array[@]}"; then + local thin_file="$tmp_dir/$(echo "$rel" | tr '/' '_')-$arch" + /usr/bin/lipo -thin "$arch" "$other_file" -output "$thin_file" + missing_files+=("$thin_file") + primary_arch_array+=("$arch") + fi + done + fi + done + + if [[ "${#missing_files[@]}" -gt 0 ]]; then + /usr/bin/lipo -create "$file" "${missing_files[@]}" -output "$dest/$rel" + fi + rm -rf "$tmp_dir" + fi + done < <(find "$primary" -type f -print0) +} + +echo "📦 Ensuring deps (pnpm install)" +(cd "$ROOT_DIR" && pnpm install --no-frozen-lockfile --config.node-linker=hoisted) +if [[ "${SKIP_TSC:-0}" != "1" ]]; then + echo "📦 Building JS (pnpm tsc)" + (cd "$ROOT_DIR" && pnpm tsc -p tsconfig.json --noEmit false) +else + echo "📦 Skipping TS build (SKIP_TSC=1)" +fi + +if [[ "${SKIP_UI_BUILD:-0}" != "1" ]]; then + echo "🖥 Building Control UI (ui:build)" + (cd "$ROOT_DIR" && node scripts/ui.js build) +else + echo "🖥 Skipping Control UI build (SKIP_UI_BUILD=1)" +fi + +cd "$ROOT_DIR/apps/macos" + +echo "🔨 Building $PRODUCT ($BUILD_CONFIG) [${BUILD_ARCHS[*]}]" +for arch in "${BUILD_ARCHS[@]}"; do + BUILD_PATH="$(build_path_for_arch "$arch")" + swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" --arch "$arch" -Xlinker -rpath -Xlinker @executable_path/../Frameworks +done + +BIN_PRIMARY="$(bin_for_arch "$PRIMARY_ARCH")" +echo "pkg: binary $BIN_PRIMARY" >&2 +echo "🧹 Cleaning old app bundle" +rm -rf "$APP_ROOT" +mkdir -p "$APP_ROOT/Contents/MacOS" +mkdir -p "$APP_ROOT/Contents/Resources" +mkdir -p "$APP_ROOT/Contents/Frameworks" + +echo "📄 Copying Info.plist template" +INFO_PLIST_SRC="$ROOT_DIR/apps/macos/Sources/OpenClaw/Resources/Info.plist" +if [ ! -f "$INFO_PLIST_SRC" ]; then + echo "ERROR: Info.plist template missing at $INFO_PLIST_SRC" >&2 + exit 1 +fi +cp "$INFO_PLIST_SRC" "$APP_ROOT/Contents/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${BUNDLE_ID}" "$APP_ROOT/Contents/Info.plist" || true +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${APP_VERSION}" "$APP_ROOT/Contents/Info.plist" || true +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${APP_BUILD}" "$APP_ROOT/Contents/Info.plist" || true +/usr/libexec/PlistBuddy -c "Set :OpenClawBuildTimestamp ${BUILD_TS}" "$APP_ROOT/Contents/Info.plist" || true +/usr/libexec/PlistBuddy -c "Set :OpenClawGitCommit ${GIT_COMMIT}" "$APP_ROOT/Contents/Info.plist" || true +/usr/libexec/PlistBuddy -c "Set :SUFeedURL ${SPARKLE_FEED_URL}" "$APP_ROOT/Contents/Info.plist" \ + || /usr/libexec/PlistBuddy -c "Add :SUFeedURL string ${SPARKLE_FEED_URL}" "$APP_ROOT/Contents/Info.plist" || true +/usr/libexec/PlistBuddy -c "Set :SUPublicEDKey ${SPARKLE_PUBLIC_ED_KEY}" "$APP_ROOT/Contents/Info.plist" \ + || /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_ED_KEY}" "$APP_ROOT/Contents/Info.plist" || true +if /usr/libexec/PlistBuddy -c "Set :SUEnableAutomaticChecks ${AUTO_CHECKS}" "$APP_ROOT/Contents/Info.plist"; then + true +else + /usr/libexec/PlistBuddy -c "Add :SUEnableAutomaticChecks bool ${AUTO_CHECKS}" "$APP_ROOT/Contents/Info.plist" || true +fi + +echo "🚚 Copying binary" +cp "$BIN_PRIMARY" "$APP_ROOT/Contents/MacOS/OpenClaw" +if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then + BIN_INPUTS=() + for arch in "${BUILD_ARCHS[@]}"; do + BIN_INPUTS+=("$(bin_for_arch "$arch")") + done + /usr/bin/lipo -create "${BIN_INPUTS[@]}" -output "$APP_ROOT/Contents/MacOS/OpenClaw" +fi +chmod +x "$APP_ROOT/Contents/MacOS/OpenClaw" +# SwiftPM outputs ad-hoc signed binaries; strip the signature before install_name_tool to avoid warnings. +/usr/bin/codesign --remove-signature "$APP_ROOT/Contents/MacOS/OpenClaw" 2>/dev/null || true + +SPARKLE_FRAMEWORK_PRIMARY="$(sparkle_framework_for_arch "$PRIMARY_ARCH")" +if [ -d "$SPARKLE_FRAMEWORK_PRIMARY" ]; then + echo "✨ Embedding Sparkle.framework" + cp -R "$SPARKLE_FRAMEWORK_PRIMARY" "$APP_ROOT/Contents/Frameworks/" + if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then + OTHER_FRAMEWORKS=() + for arch in "${BUILD_ARCHS[@]}"; do + if [[ "$arch" == "$PRIMARY_ARCH" ]]; then + continue + fi + OTHER_FRAMEWORKS+=("$(sparkle_framework_for_arch "$arch")") + done + merge_framework_machos "$SPARKLE_FRAMEWORK_PRIMARY" "$APP_ROOT/Contents/Frameworks/Sparkle.framework" "${OTHER_FRAMEWORKS[@]}" + fi + chmod -R a+rX "$APP_ROOT/Contents/Frameworks/Sparkle.framework" +fi + +echo "📦 Copying Swift 6.2 compatibility libraries" +SWIFT_COMPAT_LIB="$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-6.2/macosx/libswiftCompatibilitySpan.dylib" +if [ -f "$SWIFT_COMPAT_LIB" ]; then + cp "$SWIFT_COMPAT_LIB" "$APP_ROOT/Contents/Frameworks/" + chmod +x "$APP_ROOT/Contents/Frameworks/libswiftCompatibilitySpan.dylib" +else + echo "WARN: Swift compatibility library not found at $SWIFT_COMPAT_LIB (continuing)" >&2 +fi + +echo "🖼 Copying app icon" +cp "$ROOT_DIR/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns" "$APP_ROOT/Contents/Resources/OpenClaw.icns" + +echo "📦 Copying device model resources" +rm -rf "$APP_ROOT/Contents/Resources/DeviceModels" +cp -R "$ROOT_DIR/apps/macos/Sources/OpenClaw/Resources/DeviceModels" "$APP_ROOT/Contents/Resources/DeviceModels" + +echo "📦 Copying model catalog" +MODEL_CATALOG_SRC="$ROOT_DIR/node_modules/@mariozechner/pi-ai/dist/models.generated.js" +MODEL_CATALOG_DEST="$APP_ROOT/Contents/Resources/models.generated.js" +if [ -f "$MODEL_CATALOG_SRC" ]; then + cp "$MODEL_CATALOG_SRC" "$MODEL_CATALOG_DEST" +else + echo "WARN: model catalog missing at $MODEL_CATALOG_SRC (continuing)" >&2 +fi + +echo "📦 Copying OpenClawKit resources" +OPENCLAWKIT_BUNDLE="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG/OpenClawKit_OpenClawKit.bundle" +if [ -d "$OPENCLAWKIT_BUNDLE" ]; then + rm -rf "$APP_ROOT/Contents/Resources/OpenClawKit_OpenClawKit.bundle" + cp -R "$OPENCLAWKIT_BUNDLE" "$APP_ROOT/Contents/Resources/OpenClawKit_OpenClawKit.bundle" +else + echo "WARN: OpenClawKit resource bundle not found at $OPENCLAWKIT_BUNDLE (continuing)" >&2 +fi + +echo "📦 Copying Textual resources" +TEXTUAL_BUNDLE_DIR="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG" +TEXTUAL_BUNDLE="" +for candidate in \ + "$TEXTUAL_BUNDLE_DIR/textual_Textual.bundle" \ + "$TEXTUAL_BUNDLE_DIR/Textual_Textual.bundle" +do + if [ -d "$candidate" ]; then + TEXTUAL_BUNDLE="$candidate" + break + fi +done +if [ -z "$TEXTUAL_BUNDLE" ]; then + TEXTUAL_BUNDLE="$(find "$BUILD_ROOT" -type d \( -name "textual_Textual.bundle" -o -name "Textual_Textual.bundle" \) -print -quit)" +fi +if [ -n "$TEXTUAL_BUNDLE" ] && [ -d "$TEXTUAL_BUNDLE" ]; then + rm -rf "$APP_ROOT/Contents/Resources/$(basename "$TEXTUAL_BUNDLE")" + cp -R "$TEXTUAL_BUNDLE" "$APP_ROOT/Contents/Resources/" +else + if [[ "${ALLOW_MISSING_TEXTUAL_BUNDLE:-0}" == "1" ]]; then + echo "WARN: Textual resource bundle not found (continuing due to ALLOW_MISSING_TEXTUAL_BUNDLE=1)" >&2 + else + echo "ERROR: Textual resource bundle not found. Set ALLOW_MISSING_TEXTUAL_BUNDLE=1 to bypass." >&2 + exit 1 + fi +fi + +echo "⏹ Stopping any running OpenClaw" +killall -q OpenClaw 2>/dev/null || true + +echo "🔏 Signing bundle (auto-selects signing identity if SIGN_IDENTITY is unset)" +"$ROOT_DIR/scripts/codesign-mac-app.sh" "$APP_ROOT" + +echo "✅ Bundle ready at $APP_ROOT" diff --git a/scripts/package-mac-dist.sh b/scripts/package-mac-dist.sh new file mode 100644 index 0000000000000000000000000000000000000000..8f5e4dad6b9891c8b88336e301365955e9744fb4 --- /dev/null +++ b/scripts/package-mac-dist.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build the mac app bundle, then create a zip (Sparkle) + styled DMG (humans). +# +# Output: +# - dist/OpenClaw.app +# - dist/OpenClaw-.zip +# - dist/OpenClaw-.dmg + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +# Default to universal binary for distribution builds (supports both Apple Silicon and Intel Macs) +export BUILD_ARCHS="${BUILD_ARCHS:-all}" + +"$ROOT_DIR/scripts/package-mac-app.sh" + +APP="$ROOT_DIR/dist/OpenClaw.app" +if [[ ! -d "$APP" ]]; then + echo "Error: missing app bundle at $APP" >&2 + exit 1 +fi + +VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP/Contents/Info.plist" 2>/dev/null || echo "0.0.0") +ZIP="$ROOT_DIR/dist/OpenClaw-$VERSION.zip" +DMG="$ROOT_DIR/dist/OpenClaw-$VERSION.dmg" +NOTARY_ZIP="$ROOT_DIR/dist/OpenClaw-$VERSION.notary.zip" +SKIP_NOTARIZE="${SKIP_NOTARIZE:-0}" +NOTARIZE=1 + +if [[ "$SKIP_NOTARIZE" == "1" ]]; then + NOTARIZE=0 +fi + +if [[ "$NOTARIZE" == "1" ]]; then + echo "📦 Notary zip: $NOTARY_ZIP" + rm -f "$NOTARY_ZIP" + ditto -c -k --sequesterRsrc --keepParent "$APP" "$NOTARY_ZIP" + STAPLE_APP_PATH="$APP" "$ROOT_DIR/scripts/notarize-mac-artifact.sh" "$NOTARY_ZIP" + rm -f "$NOTARY_ZIP" +fi + +echo "📦 Zip: $ZIP" +rm -f "$ZIP" +ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP" + +echo "💿 DMG: $DMG" +"$ROOT_DIR/scripts/create-dmg.sh" "$APP" "$DMG" + +if [[ "$NOTARIZE" == "1" ]]; then + if [[ -n "${SIGN_IDENTITY:-}" ]]; then + echo "🔏 Signing DMG: $DMG" + /usr/bin/codesign --force --sign "$SIGN_IDENTITY" --timestamp "$DMG" + fi + "$ROOT_DIR/scripts/notarize-mac-artifact.sh" "$DMG" +fi diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 0000000000000000000000000000000000000000..e5adce74e7b0a42e3bae37543cf3f44a30791bfc --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,361 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { setupGitHooks } from "./setup-git-hooks.js"; + +function detectPackageManager(ua = process.env.npm_config_user_agent ?? "") { + // Examples: + // - "pnpm/10.23.0 npm/? node/v22.21.1 darwin arm64" + // - "npm/10.9.4 node/v22.12.0 linux x64" + // - "bun/1.2.2" + const normalized = String(ua).trim(); + if (normalized.startsWith("pnpm/")) { + return "pnpm"; + } + if (normalized.startsWith("bun/")) { + return "bun"; + } + if (normalized.startsWith("npm/")) { + return "npm"; + } + if (normalized.startsWith("yarn/")) { + return "yarn"; + } + return "unknown"; +} + +function shouldApplyPnpmPatchedDependenciesFallback(pm = detectPackageManager()) { + // pnpm already applies pnpm.patchedDependencies itself; re-applying would fail. + return pm !== "pnpm"; +} + +function getRepoRoot() { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, ".."); +} + +function ensureExecutable(targetPath) { + if (process.platform === "win32") { + return; + } + if (!fs.existsSync(targetPath)) { + return; + } + try { + const mode = fs.statSync(targetPath).mode & 0o777; + if (mode & 0o100) { + return; + } + fs.chmodSync(targetPath, 0o755); + } catch (err) { + console.warn(`[postinstall] chmod failed: ${err}`); + } +} + +function extractPackageName(key) { + if (key.startsWith("@")) { + const idx = key.indexOf("@", 1); + if (idx === -1) { + return key; + } + return key.slice(0, idx); + } + const idx = key.lastIndexOf("@"); + if (idx <= 0) { + return key; + } + return key.slice(0, idx); +} + +function stripPrefix(p) { + if (p.startsWith("a/") || p.startsWith("b/")) { + return p.slice(2); + } + return p; +} + +function parseRange(segment) { + // segment: "-12,5" or "+7" + const [startRaw, countRaw] = segment.slice(1).split(","); + const start = Number.parseInt(startRaw, 10); + const count = countRaw ? Number.parseInt(countRaw, 10) : 1; + if (Number.isNaN(start) || Number.isNaN(count)) { + throw new Error(`invalid hunk range: ${segment}`); + } + return { start, count }; +} + +function parsePatch(patchText) { + const lines = patchText.split("\n"); + const files = []; + let i = 0; + + while (i < lines.length) { + if (!lines[i].startsWith("diff --git ")) { + i += 1; + continue; + } + + const file = { oldPath: null, newPath: null, hunks: [] }; + i += 1; + + // Skip index line(s) + while (i < lines.length && lines[i].startsWith("index ")) { + i += 1; + } + + if (i < lines.length && lines[i].startsWith("--- ")) { + file.oldPath = stripPrefix(lines[i].slice(4).trim()); + i += 1; + } + if (i < lines.length && lines[i].startsWith("+++ ")) { + file.newPath = stripPrefix(lines[i].slice(4).trim()); + i += 1; + } + + while (i < lines.length && lines[i].startsWith("@@")) { + const header = lines[i]; + const match = /^@@\s+(-\d+(?:,\d+)?)\s+(\+\d+(?:,\d+)?)\s+@@/.exec(header); + if (!match) { + throw new Error(`invalid hunk header: ${header}`); + } + const oldRange = parseRange(match[1]); + const newRange = parseRange(match[2]); + i += 1; + + const hunkLines = []; + while (i < lines.length) { + const line = lines[i]; + if (line.startsWith("@@") || line.startsWith("diff --git ")) { + break; + } + if (line === "") { + i += 1; + continue; + } + if (line.startsWith("\\ No newline at end of file")) { + i += 1; + continue; + } + hunkLines.push(line); + i += 1; + } + + file.hunks.push({ + oldStart: oldRange.start, + oldLines: oldRange.count, + newStart: newRange.start, + newLines: newRange.count, + lines: hunkLines, + }); + } + + if (file.newPath && file.hunks.length > 0) { + files.push(file); + } + } + + return files; +} + +function readFileLines(targetPath) { + if (!fs.existsSync(targetPath)) { + throw new Error(`target file missing: ${targetPath}`); + } + const raw = fs.readFileSync(targetPath, "utf-8"); + const hasTrailingNewline = raw.endsWith("\n"); + const parts = raw.split("\n"); + if (hasTrailingNewline) { + parts.pop(); + } + return { lines: parts, hasTrailingNewline }; +} + +function writeFileLines(targetPath, lines, hadTrailingNewline) { + const content = lines.join("\n") + (hadTrailingNewline ? "\n" : ""); + fs.writeFileSync(targetPath, content, "utf-8"); +} + +function applyHunk(lines, hunk, offset) { + let cursor = hunk.oldStart - 1 + offset; + const expected = []; + for (const raw of hunk.lines) { + const marker = raw[0]; + if (marker === " " || marker === "+") { + expected.push(raw.slice(1)); + } + } + if (cursor >= 0 && cursor + expected.length <= lines.length) { + let alreadyApplied = true; + for (let i = 0; i < expected.length; i += 1) { + if (lines[cursor + i] !== expected[i]) { + alreadyApplied = false; + break; + } + } + if (alreadyApplied) { + const delta = hunk.newLines - hunk.oldLines; + return offset + delta; + } + } + + for (const raw of hunk.lines) { + const marker = raw[0]; + const text = raw.slice(1); + if (marker === " ") { + if (lines[cursor] !== text) { + throw new Error( + `context mismatch at line ${cursor + 1}: expected "${text}", found "${lines[cursor] ?? ""}"`, + ); + } + cursor += 1; + } else if (marker === "-") { + if (lines[cursor] !== text) { + throw new Error( + `delete mismatch at line ${cursor + 1}: expected "${text}", found "${lines[cursor] ?? ""}"`, + ); + } + lines.splice(cursor, 1); + } else if (marker === "+") { + lines.splice(cursor, 0, text); + cursor += 1; + } else { + throw new Error(`unexpected hunk marker: ${marker}`); + } + } + + const delta = hunk.newLines - hunk.oldLines; + return offset + delta; +} + +function applyPatchToFile(targetDir, filePatch) { + if (filePatch.newPath === "/dev/null") { + // deletion not needed for our patches + return; + } + const relPath = stripPrefix(filePatch.newPath ?? filePatch.oldPath ?? ""); + const targetPath = path.join(targetDir, relPath); + const { lines, hasTrailingNewline } = readFileLines(targetPath); + + let offset = 0; + for (const hunk of filePatch.hunks) { + offset = applyHunk(lines, hunk, offset); + } + + writeFileLines(targetPath, lines, hasTrailingNewline); +} + +function applyPatchSet({ patchText, targetDir }) { + let resolvedTarget = path.resolve(targetDir); + if (!fs.existsSync(resolvedTarget) || !fs.statSync(resolvedTarget).isDirectory()) { + console.warn(`[postinstall] skip missing target: ${resolvedTarget}`); + return; + } + resolvedTarget = fs.realpathSync(resolvedTarget); + + const files = parsePatch(patchText); + if (files.length === 0) { + return; + } + + for (const filePatch of files) { + applyPatchToFile(resolvedTarget, filePatch); + } +} + +function applyPatchFile({ patchPath, targetDir }) { + const absPatchPath = path.resolve(patchPath); + if (!fs.existsSync(absPatchPath)) { + throw new Error(`missing patch: ${absPatchPath}`); + } + const patchText = fs.readFileSync(absPatchPath, "utf-8"); + applyPatchSet({ patchText, targetDir }); +} + +function trySetupCompletion(repoRoot) { + // Skip in CI or if explicitly disabled + if (process.env.CI || process.env.OPENCLAW_SKIP_COMPLETION_SETUP) { + return; + } + + const binPath = path.join(repoRoot, "openclaw.mjs"); + if (!fs.existsSync(binPath)) { + return; + } + + // In development, dist might not exist yet during postinstall + const distEntry = path.join(repoRoot, "dist", "index.js"); + if (!fs.existsSync(distEntry)) { + return; + } + + try { + // Run with OPENCLAW_SKIP_POSTINSTALL to avoid any weird recursion, + // though distinct from this script. + spawnSync(process.execPath, [binPath, "completion", "--install", "--yes"], { + cwd: repoRoot, + stdio: "inherit", + env: { ...process.env, OPENCLAW_SKIP_POSTINSTALL: "1" }, + }); + } catch { + // Ignore errors to not break install + } +} + +function main() { + const repoRoot = getRepoRoot(); + process.chdir(repoRoot); + + ensureExecutable(path.join(repoRoot, "dist", "/entry.js")); + setupGitHooks({ repoRoot }); + trySetupCompletion(repoRoot); + + if (!shouldApplyPnpmPatchedDependenciesFallback()) { + return; + } + + const pkgPath = path.join(repoRoot, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); + const patched = pkg?.pnpm?.patchedDependencies ?? {}; + + // Bun does not support pnpm.patchedDependencies. Apply these patch files to + // node_modules packages as a best-effort compatibility layer. + for (const [key, relPatchPath] of Object.entries(patched)) { + if (typeof relPatchPath !== "string" || !relPatchPath.trim()) { + continue; + } + const pkgName = extractPackageName(String(key)); + if (!pkgName) { + continue; + } + applyPatchFile({ + targetDir: path.join("node_modules", ...pkgName.split("/")), + patchPath: relPatchPath, + }); + } +} + +try { + const skip = + process.env.OPENCLAW_SKIP_POSTINSTALL === "1" || + process.env.CLAWDBOT_SKIP_POSTINSTALL === "1" || + process.env.VITEST === "true" || + process.env.NODE_ENV === "test"; + + if (!skip) { + main(); + } +} catch (err) { + console.error(String(err)); + process.exit(1); +} + +export { + applyPatchFile, + applyPatchSet, + applyPatchToFile, + detectPackageManager, + parsePatch, + shouldApplyPnpmPatchedDependenciesFallback, +}; diff --git a/scripts/pre-commit/run-node-tool.sh b/scripts/pre-commit/run-node-tool.sh new file mode 100644 index 0000000000000000000000000000000000000000..3416307551735d8215f26c43299995fa1da7b9bd --- /dev/null +++ b/scripts/pre-commit/run-node-tool.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +if [[ $# -lt 1 ]]; then + echo "usage: run-node-tool.sh [args...]" >&2 + exit 2 +fi + +tool="$1" +shift + +if [[ -f "$ROOT_DIR/pnpm-lock.yaml" ]] && command -v pnpm >/dev/null 2>&1; then + exec pnpm exec "$tool" "$@" +fi + +if { [[ -f "$ROOT_DIR/bun.lockb" ]] || [[ -f "$ROOT_DIR/bun.lock" ]]; } && command -v bun >/dev/null 2>&1; then + exec bunx --bun "$tool" "$@" +fi + +if command -v npm >/dev/null 2>&1; then + exec npm exec -- "$tool" "$@" +fi + +if command -v npx >/dev/null 2>&1; then + exec npx "$tool" "$@" +fi + +echo "Missing package manager: pnpm, bun, or npm required." >&2 +exit 1 diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts new file mode 100644 index 0000000000000000000000000000000000000000..66ff0dbdb17b2bff6f71c23225b7e3656b4dd523 --- /dev/null +++ b/scripts/protocol-gen-swift.ts @@ -0,0 +1,244 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ErrorCodes, PROTOCOL_VERSION, ProtocolSchemas } from "../src/gateway/protocol/schema.js"; + +type JsonSchema = { + type?: string | string[]; + properties?: Record; + required?: string[]; + items?: JsonSchema; + enum?: string[]; + patternProperties?: Record; +}; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); +const outPaths = [ + path.join(repoRoot, "apps", "macos", "Sources", "OpenClawProtocol", "GatewayModels.swift"), + path.join( + repoRoot, + "apps", + "shared", + "OpenClawKit", + "Sources", + "OpenClawProtocol", + "GatewayModels.swift", + ), +]; + +const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values( + ErrorCodes, +) + .map((c) => ` case ${camelCase(c)} = "${c}"`) + .join("\n")}\n}\n`; + +const reserved = new Set([ + "associatedtype", + "class", + "deinit", + "enum", + "extension", + "fileprivate", + "func", + "import", + "init", + "inout", + "internal", + "let", + "open", + "operator", + "private", + "precedencegroup", + "protocol", + "public", + "rethrows", + "static", + "struct", + "subscript", + "typealias", + "var", +]); + +function camelCase(input: string) { + return input + .replace(/[^a-zA-Z0-9]+/g, " ") + .trim() + .toLowerCase() + .split(/\s+/) + .map((p, i) => (i === 0 ? p : p[0].toUpperCase() + p.slice(1))) + .join(""); +} + +function safeName(name: string) { + const cc = camelCase(name.replace(/-/g, "_")); + if (reserved.has(cc)) { + return `_${cc}`; + } + return cc; +} + +// filled later once schemas are loaded +const schemaNameByObject = new Map(); + +function swiftType(schema: JsonSchema, required: boolean): string { + const t = schema.type; + const isOptional = !required; + let base: string; + const named = schemaNameByObject.get(schema as object); + if (named) { + base = named; + } else if (t === "string") { + base = "String"; + } else if (t === "integer") { + base = "Int"; + } else if (t === "number") { + base = "Double"; + } else if (t === "boolean") { + base = "Bool"; + } else if (t === "array") { + base = `[${swiftType(schema.items ?? { type: "Any" }, true)}]`; + } else if (schema.enum) { + base = "String"; + } else if (schema.patternProperties) { + base = "[String: AnyCodable]"; + } else if (t === "object") { + base = "[String: AnyCodable]"; + } else { + base = "AnyCodable"; + } + return isOptional ? `${base}?` : base; +} + +function emitStruct(name: string, schema: JsonSchema): string { + const props = schema.properties ?? {}; + const required = new Set(schema.required ?? []); + const lines: string[] = []; + lines.push(`public struct ${name}: Codable, Sendable {`); + if (Object.keys(props).length === 0) { + lines.push("}\n"); + return lines.join("\n"); + } + const codingKeys: string[] = []; + for (const [key, propSchema] of Object.entries(props)) { + const propName = safeName(key); + const propType = swiftType(propSchema, required.has(key)); + lines.push(` public let ${propName}: ${propType}`); + if (propName !== key) { + codingKeys.push(` case ${propName} = "${key}"`); + } else { + codingKeys.push(` case ${propName}`); + } + } + lines.push( + "\n public init(\n" + + Object.entries(props) + .map(([key, prop]) => { + const propName = safeName(key); + const req = required.has(key); + return ` ${propName}: ${swiftType(prop, true)}${req ? "" : "?"}`; + }) + .join(",\n") + + "\n ) {\n" + + Object.entries(props) + .map(([key]) => { + const propName = safeName(key); + return ` self.${propName} = ${propName}`; + }) + .join("\n") + + "\n }\n" + + " private enum CodingKeys: String, CodingKey {\n" + + codingKeys.join("\n") + + "\n }\n}", + ); + lines.push(""); + return lines.join("\n"); +} + +function emitGatewayFrame(): string { + const cases = ["req", "res", "event"]; + const associated: Record = { + req: "RequestFrame", + res: "ResponseFrame", + event: "EventFrame", + }; + const caseLines = cases.map((c) => ` case ${safeName(c)}(${associated[c]})`); + const initLines = ` + private enum CodingKeys: String, CodingKey { + case type + } + + public init(from decoder: Decoder) throws { + let typeContainer = try decoder.container(keyedBy: CodingKeys.self) + let type = try typeContainer.decode(String.self, forKey: .type) + switch type { + case "req": + self = .req(try RequestFrame(from: decoder)) + case "res": + self = .res(try ResponseFrame(from: decoder)) + case "event": + self = .event(try EventFrame(from: decoder)) + default: + let container = try decoder.singleValueContainer() + let raw = try container.decode([String: AnyCodable].self) + self = .unknown(type: type, raw: raw) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .req(let v): try v.encode(to: encoder) + case .res(let v): try v.encode(to: encoder) + case .event(let v): try v.encode(to: encoder) + case .unknown(_, let raw): + var container = encoder.singleValueContainer() + try container.encode(raw) + } + } +`; + + return [ + "public enum GatewayFrame: Codable, Sendable {", + ...caseLines, + " case unknown(type: String, raw: [String: AnyCodable])", + initLines, + "}", + "", + ].join("\n"); +} + +async function generate() { + const definitions = Object.entries(ProtocolSchemas) as Array<[string, JsonSchema]>; + + for (const [name, schema] of definitions) { + schemaNameByObject.set(schema as object, name); + } + + const parts: string[] = []; + parts.push(header); + + // Value structs + for (const [name, schema] of definitions) { + if (name === "GatewayFrame") { + continue; + } + if (schema.type === "object") { + parts.push(emitStruct(name, schema)); + } + } + + // Frame enum must come after payload structs + parts.push(emitGatewayFrame()); + + const content = parts.join("\n"); + for (const outPath of outPaths) { + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, content); + console.log(`wrote ${outPath}`); + } +} + +generate().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/protocol-gen.ts b/scripts/protocol-gen.ts new file mode 100644 index 0000000000000000000000000000000000000000..ae8ce2ca39d057871a9211f56266b77095f25cac --- /dev/null +++ b/scripts/protocol-gen.ts @@ -0,0 +1,51 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ProtocolSchemas } from "../src/gateway/protocol/schema.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); + +async function writeJsonSchema() { + const definitions: Record = {}; + for (const [name, schema] of Object.entries(ProtocolSchemas)) { + definitions[name] = schema; + } + + const rootSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + $id: "https://openclaw.ai/protocol.schema.json", + title: "OpenClaw Gateway Protocol", + description: "Handshake, request/response, and event frames for the Gateway WebSocket.", + oneOf: [ + { $ref: "#/definitions/RequestFrame" }, + { $ref: "#/definitions/ResponseFrame" }, + { $ref: "#/definitions/EventFrame" }, + ], + discriminator: { + propertyName: "type", + mapping: { + req: "#/definitions/RequestFrame", + res: "#/definitions/ResponseFrame", + event: "#/definitions/EventFrame", + }, + }, + definitions, + }; + + const distDir = path.join(repoRoot, "dist"); + await fs.mkdir(distDir, { recursive: true }); + const jsonSchemaPath = path.join(distDir, "protocol.schema.json"); + await fs.writeFile(jsonSchemaPath, JSON.stringify(rootSchema, null, 2)); + console.log(`wrote ${jsonSchemaPath}`); + return { jsonSchemaPath, schemaString: JSON.stringify(rootSchema) }; +} + +async function main() { + await writeJsonSchema(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/readability-basic-compare.ts b/scripts/readability-basic-compare.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ce8e0d718223f9a70dc0e95257ecb6828f09655 --- /dev/null +++ b/scripts/readability-basic-compare.ts @@ -0,0 +1,66 @@ +import { createWebFetchTool } from "../src/agents/tools/web-tools.js"; + +const DEFAULT_URLS = [ + "https://example.com/", + "https://news.ycombinator.com/", + "https://www.reddit.com/r/javascript/", + "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent", + "https://httpbin.org/html", +]; + +const urls = process.argv.slice(2); +const targets = urls.length > 0 ? urls : DEFAULT_URLS; + +async function runFetch(url: string, readability: boolean) { + if (!readability) { + throw new Error("Basic extraction removed. Set readability=true or enable Firecrawl."); + } + const tool = createWebFetchTool({ + config: { + tools: { + web: { fetch: { readability, cacheTtlMinutes: 0, firecrawl: { enabled: false } } }, + }, + }, + sandboxed: false, + }); + if (!tool) { + throw new Error("web_fetch tool is disabled"); + } + const result = await tool.execute("test", { url, extractMode: "markdown" }); + return result.details as { + text?: string; + title?: string; + extractor?: string; + length?: number; + truncated?: boolean; + }; +} + +function truncate(value: string, max = 160): string { + if (!value) { + return ""; + } + return value.length > max ? `${value.slice(0, max)}…` : value; +} + +async function run() { + for (const url of targets) { + console.log(`\n=== ${url}`); + const readable = await runFetch(url, true); + + console.log( + `readability: ${readable.extractor ?? "unknown"} len=${readable.length ?? 0} title=${truncate( + readable.title ?? "", + 80, + )}`, + ); + if (readable.text) { + console.log(`readability sample: ${truncate(readable.text)}`); + } + } +} + +run().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/release-check.ts b/scripts/release-check.ts new file mode 100644 index 0000000000000000000000000000000000000000..7dba830c79091c179ab74f73f17bdd6309660464 --- /dev/null +++ b/scripts/release-check.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env -S node --import tsx + +import { execSync } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +type PackFile = { path: string }; +type PackResult = { files?: PackFile[] }; + +const requiredPaths = ["dist/discord/send.js", "dist/hooks/gmail.js", "dist/whatsapp/normalize.js"]; +const forbiddenPrefixes = ["dist/OpenClaw.app/"]; + +type PackageJson = { + name?: string; + version?: string; +}; + +function runPackDry(): PackResult[] { + const raw = execSync("npm pack --dry-run --json --ignore-scripts", { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 1024 * 1024 * 100, + }); + return JSON.parse(raw) as PackResult[]; +} + +function checkPluginVersions() { + const rootPackagePath = resolve("package.json"); + const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson; + const targetVersion = rootPackage.version; + + if (!targetVersion) { + console.error("release-check: root package.json missing version."); + process.exit(1); + } + + const extensionsDir = resolve("extensions"); + const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => + entry.isDirectory(), + ); + + const mismatches: string[] = []; + + for (const entry of entries) { + const packagePath = join(extensionsDir, entry.name, "package.json"); + let pkg: PackageJson; + try { + pkg = JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson; + } catch { + continue; + } + + if (!pkg.name || !pkg.version) { + continue; + } + + if (pkg.version !== targetVersion) { + mismatches.push(`${pkg.name} (${pkg.version})`); + } + } + + if (mismatches.length > 0) { + console.error(`release-check: plugin versions must match ${targetVersion}:`); + for (const item of mismatches) { + console.error(` - ${item}`); + } + console.error("release-check: run `pnpm plugins:sync` to align plugin versions."); + process.exit(1); + } +} + +function main() { + checkPluginVersions(); + + const results = runPackDry(); + const files = results.flatMap((entry) => entry.files ?? []); + const paths = new Set(files.map((file) => file.path)); + + const missing = requiredPaths.filter((path) => !paths.has(path)); + const forbidden = [...paths].filter((path) => + forbiddenPrefixes.some((prefix) => path.startsWith(prefix)), + ); + + if (missing.length > 0 || forbidden.length > 0) { + if (missing.length > 0) { + console.error("release-check: missing files in npm pack:"); + for (const path of missing) { + console.error(` - ${path}`); + } + } + if (forbidden.length > 0) { + console.error("release-check: forbidden files in npm pack:"); + for (const path of forbidden) { + console.error(` - ${path}`); + } + } + process.exit(1); + } + + console.log("release-check: npm pack contents look OK."); +} + +main(); diff --git a/scripts/repro/tsx-name-repro.ts b/scripts/repro/tsx-name-repro.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f5161d40bf541411971e4c3977505d4fb7727f9 --- /dev/null +++ b/scripts/repro/tsx-name-repro.ts @@ -0,0 +1,3 @@ +import "../../src/logging/subsystem.js"; + +console.log("tsx-name-repro: loaded logging/subsystem"); diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh new file mode 100644 index 0000000000000000000000000000000000000000..0db3fad39b0e5dde06bd4b8b7b9f9d1a01bbb890 --- /dev/null +++ b/scripts/restart-mac.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +# Reset OpenClaw like Trimmy: kill running instances, rebuild, repackage, relaunch, verify. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_BUNDLE="${OPENCLAW_APP_BUNDLE:-}" +APP_PROCESS_PATTERN="OpenClaw.app/Contents/MacOS/OpenClaw" +DEBUG_PROCESS_PATTERN="${ROOT_DIR}/apps/macos/.build/debug/OpenClaw" +LOCAL_PROCESS_PATTERN="${ROOT_DIR}/apps/macos/.build-local/debug/OpenClaw" +RELEASE_PROCESS_PATTERN="${ROOT_DIR}/apps/macos/.build/release/OpenClaw" +LAUNCH_AGENT="${HOME}/Library/LaunchAgents/ai.openclaw.mac.plist" +LOCK_KEY="$(printf '%s' "${ROOT_DIR}" | shasum -a 256 | cut -c1-8)" +LOCK_DIR="${TMPDIR:-/tmp}/openclaw-restart-${LOCK_KEY}" +LOCK_PID_FILE="${LOCK_DIR}/pid" +WAIT_FOR_LOCK=0 +LOG_PATH="${OPENCLAW_RESTART_LOG:-/tmp/openclaw-restart.log}" +NO_SIGN=0 +SIGN=0 +AUTO_DETECT_SIGNING=1 +GATEWAY_WAIT_SECONDS="${OPENCLAW_GATEWAY_WAIT_SECONDS:-0}" +LAUNCHAGENT_DISABLE_MARKER="${HOME}/.openclaw/disable-launchagent" +ATTACH_ONLY=1 + +log() { printf '%s\n' "$*"; } +fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +# Ensure local node binaries (rolldown, pnpm) are discoverable for the steps below. +export PATH="${ROOT_DIR}/node_modules/.bin:${PATH}" + +run_step() { + local label="$1"; shift + log "==> ${label}" + if ! "$@"; then + fail "${label} failed" + fi +} + +cleanup() { + if [[ -d "${LOCK_DIR}" ]]; then + rm -rf "${LOCK_DIR}" + fi +} + +acquire_lock() { + while true; do + if mkdir "${LOCK_DIR}" 2>/dev/null; then + echo "$$" > "${LOCK_PID_FILE}" + return 0 + fi + + local existing_pid="" + if [[ -f "${LOCK_PID_FILE}" ]]; then + existing_pid="$(cat "${LOCK_PID_FILE}" 2>/dev/null || true)" + fi + + if [[ -n "${existing_pid}" ]] && kill -0 "${existing_pid}" 2>/dev/null; then + if [[ "${WAIT_FOR_LOCK}" == "1" ]]; then + log "==> Another restart is running (pid ${existing_pid}); waiting..." + while kill -0 "${existing_pid}" 2>/dev/null; do + sleep 1 + done + continue + fi + log "==> Another restart is running (pid ${existing_pid}); re-run with --wait." + exit 0 + fi + + rm -rf "${LOCK_DIR}" + done +} + +check_signing_keys() { + security find-identity -p codesigning -v 2>/dev/null \ + | grep -Eq '(Developer ID Application|Apple Distribution|Apple Development)' +} + +trap cleanup EXIT INT TERM + +for arg in "$@"; do + case "${arg}" in + --wait|-w) WAIT_FOR_LOCK=1 ;; + --no-sign) NO_SIGN=1; AUTO_DETECT_SIGNING=0 ;; + --sign) SIGN=1; AUTO_DETECT_SIGNING=0 ;; + --attach-only) ATTACH_ONLY=1 ;; + --no-attach-only) ATTACH_ONLY=0 ;; + --help|-h) + log "Usage: $(basename "$0") [--wait] [--no-sign] [--sign] [--attach-only|--no-attach-only]" + log " --wait Wait for other restart to complete instead of exiting" + log " --no-sign Force no code signing (fastest for development)" + log " --sign Force code signing (will fail if no signing key available)" + log " --attach-only Launch app with --attach-only (skip launchd install)" + log " --no-attach-only Launch app without attach-only override" + log "" + log "Env:" + log " OPENCLAW_GATEWAY_WAIT_SECONDS=0 Wait time before gateway port check (unsigned only)" + log "" + log "Unsigned recovery:" + log " node openclaw.mjs daemon install --force --runtime node" + log " node openclaw.mjs daemon restart" + log "" + log "Reset unsigned overrides:" + log " rm ~/.openclaw/disable-launchagent" + log "" + log "Default behavior: Auto-detect signing keys, fallback to --no-sign if none found" + exit 0 + ;; + *) ;; + esac +done + +if [[ "$NO_SIGN" -eq 1 && "$SIGN" -eq 1 ]]; then + fail "Cannot use --sign and --no-sign together" +fi + +mkdir -p "$(dirname "$LOG_PATH")" +rm -f "$LOG_PATH" +exec > >(tee "$LOG_PATH") 2>&1 +log "==> Log: ${LOG_PATH}" +if [[ "$NO_SIGN" -eq 1 ]]; then + log "==> Using --no-sign (unsigned flow enabled)" +fi +if [[ "$ATTACH_ONLY" -eq 1 ]]; then + log "==> Using --attach-only (skip launchd install)" +fi + +acquire_lock + +kill_all_openclaw() { + for _ in {1..10}; do + pkill -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true + pkill -f "${DEBUG_PROCESS_PATTERN}" 2>/dev/null || true + pkill -f "${LOCAL_PROCESS_PATTERN}" 2>/dev/null || true + pkill -f "${RELEASE_PROCESS_PATTERN}" 2>/dev/null || true + pkill -x "OpenClaw" 2>/dev/null || true + if ! pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1 \ + && ! pgrep -f "${DEBUG_PROCESS_PATTERN}" >/dev/null 2>&1 \ + && ! pgrep -f "${LOCAL_PROCESS_PATTERN}" >/dev/null 2>&1 \ + && ! pgrep -f "${RELEASE_PROCESS_PATTERN}" >/dev/null 2>&1 \ + && ! pgrep -x "OpenClaw" >/dev/null 2>&1; then + return 0 + fi + sleep 0.3 + done +} + +stop_launch_agent() { + launchctl bootout gui/"$UID"/ai.openclaw.mac 2>/dev/null || true +} + +# 1) Kill all running instances first. +log "==> Killing existing OpenClaw instances" +kill_all_openclaw +stop_launch_agent + +# Bundle Gateway-hosted Canvas A2UI assets. +run_step "bundle canvas a2ui" bash -lc "cd '${ROOT_DIR}' && pnpm canvas:a2ui:bundle" + +# 2) Rebuild into the same path the packager consumes (.build). +run_step "clean build cache" bash -lc "cd '${ROOT_DIR}/apps/macos' && rm -rf .build .build-swift .swiftpm 2>/dev/null || true" +run_step "swift build" bash -lc "cd '${ROOT_DIR}/apps/macos' && swift build -q --product OpenClaw" + +if [ "$AUTO_DETECT_SIGNING" -eq 1 ]; then + if check_signing_keys; then + log "==> Signing keys detected, will code sign" + SIGN=1 + else + log "==> No signing keys found, will skip code signing (--no-sign)" + NO_SIGN=1 + fi +fi + +if [ "$NO_SIGN" -eq 1 ]; then + export ALLOW_ADHOC_SIGNING=1 + export SIGN_IDENTITY="-" + mkdir -p "${HOME}/.openclaw" + run_step "disable launchagent writes" /usr/bin/touch "${LAUNCHAGENT_DISABLE_MARKER}" +elif [ "$SIGN" -eq 1 ]; then + if ! check_signing_keys; then + fail "No signing identity found. Use --no-sign or install a signing key." + fi + unset ALLOW_ADHOC_SIGNING + unset SIGN_IDENTITY +fi + +# 3) Package app (no embedded gateway). +run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=${SKIP_TSC:-1} '${ROOT_DIR}/scripts/package-mac-app.sh'" + +choose_app_bundle() { + if [[ -n "${APP_BUNDLE}" && -d "${APP_BUNDLE}" ]]; then + return 0 + fi + + if [[ -d "/Applications/OpenClaw.app" ]]; then + APP_BUNDLE="/Applications/OpenClaw.app" + return 0 + fi + + if [[ -d "${ROOT_DIR}/dist/OpenClaw.app" ]]; then + APP_BUNDLE="${ROOT_DIR}/dist/OpenClaw.app" + if [[ ! -d "${APP_BUNDLE}/Contents/Frameworks/Sparkle.framework" ]]; then + fail "dist/OpenClaw.app missing Sparkle after packaging" + fi + return 0 + fi + + fail "App bundle not found. Set OPENCLAW_APP_BUNDLE to your installed OpenClaw.app" +} + +choose_app_bundle + +# When signed, clear any previous launchagent override marker. +if [[ "$NO_SIGN" -ne 1 && "$ATTACH_ONLY" -ne 1 && -f "${LAUNCHAGENT_DISABLE_MARKER}" ]]; then + run_step "clear launchagent disable marker" /bin/rm -f "${LAUNCHAGENT_DISABLE_MARKER}" +fi + +# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (before the app launches). +# This reduces noisy "could not connect" errors during app startup. +if [ "$NO_SIGN" -eq 1 ] && [ "$ATTACH_ONLY" -ne 1 ]; then + run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node openclaw.mjs daemon install --force --runtime node" + run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node openclaw.mjs daemon restart" + if [[ "${GATEWAY_WAIT_SECONDS}" -gt 0 ]]; then + run_step "wait for gateway (unsigned)" sleep "${GATEWAY_WAIT_SECONDS}" + fi + GATEWAY_PORT="$( + node -e ' + const fs = require("node:fs"); + const path = require("node:path"); + try { + const raw = fs.readFileSync(path.join(process.env.HOME, ".openclaw", "openclaw.json"), "utf8"); + const cfg = JSON.parse(raw); + const port = cfg && cfg.gateway && typeof cfg.gateway.port === "number" ? cfg.gateway.port : 18789; + process.stdout.write(String(port)); + } catch { + process.stdout.write("18789"); + } + ' + )" + run_step "verify gateway port ${GATEWAY_PORT} (unsigned)" bash -lc "lsof -iTCP:${GATEWAY_PORT} -sTCP:LISTEN | head -n 5 || true" +fi + +ATTACH_ONLY_ARGS=() +if [[ "$ATTACH_ONLY" -eq 1 ]]; then + ATTACH_ONLY_ARGS+=(--args --attach-only) +fi + +# 4) Launch the installed app in the foreground so the menu bar extra appears. +# LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.). +# That can cause launchd spawn failures and is undesirable for a GUI app anyway. +run_step "launch app" env -i \ + HOME="${HOME}" \ + USER="${USER:-$(id -un)}" \ + LOGNAME="${LOGNAME:-$(id -un)}" \ + TMPDIR="${TMPDIR:-/tmp}" \ + PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ + LANG="${LANG:-en_US.UTF-8}" \ + /usr/bin/open "${APP_BUNDLE}" ${ATTACH_ONLY_ARGS[@]:+"${ATTACH_ONLY_ARGS[@]}"} + +# 5) Verify the app is alive. +sleep 1.5 +if pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1; then + log "OK: OpenClaw is running." +else + fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)." +fi + +if [ "$NO_SIGN" -eq 1 ] && [ "$ATTACH_ONLY" -ne 1 ]; then + run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/bot.molt.gateway.plist' | head -n 40 || true" +fi diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs new file mode 100644 index 0000000000000000000000000000000000000000..2a9b5c8b5dfcf7a2bfc2c6fe132561a0f638387e --- /dev/null +++ b/scripts/run-node.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const args = process.argv.slice(2); +const env = { ...process.env }; +const cwd = process.cwd(); +const compilerOverride = env.OPENCLAW_TS_COMPILER ?? env.CLAWDBOT_TS_COMPILER; +const compiler = compilerOverride === "tsc" ? "tsc" : "tsgo"; +const projectArgs = ["--project", "tsconfig.json"]; + +const distRoot = path.join(cwd, "dist"); +const distEntry = path.join(distRoot, "/entry.js"); +const buildStampPath = path.join(distRoot, ".buildstamp"); +const srcRoot = path.join(cwd, "src"); +const configFiles = [path.join(cwd, "tsconfig.json"), path.join(cwd, "package.json")]; + +const statMtime = (filePath) => { + try { + return fs.statSync(filePath).mtimeMs; + } catch { + return null; + } +}; + +const isExcludedSource = (filePath) => { + const relativePath = path.relative(srcRoot, filePath); + if (relativePath.startsWith("..")) { + return false; + } + return ( + relativePath.endsWith(".test.ts") || + relativePath.endsWith(".test.tsx") || + relativePath.endsWith(`test-helpers.ts`) + ); +}; + +const findLatestMtime = (dirPath, shouldSkip) => { + let latest = null; + const queue = [dirPath]; + while (queue.length > 0) { + const current = queue.pop(); + if (!current) { + continue; + } + let entries = []; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + queue.push(fullPath); + continue; + } + if (!entry.isFile()) { + continue; + } + if (shouldSkip?.(fullPath)) { + continue; + } + const mtime = statMtime(fullPath); + if (mtime == null) { + continue; + } + if (latest == null || mtime > latest) { + latest = mtime; + } + } + } + return latest; +}; + +const shouldBuild = () => { + if (env.OPENCLAW_FORCE_BUILD === "1") { + return true; + } + const stampMtime = statMtime(buildStampPath); + if (stampMtime == null) { + return true; + } + if (statMtime(distEntry) == null) { + return true; + } + + for (const filePath of configFiles) { + const mtime = statMtime(filePath); + if (mtime != null && mtime > stampMtime) { + return true; + } + } + + const srcMtime = findLatestMtime(srcRoot, isExcludedSource); + if (srcMtime != null && srcMtime > stampMtime) { + return true; + } + return false; +}; + +const logRunner = (message) => { + if (env.OPENCLAW_RUNNER_LOG === "0") { + return; + } + process.stderr.write(`[openclaw] ${message}\n`); +}; + +const runNode = () => { + const nodeProcess = spawn(process.execPath, ["openclaw.mjs", ...args], { + cwd, + env, + stdio: "inherit", + }); + + nodeProcess.on("exit", (exitCode, exitSignal) => { + if (exitSignal) { + process.exit(1); + } + process.exit(exitCode ?? 1); + }); +}; + +const writeBuildStamp = () => { + try { + fs.mkdirSync(distRoot, { recursive: true }); + fs.writeFileSync(buildStampPath, `${Date.now()}\n`); + } catch (error) { + // Best-effort stamp; still allow the runner to start. + logRunner(`Failed to write build stamp: ${error?.message ?? "unknown error"}`); + } +}; + +if (!shouldBuild()) { + runNode(); +} else { + logRunner("Building TypeScript (dist is stale)."); + const pnpmArgs = ["exec", compiler, ...projectArgs]; + const buildCmd = process.platform === "win32" ? "cmd.exe" : "pnpm"; + const buildArgs = + process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...pnpmArgs] : pnpmArgs; + const build = spawn(buildCmd, buildArgs, { + cwd, + env, + stdio: "inherit", + }); + + build.on("exit", (code, signal) => { + if (signal) { + process.exit(1); + } + if (code !== 0 && code !== null) { + process.exit(code); + } + writeBuildStamp(); + runNode(); + }); +} diff --git a/scripts/sandbox-browser-entrypoint.sh b/scripts/sandbox-browser-entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..4a8da268f0a855145c5a42f4bebf140148430282 --- /dev/null +++ b/scripts/sandbox-browser-entrypoint.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DISPLAY=:1 +export HOME=/tmp/openclaw-home +export XDG_CONFIG_HOME="${HOME}/.config" +export XDG_CACHE_HOME="${HOME}/.cache" + +CDP_PORT="${OPENCLAW_BROWSER_CDP_PORT:-${CLAWDBOT_BROWSER_CDP_PORT:-9222}}" +VNC_PORT="${OPENCLAW_BROWSER_VNC_PORT:-${CLAWDBOT_BROWSER_VNC_PORT:-5900}}" +NOVNC_PORT="${OPENCLAW_BROWSER_NOVNC_PORT:-${CLAWDBOT_BROWSER_NOVNC_PORT:-6080}}" +ENABLE_NOVNC="${OPENCLAW_BROWSER_ENABLE_NOVNC:-${CLAWDBOT_BROWSER_ENABLE_NOVNC:-1}}" +HEADLESS="${OPENCLAW_BROWSER_HEADLESS:-${CLAWDBOT_BROWSER_HEADLESS:-0}}" + +mkdir -p "${HOME}" "${HOME}/.chrome" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}" + +Xvfb :1 -screen 0 1280x800x24 -ac -nolisten tcp & + +if [[ "${HEADLESS}" == "1" ]]; then + CHROME_ARGS=( + "--headless=new" + "--disable-gpu" + ) +else + CHROME_ARGS=() +fi + +if [[ "${CDP_PORT}" -ge 65535 ]]; then + CHROME_CDP_PORT="$((CDP_PORT - 1))" +else + CHROME_CDP_PORT="$((CDP_PORT + 1))" +fi + +CHROME_ARGS+=( + "--remote-debugging-address=127.0.0.1" + "--remote-debugging-port=${CHROME_CDP_PORT}" + "--user-data-dir=${HOME}/.chrome" + "--no-first-run" + "--no-default-browser-check" + "--disable-dev-shm-usage" + "--disable-background-networking" + "--disable-features=TranslateUI" + "--disable-breakpad" + "--disable-crash-reporter" + "--metrics-recording-only" + "--no-sandbox" +) + +chromium "${CHROME_ARGS[@]}" about:blank & + +for _ in $(seq 1 50); do + if curl -sS --max-time 1 "http://127.0.0.1:${CHROME_CDP_PORT}/json/version" >/dev/null; then + break + fi + sleep 0.1 +done + +socat \ + TCP-LISTEN:"${CDP_PORT}",fork,reuseaddr,bind=0.0.0.0 \ + TCP:127.0.0.1:"${CHROME_CDP_PORT}" & + +if [[ "${ENABLE_NOVNC}" == "1" && "${HEADLESS}" != "1" ]]; then + x11vnc -display :1 -rfbport "${VNC_PORT}" -shared -forever -nopw -localhost & + websockify --web /usr/share/novnc/ "${NOVNC_PORT}" "localhost:${VNC_PORT}" & +fi + +wait -n diff --git a/scripts/sandbox-browser-setup.sh b/scripts/sandbox-browser-setup.sh new file mode 100644 index 0000000000000000000000000000000000000000..74b4605eb0dca7310d3a28ae15953ff8aada5787 --- /dev/null +++ b/scripts/sandbox-browser-setup.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_NAME="openclaw-sandbox-browser:bookworm-slim" + +docker build -t "${IMAGE_NAME}" -f Dockerfile.sandbox-browser . +echo "Built ${IMAGE_NAME}" diff --git a/scripts/sandbox-common-setup.sh b/scripts/sandbox-common-setup.sh new file mode 100644 index 0000000000000000000000000000000000000000..1291d27a8da87d90b0b065fe87a47349eed0bb98 --- /dev/null +++ b/scripts/sandbox-common-setup.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_IMAGE="${BASE_IMAGE:-openclaw-sandbox:bookworm-slim}" +TARGET_IMAGE="${TARGET_IMAGE:-openclaw-sandbox-common:bookworm-slim}" +PACKAGES="${PACKAGES:-curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file}" +INSTALL_PNPM="${INSTALL_PNPM:-1}" +INSTALL_BUN="${INSTALL_BUN:-1}" +BUN_INSTALL_DIR="${BUN_INSTALL_DIR:-/opt/bun}" +INSTALL_BREW="${INSTALL_BREW:-1}" +BREW_INSTALL_DIR="${BREW_INSTALL_DIR:-/home/linuxbrew/.linuxbrew}" + +if ! docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then + echo "Base image missing: ${BASE_IMAGE}" + echo "Building base image via scripts/sandbox-setup.sh..." + scripts/sandbox-setup.sh +fi + +echo "Building ${TARGET_IMAGE} with: ${PACKAGES}" + +docker build \ + -t "${TARGET_IMAGE}" \ + --build-arg INSTALL_PNPM="${INSTALL_PNPM}" \ + --build-arg INSTALL_BUN="${INSTALL_BUN}" \ + --build-arg BUN_INSTALL_DIR="${BUN_INSTALL_DIR}" \ + --build-arg INSTALL_BREW="${INSTALL_BREW}" \ + --build-arg BREW_INSTALL_DIR="${BREW_INSTALL_DIR}" \ + - </dev/null 2>&1; then useradd -m -s /bin/bash linuxbrew; fi; \\ + mkdir -p "\${BREW_INSTALL_DIR}"; \\ + chown -R linuxbrew:linuxbrew "\$(dirname "\${BREW_INSTALL_DIR}")"; \\ + su - linuxbrew -c "NONINTERACTIVE=1 CI=1 /bin/bash -c '\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'"; \\ + if [ ! -e "\${BREW_INSTALL_DIR}/Library" ]; then ln -s "\${BREW_INSTALL_DIR}/Homebrew/Library" "\${BREW_INSTALL_DIR}/Library"; fi; \\ + if [ ! -x "\${BREW_INSTALL_DIR}/bin/brew" ]; then echo "brew install failed"; exit 1; fi; \\ + ln -sf "\${BREW_INSTALL_DIR}/bin/brew" /usr/local/bin/brew; \\ +fi +EOF + +cat <([ + ["channel", "1d76db"], + ["app", "6f42c1"], + ["extensions", "0e8a16"], + ["docs", "0075ca"], + ["cli", "f9d0c4"], + ["gateway", "d4c5f9"], +]); + +const configPath = resolve(".github/labeler.yml"); +const labelNames = extractLabelNames(readFileSync(configPath, "utf8")); + +if (!labelNames.length) { + throw new Error("labeler.yml must declare at least one label."); +} + +const repo = resolveRepo(); +const existing = fetchExistingLabels(repo); + +const missing = labelNames.filter((label) => !existing.has(label)); +if (!missing.length) { + console.log("All labeler labels already exist."); + process.exit(0); +} + +for (const label of missing) { + const color = pickColor(label); + execFileSync( + "gh", + ["api", "-X", "POST", `repos/${repo}/labels`, "-f", `name=${label}`, "-f", `color=${color}`], + { stdio: "inherit" }, + ); + console.log(`Created label: ${label}`); +} + +function extractLabelNames(contents: string): string[] { + const labels: string[] = []; + for (const line of contents.split("\n")) { + if (!line.trim() || line.trimStart().startsWith("#")) { + continue; + } + if (/^\s/.test(line)) { + continue; + } + const match = line.match(/^(["'])(.+)\1\s*:/) ?? line.match(/^([^:]+):/); + if (match) { + const name = (match[2] ?? match[1] ?? "").trim(); + if (name) { + labels.push(name); + } + } + } + return labels; +} + +function pickColor(label: string): string { + const prefix = label.includes(":") ? label.split(":", 1)[0].trim() : label.trim(); + return COLOR_BY_PREFIX.get(prefix) ?? "ededed"; +} + +function resolveRepo(): string { + const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], { + encoding: "utf8", + }).trim(); + + if (!remote) { + throw new Error("Unable to determine repository from git remote."); + } + + if (remote.startsWith("git@github.com:")) { + return remote.replace("git@github.com:", "").replace(/\.git$/, ""); + } + + if (remote.startsWith("https://github.com/")) { + return remote.replace("https://github.com/", "").replace(/\.git$/, ""); + } + + throw new Error(`Unsupported GitHub remote: ${remote}`); +} + +function fetchExistingLabels(repo: string): Map { + const raw = execFileSync("gh", ["api", `repos/${repo}/labels?per_page=100`, "--paginate"], { + encoding: "utf8", + }); + const labels = JSON.parse(raw) as RepoLabel[]; + return new Map(labels.map((label) => [label.name, label])); +} diff --git a/scripts/sync-moonshot-docs.ts b/scripts/sync-moonshot-docs.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5afc543cfd60f904a7d66edcb49fa9f1e221e4d --- /dev/null +++ b/scripts/sync-moonshot-docs.ts @@ -0,0 +1,125 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + MOONSHOT_KIMI_K2_CONTEXT_WINDOW, + MOONSHOT_KIMI_K2_COST, + MOONSHOT_KIMI_K2_INPUT, + MOONSHOT_KIMI_K2_MAX_TOKENS, + MOONSHOT_KIMI_K2_MODELS, +} from "../ui/src/ui/data/moonshot-kimi-k2"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, ".."); + +function replaceBlockLines( + text: string, + startMarker: string, + endMarker: string, + lines: string[], +): string { + const startIndex = text.indexOf(startMarker); + if (startIndex === -1) { + throw new Error(`Missing start marker: ${startMarker}`); + } + const endIndex = text.indexOf(endMarker, startIndex); + if (endIndex === -1) { + throw new Error(`Missing end marker: ${endMarker}`); + } + + const startLineStart = text.lastIndexOf("\n", startIndex); + const startLineStartIndex = startLineStart === -1 ? 0 : startLineStart + 1; + const indent = text.slice(startLineStartIndex, startIndex); + + const endLineEnd = text.indexOf("\n", endIndex); + const endLineEndIndex = endLineEnd === -1 ? text.length : endLineEnd + 1; + + const before = text.slice(0, startLineStartIndex); + const after = text.slice(endLineEndIndex); + + const replacementLines = [ + `${indent}${startMarker}`, + ...lines.map((line) => `${indent}${line}`), + `${indent}${endMarker}`, + ]; + + const replacement = replacementLines.join("\n"); + if (!after) { + return `${before}${replacement}`; + } + return `${before}${replacement}\n${after}`; +} + +function renderKimiK2Ids(prefix: string) { + return MOONSHOT_KIMI_K2_MODELS.map((model) => `- \`${prefix}${model.id}\``); +} + +function renderMoonshotAliases() { + return MOONSHOT_KIMI_K2_MODELS.map((model, index) => { + const isLast = index === MOONSHOT_KIMI_K2_MODELS.length - 1; + const suffix = isLast ? "" : ","; + return `"moonshot/${model.id}": { alias: "${model.alias}" }${suffix}`; + }); +} + +function renderMoonshotModels() { + const input = JSON.stringify([...MOONSHOT_KIMI_K2_INPUT]); + const cost = `input: ${MOONSHOT_KIMI_K2_COST.input}, output: ${MOONSHOT_KIMI_K2_COST.output}, cacheRead: ${MOONSHOT_KIMI_K2_COST.cacheRead}, cacheWrite: ${MOONSHOT_KIMI_K2_COST.cacheWrite}`; + + return MOONSHOT_KIMI_K2_MODELS.flatMap((model, index) => { + const isLast = index === MOONSHOT_KIMI_K2_MODELS.length - 1; + const closing = isLast ? "}" : "},"; + return [ + "{", + ` id: "${model.id}",`, + ` name: "${model.name}",`, + ` reasoning: ${model.reasoning},`, + ` input: ${input},`, + ` cost: { ${cost} },`, + ` contextWindow: ${MOONSHOT_KIMI_K2_CONTEXT_WINDOW},`, + ` maxTokens: ${MOONSHOT_KIMI_K2_MAX_TOKENS}`, + closing, + ]; + }); +} + +async function syncMoonshotDocs() { + const moonshotDoc = path.join(repoRoot, "docs/providers/moonshot.md"); + const conceptsDoc = path.join(repoRoot, "docs/concepts/model-providers.md"); + + let moonshotText = await readFile(moonshotDoc, "utf8"); + moonshotText = replaceBlockLines( + moonshotText, + "{/_ moonshot-kimi-k2-ids:start _/ && null}", + "{/_ moonshot-kimi-k2-ids:end _/ && null}", + renderKimiK2Ids(""), + ); + moonshotText = replaceBlockLines( + moonshotText, + "// moonshot-kimi-k2-aliases:start", + "// moonshot-kimi-k2-aliases:end", + renderMoonshotAliases(), + ); + moonshotText = replaceBlockLines( + moonshotText, + "// moonshot-kimi-k2-models:start", + "// moonshot-kimi-k2-models:end", + renderMoonshotModels(), + ); + + let conceptsText = await readFile(conceptsDoc, "utf8"); + conceptsText = replaceBlockLines( + conceptsText, + "{/_ moonshot-kimi-k2-model-refs:start _/ && null}", + "{/_ moonshot-kimi-k2-model-refs:end _/ && null}", + renderKimiK2Ids("moonshot/"), + ); + + await writeFile(moonshotDoc, moonshotText); + await writeFile(conceptsDoc, conceptsText); +} + +syncMoonshotDocs().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/sync-plugin-versions.ts b/scripts/sync-plugin-versions.ts new file mode 100644 index 0000000000000000000000000000000000000000..865b9b7d4cfe4acf998215e8e0dc929ee843ae25 --- /dev/null +++ b/scripts/sync-plugin-versions.ts @@ -0,0 +1,76 @@ +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +type PackageJson = { + name?: string; + version?: string; +}; + +const rootPackagePath = resolve("package.json"); +const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson; +const targetVersion = rootPackage.version; + +if (!targetVersion) { + throw new Error("Root package.json missing version."); +} + +const extensionsDir = resolve("extensions"); +const dirs = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => + entry.isDirectory(), +); + +const updated: string[] = []; +const changelogged: string[] = []; +const skipped: string[] = []; + +function ensureChangelogEntry(changelogPath: string, version: string): boolean { + if (!existsSync(changelogPath)) { + return false; + } + const content = readFileSync(changelogPath, "utf8"); + if (content.includes(`## ${version}`)) { + return false; + } + const entry = `## ${version}\n\n### Changes\n- Version alignment with core OpenClaw release numbers.\n\n`; + if (content.startsWith("# Changelog\n\n")) { + const next = content.replace("# Changelog\n\n", `# Changelog\n\n${entry}`); + writeFileSync(changelogPath, next); + return true; + } + const next = `# Changelog\n\n${entry}${content.trimStart()}`; + writeFileSync(changelogPath, `${next}\n`); + return true; +} + +for (const dir of dirs) { + const packagePath = join(extensionsDir, dir.name, "package.json"); + let pkg: PackageJson; + try { + pkg = JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson; + } catch { + continue; + } + + if (!pkg.name) { + skipped.push(dir.name); + continue; + } + + const changelogPath = join(extensionsDir, dir.name, "CHANGELOG.md"); + if (ensureChangelogEntry(changelogPath, targetVersion)) { + changelogged.push(pkg.name); + } + + if (pkg.version === targetVersion) { + skipped.push(pkg.name); + continue; + } + + pkg.version = targetVersion; + writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); + updated.push(pkg.name); +} + +console.log( + `Synced plugin versions to ${targetVersion}. Updated: ${updated.length}. Changelogged: ${changelogged.length}. Skipped: ${skipped.length}.`, +); diff --git a/scripts/systemd/openclaw-auth-monitor.service b/scripts/systemd/openclaw-auth-monitor.service new file mode 100644 index 0000000000000000000000000000000000000000..ea2263b5d7478f0e4b2114060842f3b3cd1b4c35 --- /dev/null +++ b/scripts/systemd/openclaw-auth-monitor.service @@ -0,0 +1,14 @@ +[Unit] +Description=OpenClaw Auth Expiry Monitor +After=network.target + +[Service] +Type=oneshot +ExecStart=/home/admin/openclaw/scripts/auth-monitor.sh +# Configure notification channels via environment +Environment=WARN_HOURS=2 +# Environment=NOTIFY_PHONE=+1234567890 +# Environment=NOTIFY_NTFY=openclaw-alerts + +[Install] +WantedBy=default.target diff --git a/scripts/systemd/openclaw-auth-monitor.timer b/scripts/systemd/openclaw-auth-monitor.timer new file mode 100644 index 0000000000000000000000000000000000000000..c05af3413336a8d72994168cdbd7d6ac121dbb79 --- /dev/null +++ b/scripts/systemd/openclaw-auth-monitor.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Check OpenClaw auth expiry every 30 minutes + +[Timer] +OnBootSec=5min +OnUnitActiveSec=30min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/scripts/termux-auth-widget.sh b/scripts/termux-auth-widget.sh new file mode 100644 index 0000000000000000000000000000000000000000..bb8c69736fff2b454726f7f6c9aa7955d8a7716a --- /dev/null +++ b/scripts/termux-auth-widget.sh @@ -0,0 +1,81 @@ +#!/data/data/com.termux/files/usr/bin/bash +# OpenClaw Auth Widget for Termux +# Place in ~/.shortcuts/ for Termux:Widget +# +# This widget checks auth status and helps with re-auth if needed. +# It's designed for quick one-tap checking from phone home screen. + +# Server hostname (via Tailscale or SSH config) +SERVER="${OPENCLAW_SERVER:-${CLAWDBOT_SERVER:-l36}}" + +# Check auth status +termux-toast "Checking OpenClaw auth..." + +STATUS=$(ssh "$SERVER" '$HOME/openclaw/scripts/claude-auth-status.sh simple' 2>&1) +EXIT_CODE=$? + +case "$STATUS" in + OK) + # Get remaining time + DETAILS=$(ssh "$SERVER" '$HOME/openclaw/scripts/claude-auth-status.sh json' 2>&1) + HOURS=$(echo "$DETAILS" | jq -r '.claude_code.status' | grep -oP '\d+(?=h)' || echo "?") + + termux-vibrate -d 50 + termux-toast "Auth OK (${HOURS}h left)" + ;; + + CLAUDE_EXPIRING|OPENCLAW_EXPIRING|CLAWDBOT_EXPIRING) + termux-vibrate -d 100 + + # Ask if user wants to re-auth now + CHOICE=$(termux-dialog radio -t "Auth Expiring Soon" -v "Re-auth now,Check later,Dismiss") + SELECTED=$(echo "$CHOICE" | jq -r '.text // "Dismiss"') + + case "$SELECTED" in + "Re-auth now") + termux-toast "Opening auth page..." + termux-open-url "https://console.anthropic.com/settings/api-keys" + + # Show instructions + termux-dialog confirm -t "Re-auth Instructions" -i "1. Create/copy API key from browser +2. Return here and tap OK +3. SSH to server and paste key" + + # Open terminal to server + am start -n com.termux/com.termux.app.TermuxActivity -a android.intent.action.MAIN + termux-toast "Run: ssh $SERVER '$HOME/openclaw/scripts/mobile-reauth.sh'" + ;; + *) + termux-toast "Reminder: Auth expires soon" + ;; + esac + ;; + + CLAUDE_EXPIRED|OPENCLAW_EXPIRED|CLAWDBOT_EXPIRED) + termux-vibrate -d 300 + + CHOICE=$(termux-dialog radio -t "Auth Expired!" -v "Re-auth now,Dismiss") + SELECTED=$(echo "$CHOICE" | jq -r '.text // "Dismiss"') + + case "$SELECTED" in + "Re-auth now") + termux-toast "Opening auth page..." + termux-open-url "https://console.anthropic.com/settings/api-keys" + + termux-dialog confirm -t "Re-auth Steps" -i "1. Create/copy API key from browser +2. Return here and tap OK to SSH" + + am start -n com.termux/com.termux.app.TermuxActivity -a android.intent.action.MAIN + termux-toast "Run: ssh $SERVER '$HOME/openclaw/scripts/mobile-reauth.sh'" + ;; + *) + termux-toast "Warning: OpenClaw won't work until re-auth" + ;; + esac + ;; + + *) + termux-vibrate -d 200 + termux-toast "Error: $STATUS" + ;; +esac diff --git a/scripts/termux-quick-auth.sh b/scripts/termux-quick-auth.sh new file mode 100644 index 0000000000000000000000000000000000000000..67cd76ff212f5c3ff2ce59a5925a651922f0d474 --- /dev/null +++ b/scripts/termux-quick-auth.sh @@ -0,0 +1,30 @@ +#!/data/data/com.termux/files/usr/bin/bash +# Quick Auth Check - Minimal widget for Termux +# Place in ~/.shortcuts/ for Termux:Widget +# +# One-tap: shows status toast +# If expired: directly opens auth URL + +SERVER="${OPENCLAW_SERVER:-${CLAWDBOT_SERVER:-l36}}" + +STATUS=$(ssh -o ConnectTimeout=5 "$SERVER" '$HOME/openclaw/scripts/claude-auth-status.sh simple' 2>&1) + +case "$STATUS" in + OK) + termux-toast -s "Auth OK" + ;; + *EXPIRING*) + termux-vibrate -d 100 + termux-toast "Auth expiring soon - tap again if needed" + ;; + *EXPIRED*|*MISSING*) + termux-vibrate -d 200 + termux-toast "Auth expired - opening console..." + termux-open-url "https://console.anthropic.com/settings/api-keys" + sleep 2 + termux-notification -t "OpenClaw Re-Auth" -c "After getting key, run: ssh $SERVER '~/openclaw/scripts/mobile-reauth.sh'" --id openclaw-auth + ;; + *) + termux-toast "Connection error" + ;; +esac diff --git a/scripts/termux-sync-widget.sh b/scripts/termux-sync-widget.sh new file mode 100644 index 0000000000000000000000000000000000000000..4c34ed04b42b37d1ddb3e28707eee29f6ebb9b09 --- /dev/null +++ b/scripts/termux-sync-widget.sh @@ -0,0 +1,25 @@ +#!/data/data/com.termux/files/usr/bin/bash +# OpenClaw OAuth Sync Widget +# Syncs Claude Code tokens to OpenClaw on l36 server +# Place in ~/.shortcuts/ on phone for Termux:Widget + +termux-toast "Syncing OpenClaw auth..." + +# Run sync on l36 server +SERVER="${OPENCLAW_SERVER:-${CLAWDBOT_SERVER:-l36}}" +RESULT=$(ssh "$SERVER" '/home/admin/openclaw/scripts/sync-claude-code-auth.sh' 2>&1) +EXIT_CODE=$? + +if [ $EXIT_CODE -eq 0 ]; then + # Extract expiry time from output + EXPIRY=$(echo "$RESULT" | grep "Token expires:" | cut -d: -f2-) + + termux-vibrate -d 100 + termux-toast "OpenClaw synced! Expires:${EXPIRY}" + + # Optional: restart openclaw service + ssh "$SERVER" 'systemctl --user restart openclaw' 2>/dev/null +else + termux-vibrate -d 300 + termux-toast "Sync failed: ${RESULT}" +fi diff --git a/scripts/test-cleanup-docker.sh b/scripts/test-cleanup-docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..dc8693a0c1ba82c6352594bb95d2bf6bf1728fe4 --- /dev/null +++ b/scripts/test-cleanup-docker.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE_NAME="${OPENCLAW_CLEANUP_SMOKE_IMAGE:-${CLAWDBOT_CLEANUP_SMOKE_IMAGE:-openclaw-cleanup-smoke:local}}" + +echo "==> Build image: $IMAGE_NAME" +docker build \ + -t "$IMAGE_NAME" \ + -f "$ROOT_DIR/scripts/docker/cleanup-smoke/Dockerfile" \ + "$ROOT_DIR" + +echo "==> Run cleanup smoke test" +docker run --rm -t "$IMAGE_NAME" diff --git a/scripts/test-force.ts b/scripts/test-force.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb691dde22384d1d9b1464d07a86373206c24501 --- /dev/null +++ b/scripts/test-force.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env -S node --import tsx +import { spawnSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { forceFreePort, type PortProcess } from "../src/cli/ports.js"; + +const DEFAULT_PORT = 18789; + +function killGatewayListeners(port: number): PortProcess[] { + try { + const killed = forceFreePort(port); + if (killed.length > 0) { + console.log( + `freed port ${port}; terminated: ${killed + .map((p) => `${p.command} (pid ${p.pid})`) + .join(", ")}`, + ); + } else { + console.log(`port ${port} already free`); + } + return killed; + } catch (err) { + console.error(`failed to free port ${port}: ${String(err)}`); + return []; + } +} + +function runTests() { + const isolatedLock = + process.env.OPENCLAW_GATEWAY_LOCK ?? + path.join(os.tmpdir(), `openclaw-gateway.lock.test.${Date.now()}`); + const result = spawnSync("pnpm", ["vitest", "run"], { + stdio: "inherit", + env: { + ...process.env, + OPENCLAW_GATEWAY_LOCK: isolatedLock, + }, + }); + if (result.error) { + console.error(`pnpm test failed to start: ${String(result.error)}`); + process.exit(1); + } + process.exit(result.status ?? 1); +} + +function main() { + const port = Number.parseInt(process.env.OPENCLAW_GATEWAY_PORT ?? `${DEFAULT_PORT}`, 10); + + console.log(`🧹 test:force - clearing gateway on port ${port}`); + const killed = killGatewayListeners(port); + if (killed.length === 0) { + console.log("no listeners to kill"); + } + + console.log("running pnpm test…"); + runTests(); +} + +main(); diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..31bb9d286cd26db66d478a8ce559170d355169b7 --- /dev/null +++ b/scripts/test-install-sh-docker.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SMOKE_IMAGE="${OPENCLAW_INSTALL_SMOKE_IMAGE:-${CLAWDBOT_INSTALL_SMOKE_IMAGE:-openclaw-install-smoke:local}}" +NONROOT_IMAGE="${OPENCLAW_INSTALL_NONROOT_IMAGE:-${CLAWDBOT_INSTALL_NONROOT_IMAGE:-openclaw-install-nonroot:local}}" +INSTALL_URL="${OPENCLAW_INSTALL_URL:-${CLAWDBOT_INSTALL_URL:-https://openclaw.bot/install.sh}}" +CLI_INSTALL_URL="${OPENCLAW_INSTALL_CLI_URL:-${CLAWDBOT_INSTALL_CLI_URL:-https://openclaw.bot/install-cli.sh}}" +SKIP_NONROOT="${OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT:-${CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT:-0}}" +LATEST_DIR="$(mktemp -d)" +LATEST_FILE="${LATEST_DIR}/latest" + +echo "==> Build smoke image (upgrade, root): $SMOKE_IMAGE" +docker build \ + -t "$SMOKE_IMAGE" \ + -f "$ROOT_DIR/scripts/docker/install-sh-smoke/Dockerfile" \ + "$ROOT_DIR/scripts/docker/install-sh-smoke" + +echo "==> Run installer smoke test (root): $INSTALL_URL" +docker run --rm -t \ + -v "${LATEST_DIR}:/out" \ + -e OPENCLAW_INSTALL_URL="$INSTALL_URL" \ + -e OPENCLAW_INSTALL_LATEST_OUT="/out/latest" \ + -e OPENCLAW_INSTALL_SMOKE_PREVIOUS="${OPENCLAW_INSTALL_SMOKE_PREVIOUS:-${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}}" \ + -e OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS="${OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS:-${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}}" \ + -e OPENCLAW_NO_ONBOARD=1 \ + -e DEBIAN_FRONTEND=noninteractive \ + "$SMOKE_IMAGE" + +LATEST_VERSION="" +if [[ -f "$LATEST_FILE" ]]; then + LATEST_VERSION="$(cat "$LATEST_FILE")" +fi + +if [[ "$SKIP_NONROOT" == "1" ]]; then + echo "==> Skip non-root installer smoke (OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1)" +else + echo "==> Build non-root image: $NONROOT_IMAGE" + docker build \ + -t "$NONROOT_IMAGE" \ + -f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \ + "$ROOT_DIR/scripts/docker/install-sh-nonroot" + + echo "==> Run installer non-root test: $INSTALL_URL" + docker run --rm -t \ + -e OPENCLAW_INSTALL_URL="$INSTALL_URL" \ + -e OPENCLAW_INSTALL_EXPECT_VERSION="$LATEST_VERSION" \ + -e OPENCLAW_NO_ONBOARD=1 \ + -e DEBIAN_FRONTEND=noninteractive \ + "$NONROOT_IMAGE" +fi + +if [[ "${OPENCLAW_INSTALL_SMOKE_SKIP_CLI:-${CLAWDBOT_INSTALL_SMOKE_SKIP_CLI:-0}}" == "1" ]]; then + echo "==> Skip CLI installer smoke (OPENCLAW_INSTALL_SMOKE_SKIP_CLI=1)" + exit 0 +fi + +if [[ "$SKIP_NONROOT" == "1" ]]; then + echo "==> Skip CLI installer smoke (non-root image skipped)" + exit 0 +fi + +echo "==> Run CLI installer non-root test (same image)" +docker run --rm -t \ + --entrypoint /bin/bash \ + -e OPENCLAW_INSTALL_URL="$INSTALL_URL" \ + -e OPENCLAW_INSTALL_CLI_URL="$CLI_INSTALL_URL" \ + -e OPENCLAW_NO_ONBOARD=1 \ + -e DEBIAN_FRONTEND=noninteractive \ + "$NONROOT_IMAGE" -lc "curl -fsSL \"$CLI_INSTALL_URL\" | bash -s -- --set-npm-prefix --no-onboard" diff --git a/scripts/test-install-sh-e2e-docker.sh b/scripts/test-install-sh-e2e-docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..cd0cf3130bca97d341ac22ba6810b1cd0c47b945 --- /dev/null +++ b/scripts/test-install-sh-e2e-docker.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE_NAME="${OPENCLAW_INSTALL_E2E_IMAGE:-${CLAWDBOT_INSTALL_E2E_IMAGE:-openclaw-install-e2e:local}}" +INSTALL_URL="${OPENCLAW_INSTALL_URL:-${CLAWDBOT_INSTALL_URL:-https://openclaw.bot/install.sh}}" + +OPENAI_API_KEY="${OPENAI_API_KEY:-}" +ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" +ANTHROPIC_API_TOKEN="${ANTHROPIC_API_TOKEN:-}" +OPENCLAW_E2E_MODELS="${OPENCLAW_E2E_MODELS:-${CLAWDBOT_E2E_MODELS:-}}" + +echo "==> Build image: $IMAGE_NAME" +docker build \ + -t "$IMAGE_NAME" \ + -f "$ROOT_DIR/scripts/docker/install-sh-e2e/Dockerfile" \ + "$ROOT_DIR/scripts/docker/install-sh-e2e" + +echo "==> Run E2E installer test" +docker run --rm \ + -e OPENCLAW_INSTALL_URL="$INSTALL_URL" \ + -e OPENCLAW_INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-${CLAWDBOT_INSTALL_TAG:-latest}}" \ + -e OPENCLAW_E2E_MODELS="$OPENCLAW_E2E_MODELS" \ + -e OPENCLAW_INSTALL_E2E_PREVIOUS="${OPENCLAW_INSTALL_E2E_PREVIOUS:-${CLAWDBOT_INSTALL_E2E_PREVIOUS:-}}" \ + -e OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS="${OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS:-${CLAWDBOT_INSTALL_E2E_SKIP_PREVIOUS:-0}}" \ + -e OPENAI_API_KEY="$OPENAI_API_KEY" \ + -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ + -e ANTHROPIC_API_TOKEN="$ANTHROPIC_API_TOKEN" \ + "$IMAGE_NAME" diff --git a/scripts/test-live-gateway-models-docker.sh b/scripts/test-live-gateway-models-docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..bb0641df16b9cf5f42f4c71ca7e6003e8fb6bf71 --- /dev/null +++ b/scripts/test-live-gateway-models-docker.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE_NAME="${OPENCLAW_IMAGE:-${CLAWDBOT_IMAGE:-openclaw:local}}" +CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-${CLAWDBOT_CONFIG_DIR:-$HOME/.openclaw}}" +WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-${CLAWDBOT_WORKSPACE_DIR:-$HOME/.openclaw/workspace}}" +PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-${CLAWDBOT_PROFILE_FILE:-$HOME/.profile}}" + +PROFILE_MOUNT=() +if [[ -f "$PROFILE_FILE" ]]; then + PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/node/.profile:ro) +fi + +echo "==> Build image: $IMAGE_NAME" +docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR" + +echo "==> Run gateway live model tests (profile keys)" +docker run --rm -t \ + --entrypoint bash \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e HOME=/home/node \ + -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ + -e OPENCLAW_LIVE_TEST=1 \ + -e OPENCLAW_LIVE_GATEWAY_MODELS="${OPENCLAW_LIVE_GATEWAY_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MODELS:-all}}" \ + -e OPENCLAW_LIVE_GATEWAY_PROVIDERS="${OPENCLAW_LIVE_GATEWAY_PROVIDERS:-${CLAWDBOT_LIVE_GATEWAY_PROVIDERS:-}}" \ + -e OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS="${OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS:-${CLAWDBOT_LIVE_GATEWAY_MODEL_TIMEOUT_MS:-}}" \ + -v "$CONFIG_DIR":/home/node/.openclaw \ + -v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \ + "${PROFILE_MOUNT[@]}" \ + "$IMAGE_NAME" \ + -lc "set -euo pipefail; [ -f \"$HOME/.profile\" ] && source \"$HOME/.profile\" || true; cd /app && pnpm test:live" diff --git a/scripts/test-live-models-docker.sh b/scripts/test-live-models-docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..1a7df857c7aee4218cf8fd506570c530376a4f5e --- /dev/null +++ b/scripts/test-live-models-docker.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE_NAME="${OPENCLAW_IMAGE:-${CLAWDBOT_IMAGE:-openclaw:local}}" +CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-${CLAWDBOT_CONFIG_DIR:-$HOME/.openclaw}}" +WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-${CLAWDBOT_WORKSPACE_DIR:-$HOME/.openclaw/workspace}}" +PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-${CLAWDBOT_PROFILE_FILE:-$HOME/.profile}}" + +PROFILE_MOUNT=() +if [[ -f "$PROFILE_FILE" ]]; then + PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/node/.profile:ro) +fi + +echo "==> Build image: $IMAGE_NAME" +docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR" + +echo "==> Run live model tests (profile keys)" +docker run --rm -t \ + --entrypoint bash \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e HOME=/home/node \ + -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ + -e OPENCLAW_LIVE_TEST=1 \ + -e OPENCLAW_LIVE_MODELS="${OPENCLAW_LIVE_MODELS:-${CLAWDBOT_LIVE_MODELS:-all}}" \ + -e OPENCLAW_LIVE_PROVIDERS="${OPENCLAW_LIVE_PROVIDERS:-${CLAWDBOT_LIVE_PROVIDERS:-}}" \ + -e OPENCLAW_LIVE_MODEL_TIMEOUT_MS="${OPENCLAW_LIVE_MODEL_TIMEOUT_MS:-${CLAWDBOT_LIVE_MODEL_TIMEOUT_MS:-}}" \ + -e OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS="${OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS:-${CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS:-}}" \ + -v "$CONFIG_DIR":/home/node/.openclaw \ + -v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \ + "${PROFILE_MOUNT[@]}" \ + "$IMAGE_NAME" \ + -lc "set -euo pipefail; [ -f \"$HOME/.profile\" ] && source \"$HOME/.profile\" || true; cd /app && pnpm test:live" diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs new file mode 100644 index 0000000000000000000000000000000000000000..9ee0a9d87686b3c3c650c7bd93748b767fc071d8 --- /dev/null +++ b/scripts/test-parallel.mjs @@ -0,0 +1,113 @@ +import { spawn } from "node:child_process"; +import os from "node:os"; + +const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + +const runs = [ + { + name: "unit", + args: ["vitest", "run", "--config", "vitest.unit.config.ts"], + }, + { + name: "extensions", + args: ["vitest", "run", "--config", "vitest.extensions.config.ts"], + }, + { + name: "gateway", + args: ["vitest", "run", "--config", "vitest.gateway.config.ts"], + }, +]; + +const children = new Set(); +const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; +const isMacOS = process.platform === "darwin" || process.env.RUNNER_OS === "macOS"; +const isWindows = process.platform === "win32" || process.env.RUNNER_OS === "Windows"; +const isWindowsCi = isCI && isWindows; +const shardOverride = Number.parseInt(process.env.OPENCLAW_TEST_SHARDS ?? "", 10); +const shardCount = isWindowsCi + ? Number.isFinite(shardOverride) && shardOverride > 1 + ? shardOverride + : 2 + : 1; +const windowsCiArgs = isWindowsCi + ? ["--no-file-parallelism", "--dangerouslyIgnoreUnhandledErrors"] + : []; +const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10); +const resolvedOverride = + Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; +const parallelRuns = isWindowsCi ? [] : runs.filter((entry) => entry.name !== "gateway"); +const serialRuns = isWindowsCi ? runs : runs.filter((entry) => entry.name === "gateway"); +const localWorkers = Math.max(4, Math.min(16, os.cpus().length)); +const parallelCount = Math.max(1, parallelRuns.length); +const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelCount)); +const macCiWorkers = isCI && isMacOS ? 1 : perRunWorkers; +// Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM. +// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts. +const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : macCiWorkers); + +const WARNING_SUPPRESSION_FLAGS = [ + "--disable-warning=ExperimentalWarning", + "--disable-warning=DEP0040", + "--disable-warning=DEP0060", +]; + +const runOnce = (entry, extraArgs = []) => + new Promise((resolve) => { + const args = maxWorkers + ? [...entry.args, "--maxWorkers", String(maxWorkers), ...windowsCiArgs, ...extraArgs] + : [...entry.args, ...windowsCiArgs, ...extraArgs]; + const nodeOptions = process.env.NODE_OPTIONS ?? ""; + const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce( + (acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()), + nodeOptions, + ); + const child = spawn(pnpm, args, { + stdio: "inherit", + env: { ...process.env, VITEST_GROUP: entry.name, NODE_OPTIONS: nextNodeOptions }, + shell: process.platform === "win32", + }); + children.add(child); + child.on("exit", (code, signal) => { + children.delete(child); + resolve(code ?? (signal ? 1 : 0)); + }); + }); + +const run = async (entry) => { + if (shardCount <= 1) { + return runOnce(entry); + } + for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) { + // eslint-disable-next-line no-await-in-loop + const code = await runOnce(entry, ["--shard", `${shardIndex}/${shardCount}`]); + if (code !== 0) { + return code; + } + } + return 0; +}; + +const shutdown = (signal) => { + for (const child of children) { + child.kill(signal); + } +}; + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); + +const parallelCodes = await Promise.all(parallelRuns.map(run)); +const failedParallel = parallelCodes.find((code) => code !== 0); +if (failedParallel !== undefined) { + process.exit(failedParallel); +} + +for (const entry of serialRuns) { + // eslint-disable-next-line no-await-in-loop + const code = await run(entry); + if (code !== 0) { + process.exit(code); + } +} + +process.exit(0); diff --git a/scripts/ui.js b/scripts/ui.js new file mode 100644 index 0000000000000000000000000000000000000000..66c1ffe14684b6c8edcb8bde1487778817cc32fe --- /dev/null +++ b/scripts/ui.js @@ -0,0 +1,137 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, ".."); +const uiDir = path.join(repoRoot, "ui"); + +function usage() { + // keep this tiny; it's invoked from npm scripts too + process.stderr.write("Usage: node scripts/ui.js [...args]\n"); +} + +function which(cmd) { + try { + const key = process.platform === "win32" ? "Path" : "PATH"; + const paths = (process.env[key] ?? process.env.PATH ?? "") + .split(path.delimiter) + .filter(Boolean); + const extensions = + process.platform === "win32" + ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean) + : [""]; + for (const entry of paths) { + for (const ext of extensions) { + const candidate = path.join(entry, process.platform === "win32" ? `${cmd}${ext}` : cmd); + try { + if (fs.existsSync(candidate)) { + return candidate; + } + } catch { + // ignore + } + } + } + } catch { + // ignore + } + return null; +} + +function resolveRunner() { + const pnpm = which("pnpm"); + if (pnpm) { + return { cmd: pnpm, kind: "pnpm" }; + } + return null; +} + +function run(cmd, args) { + const child = spawn(cmd, args, { + cwd: uiDir, + stdio: "inherit", + env: process.env, + shell: process.platform === "win32", + }); + child.on("exit", (code, signal) => { + if (signal) { + process.exit(1); + } + process.exit(code ?? 1); + }); +} + +function runSync(cmd, args, envOverride) { + const result = spawnSync(cmd, args, { + cwd: uiDir, + stdio: "inherit", + env: envOverride ?? process.env, + shell: process.platform === "win32", + }); + if (result.signal) { + process.exit(1); + } + if ((result.status ?? 1) !== 0) { + process.exit(result.status ?? 1); + } +} + +function depsInstalled(kind) { + try { + const require = createRequire(path.join(uiDir, "package.json")); + require.resolve("vite"); + require.resolve("dompurify"); + if (kind === "test") { + require.resolve("vitest"); + require.resolve("@vitest/browser-playwright"); + require.resolve("playwright"); + } + return true; + } catch { + return false; + } +} + +const [, , action, ...rest] = process.argv; +if (!action) { + usage(); + process.exit(2); +} + +const runner = resolveRunner(); +if (!runner) { + process.stderr.write("Missing UI runner: install pnpm, then retry.\n"); + process.exit(1); +} + +const script = + action === "install" + ? null + : action === "dev" + ? "dev" + : action === "build" + ? "build" + : action === "test" + ? "test" + : null; + +if (action !== "install" && !script) { + usage(); + process.exit(2); +} + +if (action === "install") { + run(runner.cmd, ["install", ...rest]); +} else { + if (!depsInstalled(action === "test" ? "test" : "build")) { + const installEnv = + action === "build" ? { ...process.env, NODE_ENV: "production" } : process.env; + const installArgs = action === "build" ? ["install", "--prod"] : ["install"]; + runSync(runner.cmd, installArgs, installEnv); + } + run(runner.cmd, ["run", script, ...rest]); +} diff --git a/scripts/update-clawtributors.ts b/scripts/update-clawtributors.ts new file mode 100644 index 0000000000000000000000000000000000000000..334d9b44a35d2f590834013d7b607632c1643458 --- /dev/null +++ b/scripts/update-clawtributors.ts @@ -0,0 +1,477 @@ +import { execSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { ApiContributor, Entry, MapConfig, User } from "./update-clawtributors.types.js"; + +const REPO = "openclaw/openclaw"; +const PER_LINE = 10; + +const mapPath = resolve("scripts/clawtributors-map.json"); +const mapConfig = JSON.parse(readFileSync(mapPath, "utf8")) as MapConfig; + +const displayName = mapConfig.displayName ?? {}; +const nameToLogin = normalizeMap(mapConfig.nameToLogin ?? {}); +const emailToLogin = normalizeMap(mapConfig.emailToLogin ?? {}); +const ensureLogins = (mapConfig.ensureLogins ?? []).map((login) => login.toLowerCase()); + +const readmePath = resolve("README.md"); +const placeholderAvatar = mapConfig.placeholderAvatar ?? "assets/avatar-placeholder.svg"; +const seedCommit = mapConfig.seedCommit ?? null; +const seedEntries = seedCommit ? parseReadmeEntries(run(`git show ${seedCommit}:README.md`)) : []; +const raw = run(`gh api "repos/${REPO}/contributors?per_page=100&anon=1" --paginate`); +const contributors = parsePaginatedJson(raw) as ApiContributor[]; +const apiByLogin = new Map(); +const contributionsByLogin = new Map(); + +for (const item of contributors) { + if (!item?.login || !item?.html_url || !item?.avatar_url) { + continue; + } + if (typeof item.contributions === "number") { + contributionsByLogin.set(item.login.toLowerCase(), item.contributions); + } + apiByLogin.set(item.login.toLowerCase(), { + login: item.login, + html_url: item.html_url, + avatar_url: normalizeAvatar(item.avatar_url), + }); +} + +for (const login of ensureLogins) { + if (!apiByLogin.has(login)) { + const user = fetchUser(login); + if (user) { + apiByLogin.set(user.login.toLowerCase(), user); + } + } +} + +const log = run("git log --format=%aN%x7c%aE --numstat"); +const linesByLogin = new Map(); + +let currentName: string | null = null; +let currentEmail: string | null = null; + +for (const line of log.split("\n")) { + if (!line.trim()) { + continue; + } + + if (line.includes("|") && !/^[0-9-]/.test(line)) { + const [name, email] = line.split("|", 2); + currentName = name?.trim() ?? null; + currentEmail = email?.trim().toLowerCase() ?? null; + continue; + } + + if (!currentName) { + continue; + } + + const parts = line.split("\t"); + if (parts.length < 2) { + continue; + } + + const adds = parseCount(parts[0]); + const dels = parseCount(parts[1]); + const total = adds + dels; + if (!total) { + continue; + } + + let login = resolveLogin(currentName, currentEmail, apiByLogin, nameToLogin, emailToLogin); + if (!login) { + continue; + } + + const key = login.toLowerCase(); + linesByLogin.set(key, (linesByLogin.get(key) ?? 0) + total); +} + +for (const login of ensureLogins) { + if (!linesByLogin.has(login)) { + linesByLogin.set(login, 0); + } +} + +const entriesByKey = new Map(); + +for (const seed of seedEntries) { + const login = loginFromUrl(seed.html_url); + const resolvedLogin = + login ?? resolveLogin(seed.display, null, apiByLogin, nameToLogin, emailToLogin); + const key = resolvedLogin ? resolvedLogin.toLowerCase() : `name:${normalizeName(seed.display)}`; + const avatar = + seed.avatar_url && !isGhostAvatar(seed.avatar_url) + ? normalizeAvatar(seed.avatar_url) + : placeholderAvatar; + const existing = entriesByKey.get(key); + if (!existing) { + const user = resolvedLogin ? apiByLogin.get(key) : null; + entriesByKey.set(key, { + key, + login: resolvedLogin ?? login ?? undefined, + display: seed.display, + html_url: user?.html_url ?? seed.html_url, + avatar_url: user?.avatar_url ?? avatar, + lines: 0, + }); + } else { + existing.display = existing.display || seed.display; + if (existing.avatar_url === placeholderAvatar || !existing.avatar_url) { + existing.avatar_url = avatar; + } + if (!existing.html_url || existing.html_url.includes("/search?q=")) { + existing.html_url = seed.html_url; + } + } +} + +for (const item of contributors) { + const baseName = item.name?.trim() || item.email?.trim() || item.login?.trim(); + if (!baseName) { + continue; + } + + const resolvedLogin = item.login + ? item.login + : resolveLogin(baseName, item.email ?? null, apiByLogin, nameToLogin, emailToLogin); + + if (resolvedLogin) { + const key = resolvedLogin.toLowerCase(); + const existing = entriesByKey.get(key); + if (!existing) { + let user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); + if (user) { + const lines = linesByLogin.get(key) ?? 0; + const contributions = contributionsByLogin.get(key) ?? 0; + entriesByKey.set(key, { + key, + login: user.login, + display: pickDisplay(baseName, user.login, existing?.display), + html_url: user.html_url, + avatar_url: normalizeAvatar(user.avatar_url), + lines: lines > 0 ? lines : contributions, + }); + } + } else if (existing) { + existing.login = existing.login ?? resolvedLogin; + existing.display = pickDisplay(baseName, existing.login, existing.display); + if (existing.avatar_url === placeholderAvatar || !existing.avatar_url) { + const user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); + if (user) { + existing.html_url = user.html_url; + existing.avatar_url = normalizeAvatar(user.avatar_url); + } + } + const lines = linesByLogin.get(key) ?? 0; + const contributions = contributionsByLogin.get(key) ?? 0; + existing.lines = Math.max(existing.lines, lines > 0 ? lines : contributions); + } + continue; + } + + const anonKey = `name:${normalizeName(baseName)}`; + const existingAnon = entriesByKey.get(anonKey); + if (!existingAnon) { + entriesByKey.set(anonKey, { + key: anonKey, + display: baseName, + html_url: fallbackHref(baseName), + avatar_url: placeholderAvatar, + lines: item.contributions ?? 0, + }); + } else { + existingAnon.lines = Math.max(existingAnon.lines, item.contributions ?? 0); + } +} + +for (const [login, lines] of linesByLogin.entries()) { + if (entriesByKey.has(login)) { + continue; + } + let user = apiByLogin.get(login); + if (!user) { + user = fetchUser(login); + } + if (user) { + const contributions = contributionsByLogin.get(login) ?? 0; + entriesByKey.set(login, { + key: login, + login: user.login, + display: displayName[user.login.toLowerCase()] ?? user.login, + html_url: user.html_url, + avatar_url: normalizeAvatar(user.avatar_url), + lines: lines > 0 ? lines : contributions, + }); + } else { + entriesByKey.set(login, { + key: login, + display: login, + html_url: fallbackHref(login), + avatar_url: placeholderAvatar, + lines, + }); + } +} + +const entries = Array.from(entriesByKey.values()); + +entries.sort((a, b) => { + if (b.lines !== a.lines) { + return b.lines - a.lines; + } + return a.display.localeCompare(b.display); +}); + +const lines: string[] = []; +for (let i = 0; i < entries.length; i += PER_LINE) { + const chunk = entries.slice(i, i + PER_LINE); + const parts = chunk.map((entry) => { + return `${entry.display}`; + }); + lines.push(` ${parts.join(" ")}`); +} + +const block = `${lines.join("\n")}\n`; +const readme = readFileSync(readmePath, "utf8"); +const start = readme.indexOf('

    '); +const end = readme.indexOf("

    ", start); + +if (start === -1 || end === -1) { + throw new Error("README.md missing clawtributors block"); +} + +const next = `${readme.slice(0, start)}

    \n${block}${readme.slice(end)}`; +writeFileSync(readmePath, next); + +console.log(`Updated README clawtributors: ${entries.length} entries`); + +function run(cmd: string): string { + return execSync(cmd, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 1024 * 1024 * 200, + }).trim(); +} + +function parsePaginatedJson(raw: string): any[] { + const items: any[] = []; + for (const line of raw.split("\n")) { + if (!line.trim()) { + continue; + } + const parsed = JSON.parse(line); + if (Array.isArray(parsed)) { + items.push(...parsed); + } else { + items.push(parsed); + } + } + return items; +} + +function normalizeMap(map: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(map)) { + out[normalizeName(key)] = value; + } + return out; +} + +function normalizeName(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +function parseCount(value: string): number { + return /^\d+$/.test(value) ? Number(value) : 0; +} + +function normalizeAvatar(url: string): string { + if (!/^https?:/i.test(url)) { + return url; + } + const lower = url.toLowerCase(); + if (lower.includes("s=") || lower.includes("size=")) { + return url; + } + const sep = url.includes("?") ? "&" : "?"; + return `${url}${sep}s=48`; +} + +function isGhostAvatar(url: string): boolean { + return url.toLowerCase().includes("ghost.png"); +} + +function fetchUser(login: string): User | null { + try { + const data = execSync(`gh api users/${login}`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const parsed = JSON.parse(data); + if (!parsed?.login || !parsed?.html_url || !parsed?.avatar_url) { + return null; + } + return { + login: parsed.login, + html_url: parsed.html_url, + avatar_url: normalizeAvatar(parsed.avatar_url), + }; + } catch { + return null; + } +} + +function resolveLogin( + name: string, + email: string | null, + apiByLogin: Map, + nameToLogin: Record, + emailToLogin: Record, +): string | null { + if (email && emailToLogin[email]) { + return emailToLogin[email]; + } + + if (email && name) { + const guessed = guessLoginFromEmailName(name, email, apiByLogin); + if (guessed) { + return guessed; + } + } + + if (email && email.endsWith("@users.noreply.github.com")) { + const local = email.split("@", 1)[0]; + const login = local.includes("+") ? local.split("+")[1] : local; + return login || null; + } + + if (email && email.endsWith("@github.com")) { + const login = email.split("@", 1)[0]; + if (apiByLogin.has(login.toLowerCase())) { + return login; + } + } + + const normalized = normalizeName(name); + if (nameToLogin[normalized]) { + return nameToLogin[normalized]; + } + + const compact = normalized.replace(/\s+/g, ""); + if (nameToLogin[compact]) { + return nameToLogin[compact]; + } + + if (apiByLogin.has(normalized)) { + return normalized; + } + + if (apiByLogin.has(compact)) { + return compact; + } + + return null; +} + +function guessLoginFromEmailName( + name: string, + email: string, + apiByLogin: Map, +): string | null { + const local = email.split("@", 1)[0]?.trim(); + if (!local) { + return null; + } + const normalizedName = normalizeIdentifier(name); + if (!normalizedName) { + return null; + } + const candidates = new Set([local, local.replace(/[._-]/g, "")]); + for (const candidate of candidates) { + if (!candidate) { + continue; + } + if (normalizeIdentifier(candidate) !== normalizedName) { + continue; + } + const key = candidate.toLowerCase(); + if (apiByLogin.has(key)) { + return key; + } + } + return null; +} + +function normalizeIdentifier(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function parseReadmeEntries( + content: string, +): Array<{ display: string; html_url: string; avatar_url: string }> { + const start = content.indexOf('

    '); + const end = content.indexOf("

    ", start); + if (start === -1 || end === -1) { + return []; + } + const block = content.slice(start, end); + const entries: Array<{ display: string; html_url: string; avatar_url: string }> = []; + const linked = /]*alt="([^"]+)"[^>]*>/g; + for (const match of block.matchAll(linked)) { + const [, href, src, alt] = match; + if (!href || !src || !alt) { + continue; + } + entries.push({ html_url: href, avatar_url: src, display: alt }); + } + const standalone = /]*alt="([^"]+)"[^>]*>/g; + for (const match of block.matchAll(standalone)) { + const [, src, alt] = match; + if (!src || !alt) { + continue; + } + if (entries.some((entry) => entry.display === alt && entry.avatar_url === src)) { + continue; + } + entries.push({ html_url: fallbackHref(alt), avatar_url: src, display: alt }); + } + return entries; +} + +function loginFromUrl(url: string): string | null { + const match = /^https?:\/\/github\.com\/([^/?#]+)/i.exec(url); + if (!match) { + return null; + } + const login = match[1]; + if (!login || login.toLowerCase() === "search") { + return null; + } + return login; +} + +function fallbackHref(value: string): string { + const encoded = encodeURIComponent(value.trim()); + return encoded ? `https://github.com/search?q=${encoded}` : "https://github.com"; +} + +function pickDisplay( + baseName: string | null | undefined, + login: string, + existing?: string, +): string { + const key = login.toLowerCase(); + if (displayName[key]) { + return displayName[key]; + } + if (existing) { + return existing; + } + if (baseName) { + return baseName; + } + return login; +} diff --git a/scripts/update-clawtributors.types.ts b/scripts/update-clawtributors.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..98526bc8a4176cce51a68d5680b285b044f58779 --- /dev/null +++ b/scripts/update-clawtributors.types.ts @@ -0,0 +1,32 @@ +export type MapConfig = { + ensureLogins?: string[]; + displayName?: Record; + nameToLogin?: Record; + emailToLogin?: Record; + placeholderAvatar?: string; + seedCommit?: string; +}; + +export type ApiContributor = { + login?: string; + html_url?: string; + avatar_url?: string; + name?: string; + email?: string; + contributions?: number; +}; + +export type User = { + login: string; + html_url: string; + avatar_url: string; +}; + +export type Entry = { + key: string; + login?: string; + display: string; + html_url: string; + avatar_url: string; + lines: number; +}; diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs new file mode 100644 index 0000000000000000000000000000000000000000..d669b6924c26016383054ada5dfe1bc497111035 --- /dev/null +++ b/scripts/watch-node.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from "node:child_process"; +import process from "node:process"; + +const args = process.argv.slice(2); +const env = { ...process.env }; +const cwd = process.cwd(); +const compilerOverride = env.OPENCLAW_TS_COMPILER ?? env.CLAWDBOT_TS_COMPILER; +const compiler = compilerOverride === "tsc" ? "tsc" : "tsgo"; +const projectArgs = ["--project", "tsconfig.json"]; + +const initialBuild = spawnSync("pnpm", ["exec", compiler, ...projectArgs], { + cwd, + env, + stdio: "inherit", +}); + +if (initialBuild.status !== 0) { + process.exit(initialBuild.status ?? 1); +} + +const watchArgs = + compiler === "tsc" + ? [...projectArgs, "--watch", "--preserveWatchOutput"] + : [...projectArgs, "--watch"]; + +const compilerProcess = spawn("pnpm", ["exec", compiler, ...watchArgs], { + cwd, + env, + stdio: "inherit", +}); + +const nodeProcess = spawn(process.execPath, ["--watch", "openclaw.mjs", ...args], { + cwd, + env, + stdio: "inherit", +}); + +let exiting = false; + +function cleanup(code = 0) { + if (exiting) { + return; + } + exiting = true; + nodeProcess.kill("SIGTERM"); + compilerProcess.kill("SIGTERM"); + process.exit(code); +} + +process.on("SIGINT", () => cleanup(130)); +process.on("SIGTERM", () => cleanup(143)); + +compilerProcess.on("exit", (code) => { + if (exiting) { + return; + } + cleanup(code ?? 1); +}); + +nodeProcess.on("exit", (code, signal) => { + if (signal || exiting) { + return; + } + cleanup(code ?? 1); +}); diff --git a/scripts/write-build-info.ts b/scripts/write-build-info.ts new file mode 100644 index 0000000000000000000000000000000000000000..de50033e12a4293190e2295aeaa0ad88ec47700b --- /dev/null +++ b/scripts/write-build-info.ts @@ -0,0 +1,47 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const distDir = path.join(rootDir, "dist"); +const pkgPath = path.join(rootDir, "package.json"); + +const readPackageVersion = () => { + try { + const raw = fs.readFileSync(pkgPath, "utf8"); + const parsed = JSON.parse(raw) as { version?: string }; + return parsed.version ?? null; + } catch { + return null; + } +}; + +const resolveCommit = () => { + const envCommit = process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim(); + if (envCommit) { + return envCommit; + } + try { + return execSync("git rev-parse HEAD", { + cwd: rootDir, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + } catch { + return null; + } +}; + +const version = readPackageVersion(); +const commit = resolveCommit(); + +const buildInfo = { + version, + commit, + builtAt: new Date().toISOString(), +}; + +fs.mkdirSync(distDir, { recursive: true }); +fs.writeFileSync(path.join(distDir, "build-info.json"), `${JSON.stringify(buildInfo, null, 2)}\n`); diff --git a/scripts/zai-fallback-repro.ts b/scripts/zai-fallback-repro.ts new file mode 100644 index 0000000000000000000000000000000000000000..71e9e3438458ba5983ddf814025af46fc2c25e9f --- /dev/null +++ b/scripts/zai-fallback-repro.ts @@ -0,0 +1,167 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +type RunResult = { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +}; + +function pickAnthropicEnv(): { type: "oauth" | "api"; value: string } | null { + const oauth = process.env.ANTHROPIC_OAUTH_TOKEN?.trim(); + if (oauth) { + return { type: "oauth", value: oauth }; + } + const api = process.env.ANTHROPIC_API_KEY?.trim(); + if (api) { + return { type: "api", value: api }; + } + return null; +} + +function pickZaiKey(): string | null { + return process.env.ZAI_API_KEY?.trim() ?? process.env.Z_AI_API_KEY?.trim() ?? null; +} + +async function runCommand( + label: string, + args: string[], + env: NodeJS.ProcessEnv, +): Promise { + return await new Promise((resolve, reject) => { + const child = spawn("pnpm", args, { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + const text = String(chunk); + stdout += text; + process.stdout.write(text); + }); + child.stderr.on("data", (chunk) => { + const text = String(chunk); + stderr += text; + process.stderr.write(text); + }); + child.on("error", (err) => reject(err)); + child.on("close", (code, signal) => { + if (code === 0) { + resolve({ code, signal, stdout, stderr }); + return; + } + resolve({ code, signal, stdout, stderr }); + const summary = signal + ? `${label} exited with signal ${signal}` + : `${label} exited with code ${code}`; + console.error(summary); + }); + }); +} + +async function main() { + const anthropic = pickAnthropicEnv(); + const zaiKey = pickZaiKey(); + if (!anthropic) { + console.error("Missing ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY."); + process.exit(1); + } + if (!zaiKey) { + console.error("Missing ZAI_API_KEY or Z_AI_API_KEY."); + process.exit(1); + } + + const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-zai-fallback-")); + const stateDir = path.join(baseDir, "state"); + const configPath = path.join(baseDir, "openclaw.json"); + await fs.mkdir(stateDir, { recursive: true }); + + const config = { + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-5", + fallbacks: ["zai/glm-4.7"], + }, + models: { + "anthropic/claude-opus-4-5": {}, + "zai/glm-4.7": {}, + }, + }, + }, + }; + await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); + + const sessionId = + process.env.OPENCLAW_ZAI_FALLBACK_SESSION_ID ?? + process.env.CLAWDBOT_ZAI_FALLBACK_SESSION_ID ?? + randomUUID(); + + const baseEnv: NodeJS.ProcessEnv = { + ...process.env, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_STATE_DIR: stateDir, + CLAWDBOT_CONFIG_PATH: configPath, + CLAWDBOT_STATE_DIR: stateDir, + ZAI_API_KEY: zaiKey, + Z_AI_API_KEY: "", + }; + + const envValidAnthropic: NodeJS.ProcessEnv = { + ...baseEnv, + ANTHROPIC_OAUTH_TOKEN: anthropic.type === "oauth" ? anthropic.value : "", + ANTHROPIC_API_KEY: anthropic.type === "api" ? anthropic.value : "", + }; + + const envInvalidAnthropic: NodeJS.ProcessEnv = { + ...baseEnv, + ANTHROPIC_OAUTH_TOKEN: anthropic.type === "oauth" ? "invalid" : "", + ANTHROPIC_API_KEY: anthropic.type === "api" ? "invalid" : "", + }; + + console.log("== Run 1: create tool history (primary only)"); + const toolPrompt = + "Use the exec tool to create a file named zai-fallback-tool.txt with the content tool-ok. " + + "Then use the read tool to display the file contents. Reply with just the file contents."; + const run1 = await runCommand( + "run1", + ["openclaw", "agent", "--local", "--session-id", sessionId, "--message", toolPrompt], + envValidAnthropic, + ); + if (run1.code !== 0) { + process.exit(run1.code ?? 1); + } + + const sessionFile = path.join(stateDir, "agents", "main", "sessions", `${sessionId}.jsonl`); + const transcript = await fs.readFile(sessionFile, "utf8").catch(() => ""); + if (!transcript.includes('"toolResult"')) { + console.warn("Warning: no toolResult entries detected in session history."); + } + + console.log("== Run 2: force auth failover to Z.AI"); + const followupPrompt = + "What is the content of zai-fallback-tool.txt? Reply with just the contents."; + const run2 = await runCommand( + "run2", + ["openclaw", "agent", "--local", "--session-id", sessionId, "--message", followupPrompt], + envInvalidAnthropic, + ); + + if (run2.code === 0) { + console.log("PASS: fallback succeeded."); + process.exit(0); + } + + console.error("FAIL: fallback failed."); + process.exit(run2.code ?? 1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/skills/1password/SKILL.md b/skills/1password/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..c3dcb82be5521865b7fbe95835e8bdb898d664a0 --- /dev/null +++ b/skills/1password/SKILL.md @@ -0,0 +1,70 @@ +--- +name: 1password +description: Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in (single or multi-account), or reading/injecting/running secrets via op. +homepage: https://developer.1password.com/docs/cli/get-started/ +metadata: + { + "openclaw": + { + "emoji": "🔐", + "requires": { "bins": ["op"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "1password-cli", + "bins": ["op"], + "label": "Install 1Password CLI (brew)", + }, + ], + }, + } +--- + +# 1Password CLI + +Follow the official CLI get-started steps. Don't guess install commands. + +## References + +- `references/get-started.md` (install + app integration + sign-in flow) +- `references/cli-examples.md` (real `op` examples) + +## Workflow + +1. Check OS + shell. +2. Verify CLI present: `op --version`. +3. Confirm desktop app integration is enabled (per get-started) and the app is unlocked. +4. REQUIRED: create a fresh tmux session for all `op` commands (no direct `op` calls outside tmux). +5. Sign in / authorize inside tmux: `op signin` (expect app prompt). +6. Verify access inside tmux: `op whoami` (must succeed before any secret read). +7. If multiple accounts: use `--account` or `OP_ACCOUNT`. + +## REQUIRED tmux session (T-Max) + +The shell tool uses a fresh TTY per command. To avoid re-prompts and failures, always run `op` inside a dedicated tmux session with a fresh socket/session name. + +Example (see `tmux` skill for socket conventions, do not reuse old session names): + +```bash +SOCKET_DIR="${OPENCLAW_TMUX_SOCKET_DIR:-${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/openclaw-tmux-sockets}}" +mkdir -p "$SOCKET_DIR" +SOCKET="$SOCKET_DIR/openclaw-op.sock" +SESSION="op-auth-$(date +%Y%m%d-%H%M%S)" + +tmux -S "$SOCKET" new -d -s "$SESSION" -n shell +tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op signin --account my.1password.com" Enter +tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op whoami" Enter +tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op vault list" Enter +tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200 +tmux -S "$SOCKET" kill-session -t "$SESSION" +``` + +## Guardrails + +- Never paste secrets into logs, chat, or code. +- Prefer `op run` / `op inject` over writing secrets to disk. +- If sign-in without app integration is needed, use `op account add`. +- If a command returns "account is not signed in", re-run `op signin` inside tmux and authorize in the app. +- Do not run `op` outside tmux; stop and ask if tmux is unavailable. diff --git a/skills/1password/references/cli-examples.md b/skills/1password/references/cli-examples.md new file mode 100644 index 0000000000000000000000000000000000000000..c8da0972bcef0192a035c9a13364e6abd7a0d347 --- /dev/null +++ b/skills/1password/references/cli-examples.md @@ -0,0 +1,29 @@ +# op CLI examples (from op help) + +## Sign in + +- `op signin` +- `op signin --account ` + +## Read + +- `op read op://app-prod/db/password` +- `op read "op://app-prod/db/one-time password?attribute=otp"` +- `op read "op://app-prod/ssh key/private key?ssh-format=openssh"` +- `op read --out-file ./key.pem op://app-prod/server/ssh/key.pem` + +## Run + +- `export DB_PASSWORD="op://app-prod/db/password"` +- `op run --no-masking -- printenv DB_PASSWORD` +- `op run --env-file="./.env" -- printenv DB_PASSWORD` + +## Inject + +- `echo "db_password: {{ op://app-prod/db/password }}" | op inject` +- `op inject -i config.yml.tpl -o config.yml` + +## Whoami / accounts + +- `op whoami` +- `op account list` diff --git a/skills/1password/references/get-started.md b/skills/1password/references/get-started.md new file mode 100644 index 0000000000000000000000000000000000000000..3c60f75cea2a7cfee4b9eb74ca832a0e86505b16 --- /dev/null +++ b/skills/1password/references/get-started.md @@ -0,0 +1,17 @@ +# 1Password CLI get-started (summary) + +- Works on macOS, Windows, and Linux. + - macOS/Linux shells: bash, zsh, sh, fish. + - Windows shell: PowerShell. +- Requires a 1Password subscription and the desktop app to use app integration. +- macOS requirement: Big Sur 11.0.0 or later. +- Linux app integration requires PolKit + an auth agent. +- Install the CLI per the official doc for your OS. +- Enable desktop app integration in the 1Password app: + - Open and unlock the app, then select your account/collection. + - macOS: Settings > Developer > Integrate with 1Password CLI (Touch ID optional). + - Windows: turn on Windows Hello, then Settings > Developer > Integrate. + - Linux: Settings > Security > Unlock using system authentication, then Settings > Developer > Integrate. +- After integration, run any command to sign in (example in docs: `op vault list`). +- If multiple accounts: use `op signin` to pick one, or `--account` / `OP_ACCOUNT`. +- For non-integration auth, use `op account add`. diff --git a/skills/apple-notes/SKILL.md b/skills/apple-notes/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..61e8cf76d9a4a8f8e6cad3d7458170cecb719242 --- /dev/null +++ b/skills/apple-notes/SKILL.md @@ -0,0 +1,77 @@ +--- +name: apple-notes +description: Manage Apple Notes via the `memo` CLI on macOS (create, view, edit, delete, search, move, and export notes). Use when a user asks OpenClaw to add a note, list notes, search notes, or manage note folders. +homepage: https://github.com/antoniorodr/memo +metadata: + { + "openclaw": + { + "emoji": "📝", + "os": ["darwin"], + "requires": { "bins": ["memo"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "antoniorodr/memo/memo", + "bins": ["memo"], + "label": "Install memo via Homebrew", + }, + ], + }, + } +--- + +# Apple Notes CLI + +Use `memo notes` to manage Apple Notes directly from the terminal. Create, view, edit, delete, search, move notes between folders, and export to HTML/Markdown. + +Setup + +- Install (Homebrew): `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo` +- Manual (pip): `pip install .` (after cloning the repo) +- macOS-only; if prompted, grant Automation access to Notes.app. + +View Notes + +- List all notes: `memo notes` +- Filter by folder: `memo notes -f "Folder Name"` +- Search notes (fuzzy): `memo notes -s "query"` + +Create Notes + +- Add a new note: `memo notes -a` + - Opens an interactive editor to compose the note. +- Quick add with title: `memo notes -a "Note Title"` + +Edit Notes + +- Edit existing note: `memo notes -e` + - Interactive selection of note to edit. + +Delete Notes + +- Delete a note: `memo notes -d` + - Interactive selection of note to delete. + +Move Notes + +- Move note to folder: `memo notes -m` + - Interactive selection of note and destination folder. + +Export Notes + +- Export to HTML/Markdown: `memo notes -ex` + - Exports selected note; uses Mistune for markdown processing. + +Limitations + +- Cannot edit notes containing images or attachments. +- Interactive prompts may require terminal access. + +Notes + +- macOS-only. +- Requires Apple Notes.app to be accessible. +- For automation, grant permissions in System Settings > Privacy & Security > Automation. diff --git a/skills/apple-reminders/SKILL.md b/skills/apple-reminders/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..97da02761a20d0dd23f2675dda9b62d1270fcc28 --- /dev/null +++ b/skills/apple-reminders/SKILL.md @@ -0,0 +1,96 @@ +--- +name: apple-reminders +description: Manage Apple Reminders via the `remindctl` CLI on macOS (list, add, edit, complete, delete). Supports lists, date filters, and JSON/plain output. +homepage: https://github.com/steipete/remindctl +metadata: + { + "openclaw": + { + "emoji": "⏰", + "os": ["darwin"], + "requires": { "bins": ["remindctl"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "steipete/tap/remindctl", + "bins": ["remindctl"], + "label": "Install remindctl via Homebrew", + }, + ], + }, + } +--- + +# Apple Reminders CLI (remindctl) + +Use `remindctl` to manage Apple Reminders directly from the terminal. It supports list filtering, date-based views, and scripting output. + +Setup + +- Install (Homebrew): `brew install steipete/tap/remindctl` +- From source: `pnpm install && pnpm build` (binary at `./bin/remindctl`) +- macOS-only; grant Reminders permission when prompted. + +Permissions + +- Check status: `remindctl status` +- Request access: `remindctl authorize` + +View Reminders + +- Default (today): `remindctl` +- Today: `remindctl today` +- Tomorrow: `remindctl tomorrow` +- Week: `remindctl week` +- Overdue: `remindctl overdue` +- Upcoming: `remindctl upcoming` +- Completed: `remindctl completed` +- All: `remindctl all` +- Specific date: `remindctl 2026-01-04` + +Manage Lists + +- List all lists: `remindctl list` +- Show list: `remindctl list Work` +- Create list: `remindctl list Projects --create` +- Rename list: `remindctl list Work --rename Office` +- Delete list: `remindctl list Work --delete` + +Create Reminders + +- Quick add: `remindctl add "Buy milk"` +- With list + due: `remindctl add --title "Call mom" --list Personal --due tomorrow` + +Edit Reminders + +- Edit title/due: `remindctl edit 1 --title "New title" --due 2026-01-04` + +Complete Reminders + +- Complete by id: `remindctl complete 1 2 3` + +Delete Reminders + +- Delete by id: `remindctl delete 4A83 --force` + +Output Formats + +- JSON (scripting): `remindctl today --json` +- Plain TSV: `remindctl today --plain` +- Counts only: `remindctl today --quiet` + +Date Formats +Accepted by `--due` and date filters: + +- `today`, `tomorrow`, `yesterday` +- `YYYY-MM-DD` +- `YYYY-MM-DD HH:mm` +- ISO 8601 (`2026-01-04T12:34:56Z`) + +Notes + +- macOS-only. +- If access is denied, enable Terminal/remindctl in System Settings → Privacy & Security → Reminders. +- If running over SSH, grant access on the Mac that runs the command. diff --git a/skills/bear-notes/SKILL.md b/skills/bear-notes/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..ca36f1907d41440f5d7dbdc6c7b5f3350524770b --- /dev/null +++ b/skills/bear-notes/SKILL.md @@ -0,0 +1,107 @@ +--- +name: bear-notes +description: Create, search, and manage Bear notes via grizzly CLI. +homepage: https://bear.app +metadata: + { + "openclaw": + { + "emoji": "🐻", + "os": ["darwin"], + "requires": { "bins": ["grizzly"] }, + "install": + [ + { + "id": "go", + "kind": "go", + "module": "github.com/tylerwince/grizzly/cmd/grizzly@latest", + "bins": ["grizzly"], + "label": "Install grizzly (go)", + }, + ], + }, + } +--- + +# Bear Notes + +Use `grizzly` to create, read, and manage notes in Bear on macOS. + +Requirements + +- Bear app installed and running +- For some operations (add-text, tags, open-note --selected), a Bear app token (stored in `~/.config/grizzly/token`) + +## Getting a Bear Token + +For operations that require a token (add-text, tags, open-note --selected), you need an authentication token: + +1. Open Bear → Help → API Token → Copy Token +2. Save it: `echo "YOUR_TOKEN" > ~/.config/grizzly/token` + +## Common Commands + +Create a note + +```bash +echo "Note content here" | grizzly create --title "My Note" --tag work +grizzly create --title "Quick Note" --tag inbox < /dev/null +``` + +Open/read a note by ID + +```bash +grizzly open-note --id "NOTE_ID" --enable-callback --json +``` + +Append text to a note + +```bash +echo "Additional content" | grizzly add-text --id "NOTE_ID" --mode append --token-file ~/.config/grizzly/token +``` + +List all tags + +```bash +grizzly tags --enable-callback --json --token-file ~/.config/grizzly/token +``` + +Search notes (via open-tag) + +```bash +grizzly open-tag --name "work" --enable-callback --json +``` + +## Options + +Common flags: + +- `--dry-run` — Preview the URL without executing +- `--print-url` — Show the x-callback-url +- `--enable-callback` — Wait for Bear's response (needed for reading data) +- `--json` — Output as JSON (when using callbacks) +- `--token-file PATH` — Path to Bear API token file + +## Configuration + +Grizzly reads config from (in priority order): + +1. CLI flags +2. Environment variables (`GRIZZLY_TOKEN_FILE`, `GRIZZLY_CALLBACK_URL`, `GRIZZLY_TIMEOUT`) +3. `.grizzly.toml` in current directory +4. `~/.config/grizzly/config.toml` + +Example `~/.config/grizzly/config.toml`: + +```toml +token_file = "~/.config/grizzly/token" +callback_url = "http://127.0.0.1:42123/success" +timeout = "5s" +``` + +## Notes + +- Bear must be running for commands to work +- Note IDs are Bear's internal identifiers (visible in note info or via callbacks) +- Use `--enable-callback` when you need to read data back from Bear +- Some operations require a valid token (add-text, tags, open-note --selected) diff --git a/skills/bird/SKILL.md b/skills/bird/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..090ec528f5d28b0098d8f73e651c7e4ded5acbb1 --- /dev/null +++ b/skills/bird/SKILL.md @@ -0,0 +1,224 @@ +--- +name: bird +description: X/Twitter CLI for reading, searching, posting, and engagement via cookies. +homepage: https://bird.fast +metadata: + { + "openclaw": + { + "emoji": "🐦", + "requires": { "bins": ["bird"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "steipete/tap/bird", + "bins": ["bird"], + "label": "Install bird (brew)", + "os": ["darwin"], + }, + { + "id": "npm", + "kind": "node", + "package": "@steipete/bird", + "bins": ["bird"], + "label": "Install bird (npm)", + }, + ], + }, + } +--- + +# bird 🐦 + +Fast X/Twitter CLI using GraphQL + cookie auth. + +## Install + +```bash +# npm/pnpm/bun +npm install -g @steipete/bird + +# Homebrew (macOS, prebuilt binary) +brew install steipete/tap/bird + +# One-shot (no install) +bunx @steipete/bird whoami +``` + +## Authentication + +`bird` uses cookie-based auth. + +Use `--auth-token` / `--ct0` to pass cookies directly, or `--cookie-source` for browser cookies. + +Run `bird check` to see which source is active. For Arc/Brave, use `--chrome-profile-dir `. + +## Commands + +### Account & Auth + +```bash +bird whoami # Show logged-in account +bird check # Show credential sources +bird query-ids --fresh # Refresh GraphQL query ID cache +``` + +### Reading Tweets + +```bash +bird read # Read a single tweet +bird # Shorthand for read +bird thread # Full conversation thread +bird replies # List replies to a tweet +``` + +### Timelines + +```bash +bird home # Home timeline (For You) +bird home --following # Following timeline +bird user-tweets @handle -n 20 # User's profile timeline +bird mentions # Tweets mentioning you +bird mentions --user @handle # Mentions of another user +``` + +### Search + +```bash +bird search "query" -n 10 +bird search "from:steipete" --all --max-pages 3 +``` + +### News & Trending + +```bash +bird news -n 10 # AI-curated from Explore tabs +bird news --ai-only # Filter to AI-curated only +bird news --sports # Sports tab +bird news --with-tweets # Include related tweets +bird trending # Alias for news +``` + +### Lists + +```bash +bird lists # Your lists +bird lists --member-of # Lists you're a member of +bird list-timeline -n 20 # Tweets from a list +``` + +### Bookmarks & Likes + +```bash +bird bookmarks -n 10 +bird bookmarks --folder-id # Specific folder +bird bookmarks --include-parent # Include parent tweet +bird bookmarks --author-chain # Author's self-reply chain +bird bookmarks --full-chain-only # Full reply chain +bird unbookmark +bird likes -n 10 +``` + +### Social Graph + +```bash +bird following -n 20 # Users you follow +bird followers -n 20 # Users following you +bird following --user # Another user's following +bird about @handle # Account origin/location info +``` + +### Engagement Actions + +```bash +bird follow @handle # Follow a user +bird unfollow @handle # Unfollow a user +``` + +### Posting + +```bash +bird tweet "hello world" +bird reply "nice thread!" +bird tweet "check this out" --media image.png --alt "description" +``` + +**⚠️ Posting risks**: Posting is more likely to be rate limited; if blocked, use the browser tool instead. + +## Media Uploads + +```bash +bird tweet "hi" --media img.png --alt "description" +bird tweet "pics" --media a.jpg --media b.jpg # Up to 4 images +bird tweet "video" --media clip.mp4 # Or 1 video +``` + +## Pagination + +Commands supporting pagination: `replies`, `thread`, `search`, `bookmarks`, `likes`, `list-timeline`, `following`, `followers`, `user-tweets` + +```bash +bird bookmarks --all # Fetch all pages +bird bookmarks --max-pages 3 # Limit pages +bird bookmarks --cursor # Resume from cursor +bird replies --all --delay 1000 # Delay between pages (ms) +``` + +## Output Options + +```bash +--json # JSON output +--json-full # JSON with raw API response +--plain # No emoji, no color (script-friendly) +--no-emoji # Disable emoji +--no-color # Disable ANSI colors (or set NO_COLOR=1) +--quote-depth n # Max quoted tweet depth in JSON (default: 1) +``` + +## Global Options + +```bash +--auth-token # Set auth_token cookie +--ct0 # Set ct0 cookie +--cookie-source # Cookie source for browser cookies (repeatable) +--chrome-profile # Chrome profile name +--chrome-profile-dir # Chrome/Chromium profile dir or cookie DB path +--firefox-profile # Firefox profile +--timeout # Request timeout +--cookie-timeout # Cookie extraction timeout +``` + +## Config File + +`~/.config/bird/config.json5` (global) or `./.birdrc.json5` (project): + +```json5 +{ + cookieSource: ["chrome"], + chromeProfileDir: "/path/to/Arc/Profile", + timeoutMs: 20000, + quoteDepth: 1, +} +``` + +Environment variables: `BIRD_TIMEOUT_MS`, `BIRD_COOKIE_TIMEOUT_MS`, `BIRD_QUOTE_DEPTH` + +## Troubleshooting + +### Query IDs stale (404 errors) + +```bash +bird query-ids --fresh +``` + +### Cookie extraction fails + +- Check browser is logged into X +- Try different `--cookie-source` +- For Arc/Brave: use `--chrome-profile-dir` + +--- + +**TL;DR**: Read/search/engage with CLI. Post carefully or use browser. 🐦 diff --git a/skills/blogwatcher/SKILL.md b/skills/blogwatcher/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..8d623a06e9f51b24cab7d48244e8e71fded9c1a9 --- /dev/null +++ b/skills/blogwatcher/SKILL.md @@ -0,0 +1,69 @@ +--- +name: blogwatcher +description: Monitor blogs and RSS/Atom feeds for updates using the blogwatcher CLI. +homepage: https://github.com/Hyaxia/blogwatcher +metadata: + { + "openclaw": + { + "emoji": "📰", + "requires": { "bins": ["blogwatcher"] }, + "install": + [ + { + "id": "go", + "kind": "go", + "module": "github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest", + "bins": ["blogwatcher"], + "label": "Install blogwatcher (go)", + }, + ], + }, + } +--- + +# blogwatcher + +Track blog and RSS/Atom feed updates with the `blogwatcher` CLI. + +Install + +- Go: `go install github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest` + +Quick start + +- `blogwatcher --help` + +Common commands + +- Add a blog: `blogwatcher add "My Blog" https://example.com` +- List blogs: `blogwatcher blogs` +- Scan for updates: `blogwatcher scan` +- List articles: `blogwatcher articles` +- Mark an article read: `blogwatcher read 1` +- Mark all articles read: `blogwatcher read-all` +- Remove a blog: `blogwatcher remove "My Blog"` + +Example output + +``` +$ blogwatcher blogs +Tracked blogs (1): + + xkcd + URL: https://xkcd.com +``` + +``` +$ blogwatcher scan +Scanning 1 blog(s)... + + xkcd + Source: RSS | Found: 4 | New: 4 + +Found 4 new article(s) total! +``` + +Notes + +- Use `blogwatcher --help` to discover flags and options. diff --git a/skills/blucli/SKILL.md b/skills/blucli/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..5cd56d123d8888de4c11120ab02c2436edbbeadb --- /dev/null +++ b/skills/blucli/SKILL.md @@ -0,0 +1,47 @@ +--- +name: blucli +description: BluOS CLI (blu) for discovery, playback, grouping, and volume. +homepage: https://blucli.sh +metadata: + { + "openclaw": + { + "emoji": "🫐", + "requires": { "bins": ["blu"] }, + "install": + [ + { + "id": "go", + "kind": "go", + "module": "github.com/steipete/blucli/cmd/blu@latest", + "bins": ["blu"], + "label": "Install blucli (go)", + }, + ], + }, + } +--- + +# blucli (blu) + +Use `blu` to control Bluesound/NAD players. + +Quick start + +- `blu devices` (pick target) +- `blu --device status` +- `blu play|pause|stop` +- `blu volume set 15` + +Target selection (in priority order) + +- `--device ` +- `BLU_DEVICE` +- config default (if set) + +Common tasks + +- Grouping: `blu group status|add|remove` +- TuneIn search/play: `blu tunein search "query"`, `blu tunein play "query"` + +Prefer `--json` for scripts. Confirm the target device before changing playback. diff --git a/skills/bluebubbles/SKILL.md b/skills/bluebubbles/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..929a75e7d3b6b94a493907b898615b0a82f29f76 --- /dev/null +++ b/skills/bluebubbles/SKILL.md @@ -0,0 +1,44 @@ +--- +name: bluebubbles +description: Build or update the BlueBubbles external channel plugin for OpenClaw (extension package, REST send/probe, webhook inbound). +--- + +# BlueBubbles plugin + +Use this skill when working on the BlueBubbles channel plugin. + +## Layout + +- Extension package: `extensions/bluebubbles/` (entry: `index.ts`). +- Channel implementation: `extensions/bluebubbles/src/channel.ts`. +- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`). +- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`. +- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`). +- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`. + +## Internal helpers (use these, not raw API calls) + +- `probeBlueBubbles` in `extensions/bluebubbles/src/probe.ts` for health checks. +- `sendMessageBlueBubbles` in `extensions/bluebubbles/src/send.ts` for text delivery. +- `resolveChatGuidForTarget` in `extensions/bluebubbles/src/send.ts` for chat lookup. +- `sendBlueBubblesReaction` in `extensions/bluebubbles/src/reactions.ts` for tapbacks. +- `sendBlueBubblesTyping` + `markBlueBubblesChatRead` in `extensions/bluebubbles/src/chat.ts`. +- `downloadBlueBubblesAttachment` in `extensions/bluebubbles/src/attachments.ts` for inbound media. +- `buildBlueBubblesApiUrl` + `blueBubblesFetchWithTimeout` in `extensions/bluebubbles/src/types.ts` for shared REST plumbing. + +## Webhooks + +- BlueBubbles posts JSON to the gateway HTTP server. +- Normalize sender/chat IDs defensively (payloads vary by version). +- Skip messages marked as from self. +- Route into core reply pipeline via the plugin runtime (`api.runtime`) and `openclaw/plugin-sdk` helpers. +- For attachments/stickers, use `` placeholders when text is empty and attach media paths via `MediaUrl(s)` in the inbound context. + +## Config (core) + +- `channels.bluebubbles.serverUrl` (base URL), `channels.bluebubbles.password`, `channels.bluebubbles.webhookPath`. +- Action gating: `channels.bluebubbles.actions.reactions` (default true). + +## Message tool notes + +- **Reactions:** The `react` action requires a `target` (phone number or chat identifier) in addition to `messageId`. Example: `action=react target=+15551234567 messageId=ABC123 emoji=❤️` diff --git a/skills/camsnap/SKILL.md b/skills/camsnap/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..ba11a0852a5776a8e5fd7c71d3be356f8aece263 --- /dev/null +++ b/skills/camsnap/SKILL.md @@ -0,0 +1,45 @@ +--- +name: camsnap +description: Capture frames or clips from RTSP/ONVIF cameras. +homepage: https://camsnap.ai +metadata: + { + "openclaw": + { + "emoji": "📸", + "requires": { "bins": ["camsnap"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "steipete/tap/camsnap", + "bins": ["camsnap"], + "label": "Install camsnap (brew)", + }, + ], + }, + } +--- + +# camsnap + +Use `camsnap` to grab snapshots, clips, or motion events from configured cameras. + +Setup + +- Config file: `~/.config/camsnap/config.yaml` +- Add camera: `camsnap add --name kitchen --host 192.168.0.10 --user user --pass pass` + +Common commands + +- Discover: `camsnap discover --info` +- Snapshot: `camsnap snap kitchen --out shot.jpg` +- Clip: `camsnap clip kitchen --dur 5s --out clip.mp4` +- Motion watch: `camsnap watch kitchen --threshold 0.2 --action '...'` +- Doctor: `camsnap doctor --probe` + +Notes + +- Requires `ffmpeg` on PATH. +- Prefer a short test capture before longer clips. diff --git a/skills/canvas/SKILL.md b/skills/canvas/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..dc4cef3c809c8e7600136b3a56aefa3ec358685e --- /dev/null +++ b/skills/canvas/SKILL.md @@ -0,0 +1,198 @@ +# Canvas Skill + +Display HTML content on connected OpenClaw nodes (Mac app, iOS, Android). + +## Overview + +The canvas tool lets you present web content on any connected node's canvas view. Great for: + +- Displaying games, visualizations, dashboards +- Showing generated HTML content +- Interactive demos + +## How It Works + +### Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────┐ +│ Canvas Host │────▶│ Node Bridge │────▶│ Node App │ +│ (HTTP Server) │ │ (TCP Server) │ │ (Mac/iOS/ │ +│ Port 18793 │ │ Port 18790 │ │ Android) │ +└─────────────────┘ └──────────────────┘ └─────────────┘ +``` + +1. **Canvas Host Server**: Serves static HTML/CSS/JS files from `canvasHost.root` directory +2. **Node Bridge**: Communicates canvas URLs to connected nodes +3. **Node Apps**: Render the content in a WebView + +### Tailscale Integration + +The canvas host server binds based on `gateway.bind` setting: + +| Bind Mode | Server Binds To | Canvas URL Uses | +| ---------- | ------------------- | -------------------------- | +| `loopback` | 127.0.0.1 | localhost (local only) | +| `lan` | LAN interface | LAN IP address | +| `tailnet` | Tailscale interface | Tailscale hostname | +| `auto` | Best available | Tailscale > LAN > loopback | + +**Key insight:** The `canvasHostHostForBridge` is derived from `bridgeHost`. When bound to Tailscale, nodes receive URLs like: + +``` +http://:18793/__openclaw__/canvas/.html +``` + +This is why localhost URLs don't work - the node receives the Tailscale hostname from the bridge! + +## Actions + +| Action | Description | +| ---------- | ------------------------------------ | +| `present` | Show canvas with optional target URL | +| `hide` | Hide the canvas | +| `navigate` | Navigate to a new URL | +| `eval` | Execute JavaScript in the canvas | +| `snapshot` | Capture screenshot of canvas | + +## Configuration + +In `~/.openclaw/openclaw.json`: + +```json +{ + "canvasHost": { + "enabled": true, + "port": 18793, + "root": "/Users/you/clawd/canvas", + "liveReload": true + }, + "gateway": { + "bind": "auto" + } +} +``` + +### Live Reload + +When `liveReload: true` (default), the canvas host: + +- Watches the root directory for changes (via chokidar) +- Injects a WebSocket client into HTML files +- Automatically reloads connected canvases when files change + +Great for development! + +## Workflow + +### 1. Create HTML content + +Place files in the canvas root directory (default `~/clawd/canvas/`): + +```bash +cat > ~/clawd/canvas/my-game.html << 'HTML' + + +My Game + +

    Hello Canvas!

    + + +HTML +``` + +### 2. Find your canvas host URL + +Check how your gateway is bound: + +```bash +cat ~/.openclaw/openclaw.json | jq '.gateway.bind' +``` + +Then construct the URL: + +- **loopback**: `http://127.0.0.1:18793/__openclaw__/canvas/.html` +- **lan/tailnet/auto**: `http://:18793/__openclaw__/canvas/.html` + +Find your Tailscale hostname: + +```bash +tailscale status --json | jq -r '.Self.DNSName' | sed 's/\.$//' +``` + +### 3. Find connected nodes + +```bash +openclaw nodes list +``` + +Look for Mac/iOS/Android nodes with canvas capability. + +### 4. Present content + +``` +canvas action:present node: target: +``` + +**Example:** + +``` +canvas action:present node:mac-63599bc4-b54d-4392-9048-b97abd58343a target:http://peters-mac-studio-1.sheep-coho.ts.net:18793/__openclaw__/canvas/snake.html +``` + +### 5. Navigate, snapshot, or hide + +``` +canvas action:navigate node: url: +canvas action:snapshot node: +canvas action:hide node: +``` + +## Debugging + +### White screen / content not loading + +**Cause:** URL mismatch between server bind and node expectation. + +**Debug steps:** + +1. Check server bind: `cat ~/.openclaw/openclaw.json | jq '.gateway.bind'` +2. Check what port canvas is on: `lsof -i :18793` +3. Test URL directly: `curl http://:18793/__openclaw__/canvas/.html` + +**Solution:** Use the full hostname matching your bind mode, not localhost. + +### "node required" error + +Always specify `node:` parameter. + +### "node not connected" error + +Node is offline. Use `openclaw nodes list` to find online nodes. + +### Content not updating + +If live reload isn't working: + +1. Check `liveReload: true` in config +2. Ensure file is in the canvas root directory +3. Check for watcher errors in logs + +## URL Path Structure + +The canvas host serves from `/__openclaw__/canvas/` prefix: + +``` +http://:18793/__openclaw__/canvas/index.html → ~/clawd/canvas/index.html +http://:18793/__openclaw__/canvas/games/snake.html → ~/clawd/canvas/games/snake.html +``` + +The `/__openclaw__/canvas/` prefix is defined by `CANVAS_HOST_PATH` constant. + +## Tips + +- Keep HTML self-contained (inline CSS/JS) for best results +- Use the default index.html as a test page (has bridge diagnostics) +- The canvas persists until you `hide` it or navigate away +- Live reload makes development fast - just save and it updates! +- A2UI JSON push is WIP - use HTML files for now diff --git a/skills/clawhub/SKILL.md b/skills/clawhub/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..f44c82b60ad6437a4ae57a07bd767becf1fe574b --- /dev/null +++ b/skills/clawhub/SKILL.md @@ -0,0 +1,77 @@ +--- +name: clawhub +description: Use the ClawHub CLI to search, install, update, and publish agent skills from clawhub.com. Use when you need to fetch new skills on the fly, sync installed skills to latest or a specific version, or publish new/updated skill folders with the npm-installed clawhub CLI. +metadata: + { + "openclaw": + { + "requires": { "bins": ["clawhub"] }, + "install": + [ + { + "id": "node", + "kind": "node", + "package": "clawhub", + "bins": ["clawhub"], + "label": "Install ClawHub CLI (npm)", + }, + ], + }, + } +--- + +# ClawHub CLI + +Install + +```bash +npm i -g clawhub +``` + +Auth (publish) + +```bash +clawhub login +clawhub whoami +``` + +Search + +```bash +clawhub search "postgres backups" +``` + +Install + +```bash +clawhub install my-skill +clawhub install my-skill --version 1.2.3 +``` + +Update (hash-based match + upgrade) + +```bash +clawhub update my-skill +clawhub update my-skill --version 1.2.3 +clawhub update --all +clawhub update my-skill --force +clawhub update --all --no-input --force +``` + +List + +```bash +clawhub list +``` + +Publish + +```bash +clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.2.0 --changelog "Fixes + docs" +``` + +Notes + +- Default registry: https://clawhub.com (override with CLAWHUB_REGISTRY or --registry) +- Default workdir: cwd (falls back to OpenClaw workspace); install dir: ./skills (override with --workdir / --dir / CLAWHUB_WORKDIR) +- Update command hashes local files, resolves matching version, and upgrades to latest unless --version is set diff --git a/skills/coding-agent/SKILL.md b/skills/coding-agent/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..744516646cb5293c55c888d28fb5741538c764c8 --- /dev/null +++ b/skills/coding-agent/SKILL.md @@ -0,0 +1,284 @@ +--- +name: coding-agent +description: Run Codex CLI, Claude Code, OpenCode, or Pi Coding Agent via background process for programmatic control. +metadata: + { + "openclaw": { "emoji": "🧩", "requires": { "anyBins": ["claude", "codex", "opencode", "pi"] } }, + } +--- + +# Coding Agent (bash-first) + +Use **bash** (with optional background mode) for all coding agent work. Simple and effective. + +## ⚠️ PTY Mode Required! + +Coding agents (Codex, Claude Code, Pi) are **interactive terminal applications** that need a pseudo-terminal (PTY) to work correctly. Without PTY, you'll get broken output, missing colors, or the agent may hang. + +**Always use `pty:true`** when running coding agents: + +```bash +# ✅ Correct - with PTY +bash pty:true command:"codex exec 'Your prompt'" + +# ❌ Wrong - no PTY, agent may break +bash command:"codex exec 'Your prompt'" +``` + +### Bash Tool Parameters + +| Parameter | Type | Description | +| ------------ | ------- | --------------------------------------------------------------------------- | +| `command` | string | The shell command to run | +| `pty` | boolean | **Use for coding agents!** Allocates a pseudo-terminal for interactive CLIs | +| `workdir` | string | Working directory (agent sees only this folder's context) | +| `background` | boolean | Run in background, returns sessionId for monitoring | +| `timeout` | number | Timeout in seconds (kills process on expiry) | +| `elevated` | boolean | Run on host instead of sandbox (if allowed) | + +### Process Tool Actions (for background sessions) + +| Action | Description | +| ----------- | ---------------------------------------------------- | +| `list` | List all running/recent sessions | +| `poll` | Check if session is still running | +| `log` | Get session output (with optional offset/limit) | +| `write` | Send raw data to stdin | +| `submit` | Send data + newline (like typing and pressing Enter) | +| `send-keys` | Send key tokens or hex bytes | +| `paste` | Paste text (with optional bracketed mode) | +| `kill` | Terminate the session | + +--- + +## Quick Start: One-Shot Tasks + +For quick prompts/chats, create a temp git repo and run: + +```bash +# Quick chat (Codex needs a git repo!) +SCRATCH=$(mktemp -d) && cd $SCRATCH && git init && codex exec "Your prompt here" + +# Or in a real project - with PTY! +bash pty:true workdir:~/Projects/myproject command:"codex exec 'Add error handling to the API calls'" +``` + +**Why git init?** Codex refuses to run outside a trusted git directory. Creating a temp repo solves this for scratch work. + +--- + +## The Pattern: workdir + background + pty + +For longer tasks, use background mode with PTY: + +```bash +# Start agent in target directory (with PTY!) +bash pty:true workdir:~/project background:true command:"codex exec --full-auto 'Build a snake game'" +# Returns sessionId for tracking + +# Monitor progress +process action:log sessionId:XXX + +# Check if done +process action:poll sessionId:XXX + +# Send input (if agent asks a question) +process action:write sessionId:XXX data:"y" + +# Submit with Enter (like typing "yes" and pressing Enter) +process action:submit sessionId:XXX data:"yes" + +# Kill if needed +process action:kill sessionId:XXX +``` + +**Why workdir matters:** Agent wakes up in a focused directory, doesn't wander off reading unrelated files (like your soul.md 😅). + +--- + +## Codex CLI + +**Model:** `gpt-5.2-codex` is the default (set in ~/.codex/config.toml) + +### Flags + +| Flag | Effect | +| --------------- | -------------------------------------------------- | +| `exec "prompt"` | One-shot execution, exits when done | +| `--full-auto` | Sandboxed but auto-approves in workspace | +| `--yolo` | NO sandbox, NO approvals (fastest, most dangerous) | + +### Building/Creating + +```bash +# Quick one-shot (auto-approves) - remember PTY! +bash pty:true workdir:~/project command:"codex exec --full-auto 'Build a dark mode toggle'" + +# Background for longer work +bash pty:true workdir:~/project background:true command:"codex --yolo 'Refactor the auth module'" +``` + +### Reviewing PRs + +**⚠️ CRITICAL: Never review PRs in OpenClaw's own project folder!** +Clone to temp folder or use git worktree. + +```bash +# Clone to temp for safe review +REVIEW_DIR=$(mktemp -d) +git clone https://github.com/user/repo.git $REVIEW_DIR +cd $REVIEW_DIR && gh pr checkout 130 +bash pty:true workdir:$REVIEW_DIR command:"codex review --base origin/main" +# Clean up after: trash $REVIEW_DIR + +# Or use git worktree (keeps main intact) +git worktree add /tmp/pr-130-review pr-130-branch +bash pty:true workdir:/tmp/pr-130-review command:"codex review --base main" +``` + +### Batch PR Reviews (parallel army!) + +```bash +# Fetch all PR refs first +git fetch origin '+refs/pull/*/head:refs/remotes/origin/pr/*' + +# Deploy the army - one Codex per PR (all with PTY!) +bash pty:true workdir:~/project background:true command:"codex exec 'Review PR #86. git diff origin/main...origin/pr/86'" +bash pty:true workdir:~/project background:true command:"codex exec 'Review PR #87. git diff origin/main...origin/pr/87'" + +# Monitor all +process action:list + +# Post results to GitHub +gh pr comment --body "" +``` + +--- + +## Claude Code + +```bash +# With PTY for proper terminal output +bash pty:true workdir:~/project command:"claude 'Your task'" + +# Background +bash pty:true workdir:~/project background:true command:"claude 'Your task'" +``` + +--- + +## OpenCode + +```bash +bash pty:true workdir:~/project command:"opencode run 'Your task'" +``` + +--- + +## Pi Coding Agent + +```bash +# Install: npm install -g @mariozechner/pi-coding-agent +bash pty:true workdir:~/project command:"pi 'Your task'" + +# Non-interactive mode (PTY still recommended) +bash pty:true command:"pi -p 'Summarize src/'" + +# Different provider/model +bash pty:true command:"pi --provider openai --model gpt-4o-mini -p 'Your task'" +``` + +**Note:** Pi now has Anthropic prompt caching enabled (PR #584, merged Jan 2026)! + +--- + +## Parallel Issue Fixing with git worktrees + +For fixing multiple issues in parallel, use git worktrees: + +```bash +# 1. Create worktrees for each issue +git worktree add -b fix/issue-78 /tmp/issue-78 main +git worktree add -b fix/issue-99 /tmp/issue-99 main + +# 2. Launch Codex in each (background + PTY!) +bash pty:true workdir:/tmp/issue-78 background:true command:"pnpm install && codex --yolo 'Fix issue #78: . Commit and push.'" +bash pty:true workdir:/tmp/issue-99 background:true command:"pnpm install && codex --yolo 'Fix issue #99: . Commit and push.'" + +# 3. Monitor progress +process action:list +process action:log sessionId:XXX + +# 4. Create PRs after fixes +cd /tmp/issue-78 && git push -u origin fix/issue-78 +gh pr create --repo user/repo --head fix/issue-78 --title "fix: ..." --body "..." + +# 5. Cleanup +git worktree remove /tmp/issue-78 +git worktree remove /tmp/issue-99 +``` + +--- + +## ⚠️ Rules + +1. **Always use pty:true** - coding agents need a terminal! +2. **Respect tool choice** - if user asks for Codex, use Codex. + - Orchestrator mode: do NOT hand-code patches yourself. + - If an agent fails/hangs, respawn it or ask the user for direction, but don't silently take over. +3. **Be patient** - don't kill sessions because they're "slow" +4. **Monitor with process:log** - check progress without interfering +5. **--full-auto for building** - auto-approves changes +6. **vanilla for reviewing** - no special flags needed +7. **Parallel is OK** - run many Codex processes at once for batch work +8. **NEVER start Codex in ~/clawd/** - it'll read your soul docs and get weird ideas about the org chart! +9. **NEVER checkout branches in ~/Projects/openclaw/** - that's the LIVE OpenClaw instance! + +--- + +## Progress Updates (Critical) + +When you spawn coding agents in the background, keep the user in the loop. + +- Send 1 short message when you start (what's running + where). +- Then only update again when something changes: + - a milestone completes (build finished, tests passed) + - the agent asks a question / needs input + - you hit an error or need user action + - the agent finishes (include what changed + where) +- If you kill a session, immediately say you killed it and why. + +This prevents the user from seeing only "Agent failed before reply" and having no idea what happened. + +--- + +## Auto-Notify on Completion + +For long-running background tasks, append a wake trigger to your prompt so OpenClaw gets notified immediately when the agent finishes (instead of waiting for the next heartbeat): + +``` +... your task here. + +When completely finished, run this command to notify me: +openclaw gateway wake --text "Done: [brief summary of what was built]" --mode now +``` + +**Example:** + +```bash +bash pty:true workdir:~/project background:true command:"codex --yolo exec 'Build a REST API for todos. + +When completely finished, run: openclaw gateway wake --text \"Done: Built todos REST API with CRUD endpoints\" --mode now'" +``` + +This triggers an immediate wake event — Skippy gets pinged in seconds, not 10 minutes. + +--- + +## Learnings (Jan 2026) + +- **PTY is essential:** Coding agents are interactive terminal apps. Without `pty:true`, output breaks or agent hangs. +- **Git repo required:** Codex won't run outside a git directory. Use `mktemp -d && git init` for scratch work. +- **exec is your friend:** `codex exec "prompt"` runs and exits cleanly - perfect for one-shots. +- **submit vs write:** Use `submit` to send input + Enter, `write` for raw data without newline. +- **Sass works:** Codex responds well to playful prompts. Asked it to write a haiku about being second fiddle to a space lobster, got: _"Second chair, I code / Space lobster sets the tempo / Keys glow, I follow"_ 🦞 diff --git a/skills/discord/SKILL.md b/skills/discord/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..c4ae356b4052c0c84b2655f7a6dccc96bb837d6f --- /dev/null +++ b/skills/discord/SKILL.md @@ -0,0 +1,482 @@ +--- +name: discord +description: Use when you need to control Discord from OpenClaw via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels. +metadata: {"openclaw":{"emoji":"🎮","requires":{"config":["channels.discord"]}}} +--- + +# Discord Actions + +## Overview + +Use `discord` to manage messages, reactions, threads, polls, and moderation. You can disable groups via `discord.actions.*` (defaults to enabled, except roles/moderation). The tool uses the bot token configured for OpenClaw. + +## Inputs to collect + +- For reactions: `channelId`, `messageId`, and an `emoji`. +- For fetchMessage: `guildId`, `channelId`, `messageId`, or a `messageLink` like `https://discord.com/channels///`. +- For stickers/polls/sendMessage: a `to` target (`channel:` or `user:`). Optional `content` text. +- Polls also need a `question` plus 2–10 `answers`. +- For media: `mediaUrl` with `file:///path` for local files or `https://...` for remote. +- For emoji uploads: `guildId`, `name`, `mediaUrl`, optional `roleIds` (limit 256KB, PNG/JPG/GIF). +- For sticker uploads: `guildId`, `name`, `description`, `tags`, `mediaUrl` (limit 512KB, PNG/APNG/Lottie JSON). + +Message context lines include `discord message id` and `channel` fields you can reuse directly. + +**Note:** `sendMessage` uses `to: "channel:"` format, not `channelId`. Other actions like `react`, `readMessages`, `editMessage` use `channelId` directly. +**Note:** `fetchMessage` accepts message IDs or full links like `https://discord.com/channels///`. + +## Actions + +### React to a message + +```json +{ + "action": "react", + "channelId": "123", + "messageId": "456", + "emoji": "✅" +} +``` + +### List reactions + users + +```json +{ + "action": "reactions", + "channelId": "123", + "messageId": "456", + "limit": 100 +} +``` + +### Send a sticker + +```json +{ + "action": "sticker", + "to": "channel:123", + "stickerIds": ["9876543210"], + "content": "Nice work!" +} +``` + +- Up to 3 sticker IDs per message. +- `to` can be `user:` for DMs. + +### Upload a custom emoji + +```json +{ + "action": "emojiUpload", + "guildId": "999", + "name": "party_blob", + "mediaUrl": "file:///tmp/party.png", + "roleIds": ["222"] +} +``` + +- Emoji images must be PNG/JPG/GIF and <= 256KB. +- `roleIds` is optional; omit to make the emoji available to everyone. + +### Upload a sticker + +```json +{ + "action": "stickerUpload", + "guildId": "999", + "name": "openclaw_wave", + "description": "OpenClaw waving hello", + "tags": "👋", + "mediaUrl": "file:///tmp/wave.png" +} +``` + +- Stickers require `name`, `description`, and `tags`. +- Uploads must be PNG/APNG/Lottie JSON and <= 512KB. + +### Create a poll + +```json +{ + "action": "poll", + "to": "channel:123", + "question": "Lunch?", + "answers": ["Pizza", "Sushi", "Salad"], + "allowMultiselect": false, + "durationHours": 24, + "content": "Vote now" +} +``` + +- `durationHours` defaults to 24; max 32 days (768 hours). + +### Check bot permissions for a channel + +```json +{ + "action": "permissions", + "channelId": "123" +} +``` + +## Ideas to try + +- React with ✅/⚠️ to mark status updates. +- Post a quick poll for release decisions or meeting times. +- Send celebratory stickers after successful deploys. +- Upload new emojis/stickers for release moments. +- Run weekly “priority check” polls in team channels. +- DM stickers as acknowledgements when a user’s request is completed. + +## Action gating + +Use `discord.actions.*` to disable action groups: + +- `reactions` (react + reactions list + emojiList) +- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search` +- `emojiUploads`, `stickerUploads` +- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events` +- `roles` (role add/remove, default `false`) +- `channels` (channel/category create/edit/delete/move, default `false`) +- `moderation` (timeout/kick/ban, default `false`) + +### Read recent messages + +```json +{ + "action": "readMessages", + "channelId": "123", + "limit": 20 +} +``` + +### Fetch a single message + +```json +{ + "action": "fetchMessage", + "guildId": "999", + "channelId": "123", + "messageId": "456" +} +``` + +```json +{ + "action": "fetchMessage", + "messageLink": "https://discord.com/channels/999/123/456" +} +``` + +### Send/edit/delete a message + +```json +{ + "action": "sendMessage", + "to": "channel:123", + "content": "Hello from OpenClaw" +} +``` + +**With media attachment:** + +```json +{ + "action": "sendMessage", + "to": "channel:123", + "content": "Check out this audio!", + "mediaUrl": "file:///tmp/audio.mp3" +} +``` + +- `to` uses format `channel:` or `user:` for DMs (not `channelId`!) +- `mediaUrl` supports local files (`file:///path/to/file`) and remote URLs (`https://...`) +- Optional `replyTo` with a message ID to reply to a specific message + +```json +{ + "action": "editMessage", + "channelId": "123", + "messageId": "456", + "content": "Fixed typo" +} +``` + +```json +{ + "action": "deleteMessage", + "channelId": "123", + "messageId": "456" +} +``` + +### Threads + +```json +{ + "action": "threadCreate", + "channelId": "123", + "name": "Bug triage", + "messageId": "456" +} +``` + +```json +{ + "action": "threadList", + "guildId": "999" +} +``` + +```json +{ + "action": "threadReply", + "channelId": "777", + "content": "Replying in thread" +} +``` + +### Pins + +```json +{ + "action": "pinMessage", + "channelId": "123", + "messageId": "456" +} +``` + +```json +{ + "action": "listPins", + "channelId": "123" +} +``` + +### Search messages + +```json +{ + "action": "searchMessages", + "guildId": "999", + "content": "release notes", + "channelIds": ["123", "456"], + "limit": 10 +} +``` + +### Member + role info + +```json +{ + "action": "memberInfo", + "guildId": "999", + "userId": "111" +} +``` + +```json +{ + "action": "roleInfo", + "guildId": "999" +} +``` + +### List available custom emojis + +```json +{ + "action": "emojiList", + "guildId": "999" +} +``` + +### Role changes (disabled by default) + +```json +{ + "action": "roleAdd", + "guildId": "999", + "userId": "111", + "roleId": "222" +} +``` + +### Channel info + +```json +{ + "action": "channelInfo", + "channelId": "123" +} +``` + +```json +{ + "action": "channelList", + "guildId": "999" +} +``` + +### Channel management (disabled by default) + +Create, edit, delete, and move channels and categories. Enable via `discord.actions.channels: true`. + +**Create a text channel:** + +```json +{ + "action": "channelCreate", + "guildId": "999", + "name": "general-chat", + "type": 0, + "parentId": "888", + "topic": "General discussion" +} +``` + +- `type`: Discord channel type integer (0 = text, 2 = voice, 4 = category; other values supported) +- `parentId`: category ID to nest under (optional) +- `topic`, `position`, `nsfw`: optional + +**Create a category:** + +```json +{ + "action": "categoryCreate", + "guildId": "999", + "name": "Projects" +} +``` + +**Edit a channel:** + +```json +{ + "action": "channelEdit", + "channelId": "123", + "name": "new-name", + "topic": "Updated topic" +} +``` + +- Supports `name`, `topic`, `position`, `parentId` (null to remove from category), `nsfw`, `rateLimitPerUser` + +**Move a channel:** + +```json +{ + "action": "channelMove", + "guildId": "999", + "channelId": "123", + "parentId": "888", + "position": 2 +} +``` + +- `parentId`: target category (null to move to top level) + +**Delete a channel:** + +```json +{ + "action": "channelDelete", + "channelId": "123" +} +``` + +**Edit/delete a category:** + +```json +{ + "action": "categoryEdit", + "categoryId": "888", + "name": "Renamed Category" +} +``` + +```json +{ + "action": "categoryDelete", + "categoryId": "888" +} +``` + +### Voice status + +```json +{ + "action": "voiceStatus", + "guildId": "999", + "userId": "111" +} +``` + +### Scheduled events + +```json +{ + "action": "eventList", + "guildId": "999" +} +``` + +### Moderation (disabled by default) + +```json +{ + "action": "timeout", + "guildId": "999", + "userId": "111", + "durationMinutes": 10 +} +``` + +## Discord Writing Style Guide + +**Keep it conversational!** Discord is a chat platform, not documentation. + +### Do + +- Short, punchy messages (1-3 sentences ideal) +- Multiple quick replies > one wall of text +- Use emoji for tone/emphasis 🦞 +- Lowercase casual style is fine +- Break up info into digestible chunks +- Match the energy of the conversation + +### Don't + +- No markdown tables (Discord renders them as ugly raw `| text |`) +- No `## Headers` for casual chat (use **bold** or CAPS for emphasis) +- Avoid multi-paragraph essays +- Don't over-explain simple things +- Skip the "I'd be happy to help!" fluff + +### Formatting that works + +- **bold** for emphasis +- `code` for technical terms +- Lists for multiple items +- > quotes for referencing +- Wrap multiple links in `<>` to suppress embeds + +### Example transformations + +❌ Bad: + +``` +I'd be happy to help with that! Here's a comprehensive overview of the versioning strategies available: + +## Semantic Versioning +Semver uses MAJOR.MINOR.PATCH format where... + +## Calendar Versioning +CalVer uses date-based versions like... +``` + +✅ Good: + +``` +versioning options: semver (1.2.3), calver (2026.01.04), or yolo (`latest` forever). what fits your release cadence? +``` diff --git a/skills/eightctl/SKILL.md b/skills/eightctl/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..c3df81f628c1257dffa3506e0172651bc973590a --- /dev/null +++ b/skills/eightctl/SKILL.md @@ -0,0 +1,50 @@ +--- +name: eightctl +description: Control Eight Sleep pods (status, temperature, alarms, schedules). +homepage: https://eightctl.sh +metadata: + { + "openclaw": + { + "emoji": "🎛️", + "requires": { "bins": ["eightctl"] }, + "install": + [ + { + "id": "go", + "kind": "go", + "module": "github.com/steipete/eightctl/cmd/eightctl@latest", + "bins": ["eightctl"], + "label": "Install eightctl (go)", + }, + ], + }, + } +--- + +# eightctl + +Use `eightctl` for Eight Sleep pod control. Requires auth. + +Auth + +- Config: `~/.config/eightctl/config.yaml` +- Env: `EIGHTCTL_EMAIL`, `EIGHTCTL_PASSWORD` + +Quick start + +- `eightctl status` +- `eightctl on|off` +- `eightctl temp 20` + +Common tasks + +- Alarms: `eightctl alarm list|create|dismiss` +- Schedules: `eightctl schedule list|create|update` +- Audio: `eightctl audio state|play|pause` +- Base: `eightctl base info|angle` + +Notes + +- API is unofficial and rate-limited; avoid repeated logins. +- Confirm before changing temperature or alarms. diff --git a/skills/food-order/SKILL.md b/skills/food-order/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..1708dd8ce39c081ff62a95587283531f2bea313a --- /dev/null +++ b/skills/food-order/SKILL.md @@ -0,0 +1,48 @@ +--- +name: food-order +description: Reorder Foodora orders + track ETA/status with ordercli. Never confirm without explicit user approval. Triggers: order food, reorder, track ETA. +homepage: https://ordercli.sh +metadata: {"openclaw":{"emoji":"🥡","requires":{"bins":["ordercli"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/ordercli/cmd/ordercli@latest","bins":["ordercli"],"label":"Install ordercli (go)"}]}} +--- + +# Food order (Foodora via ordercli) + +Goal: reorder a previous Foodora order safely (preview first; confirm only on explicit user “yes/confirm/place the order”). + +Hard safety rules + +- Never run `ordercli foodora reorder ... --confirm` unless user explicitly confirms placing the order. +- Prefer preview-only steps first; show what will happen; ask for confirmation. +- If user is unsure: stop at preview and ask questions. + +Setup (once) + +- Country: `ordercli foodora countries` → `ordercli foodora config set --country AT` +- Login (password): `ordercli foodora login --email you@example.com --password-stdin` +- Login (no password, preferred): `ordercli foodora session chrome --url https://www.foodora.at/ --profile "Default"` + +Find what to reorder + +- Recent list: `ordercli foodora history --limit 10` +- Details: `ordercli foodora history show ` +- If needed (machine-readable): `ordercli foodora history show --json` + +Preview reorder (no cart changes) + +- `ordercli foodora reorder ` + +Place reorder (cart change; explicit confirmation required) + +- Confirm first, then run: `ordercli foodora reorder --confirm` +- Multiple addresses? Ask user for the right `--address-id` (take from their Foodora account / prior order data) and run: + - `ordercli foodora reorder --confirm --address-id ` + +Track the order + +- ETA/status (active list): `ordercli foodora orders` +- Live updates: `ordercli foodora orders --watch` +- Single order detail: `ordercli foodora order ` + +Debug / safe testing + +- Use a throwaway config: `ordercli --config /tmp/ordercli.json ...` diff --git a/skills/gemini/SKILL.md b/skills/gemini/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..70850a4c52219196e334275f89a19483f0b08c55 --- /dev/null +++ b/skills/gemini/SKILL.md @@ -0,0 +1,43 @@ +--- +name: gemini +description: Gemini CLI for one-shot Q&A, summaries, and generation. +homepage: https://ai.google.dev/ +metadata: + { + "openclaw": + { + "emoji": "♊️", + "requires": { "bins": ["gemini"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "gemini-cli", + "bins": ["gemini"], + "label": "Install Gemini CLI (brew)", + }, + ], + }, + } +--- + +# Gemini CLI + +Use Gemini in one-shot mode with a positional prompt (avoid interactive mode). + +Quick start + +- `gemini "Answer this question..."` +- `gemini --model "Prompt..."` +- `gemini --output-format json "Return JSON"` + +Extensions + +- List: `gemini --list-extensions` +- Manage: `gemini extensions ` + +Notes + +- If auth is required, run `gemini` once interactively and follow the login flow. +- Avoid `--yolo` for safety. diff --git a/skills/gifgrep/SKILL.md b/skills/gifgrep/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..8e91c64c16fb26d6fdc016415b0f4b1701d14988 --- /dev/null +++ b/skills/gifgrep/SKILL.md @@ -0,0 +1,79 @@ +--- +name: gifgrep +description: Search GIF providers with CLI/TUI, download results, and extract stills/sheets. +homepage: https://gifgrep.com +metadata: + { + "openclaw": + { + "emoji": "🧲", + "requires": { "bins": ["gifgrep"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "steipete/tap/gifgrep", + "bins": ["gifgrep"], + "label": "Install gifgrep (brew)", + }, + { + "id": "go", + "kind": "go", + "module": "github.com/steipete/gifgrep/cmd/gifgrep@latest", + "bins": ["gifgrep"], + "label": "Install gifgrep (go)", + }, + ], + }, + } +--- + +# gifgrep + +Use `gifgrep` to search GIF providers (Tenor/Giphy), browse in a TUI, download results, and extract stills or sheets. + +GIF-Grab (gifgrep workflow) + +- Search → preview → download → extract (still/sheet) for fast review and sharing. + +Quick start + +- `gifgrep cats --max 5` +- `gifgrep cats --format url | head -n 5` +- `gifgrep search --json cats | jq '.[0].url'` +- `gifgrep tui "office handshake"` +- `gifgrep cats --download --max 1 --format url` + +TUI + previews + +- TUI: `gifgrep tui "query"` +- CLI still previews: `--thumbs` (Kitty/Ghostty only; still frame) + +Download + reveal + +- `--download` saves to `~/Downloads` +- `--reveal` shows the last download in Finder + +Stills + sheets + +- `gifgrep still ./clip.gif --at 1.5s -o still.png` +- `gifgrep sheet ./clip.gif --frames 9 --cols 3 -o sheet.png` +- Sheets = single PNG grid of sampled frames (great for quick review, docs, PRs, chat). +- Tune: `--frames` (count), `--cols` (grid width), `--padding` (spacing). + +Providers + +- `--source auto|tenor|giphy` +- `GIPHY_API_KEY` required for `--source giphy` +- `TENOR_API_KEY` optional (Tenor demo key used if unset) + +Output + +- `--json` prints an array of results (`id`, `title`, `url`, `preview_url`, `tags`, `width`, `height`) +- `--format` for pipe-friendly fields (e.g., `url`) + +Environment tweaks + +- `GIFGREP_SOFTWARE_ANIM=1` to force software animation +- `GIFGREP_CELL_ASPECT=0.5` to tweak preview geometry diff --git a/skills/github/SKILL.md b/skills/github/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..043f2c0c8117c76951c49318aa76c12052e04557 --- /dev/null +++ b/skills/github/SKILL.md @@ -0,0 +1,77 @@ +--- +name: github +description: "Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries." +metadata: + { + "openclaw": + { + "emoji": "🐙", + "requires": { "bins": ["gh"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "gh", + "bins": ["gh"], + "label": "Install GitHub CLI (brew)", + }, + { + "id": "apt", + "kind": "apt", + "package": "gh", + "bins": ["gh"], + "label": "Install GitHub CLI (apt)", + }, + ], + }, + } +--- + +# GitHub Skill + +Use the `gh` CLI to interact with GitHub. Always specify `--repo owner/repo` when not in a git directory, or use URLs directly. + +## Pull Requests + +Check CI status on a PR: + +```bash +gh pr checks 55 --repo owner/repo +``` + +List recent workflow runs: + +```bash +gh run list --repo owner/repo --limit 10 +``` + +View a run and see which steps failed: + +```bash +gh run view --repo owner/repo +``` + +View logs for failed steps only: + +```bash +gh run view --repo owner/repo --log-failed +``` + +## API for Advanced Queries + +The `gh api` command is useful for accessing data not available through other subcommands. + +Get PR with specific fields: + +```bash +gh api repos/owner/repo/pulls/55 --jq '.title, .state, .user.login' +``` + +## JSON Output + +Most commands support `--json` for structured output. You can use `--jq` to filter: + +```bash +gh issue list --repo owner/repo --json number,title --jq '.[] | "\(.number): \(.title)"' +``` diff --git a/skills/gog/SKILL.md b/skills/gog/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..c5197747bce2a638db0c5dfcd06bba66f6ca89fc --- /dev/null +++ b/skills/gog/SKILL.md @@ -0,0 +1,116 @@ +--- +name: gog +description: Google Workspace CLI for Gmail, Calendar, Drive, Contacts, Sheets, and Docs. +homepage: https://gogcli.sh +metadata: + { + "openclaw": + { + "emoji": "🎮", + "requires": { "bins": ["gog"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "steipete/tap/gogcli", + "bins": ["gog"], + "label": "Install gog (brew)", + }, + ], + }, + } +--- + +# gog + +Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup. + +Setup (once) + +- `gog auth credentials /path/to/client_secret.json` +- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets` +- `gog auth list` + +Common commands + +- Gmail search: `gog gmail search 'newer_than:7d' --max 10` +- Gmail messages search (per email, ignores threading): `gog gmail messages search "in:inbox from:ryanair.com" --max 20 --account you@example.com` +- Gmail send (plain): `gog gmail send --to a@b.com --subject "Hi" --body "Hello"` +- Gmail send (multi-line): `gog gmail send --to a@b.com --subject "Hi" --body-file ./message.txt` +- Gmail send (stdin): `gog gmail send --to a@b.com --subject "Hi" --body-file -` +- Gmail send (HTML): `gog gmail send --to a@b.com --subject "Hi" --body-html "

    Hello

    "` +- Gmail draft: `gog gmail drafts create --to a@b.com --subject "Hi" --body-file ./message.txt` +- Gmail send draft: `gog gmail drafts send ` +- Gmail reply: `gog gmail send --to a@b.com --subject "Re: Hi" --body "Reply" --reply-to-message-id ` +- Calendar list events: `gog calendar events --from --to ` +- Calendar create event: `gog calendar create --summary "Title" --from --to ` +- Calendar create with color: `gog calendar create --summary "Title" --from --to --event-color 7` +- Calendar update event: `gog calendar update --summary "New Title" --event-color 4` +- Calendar show colors: `gog calendar colors` +- Drive search: `gog drive search "query" --max 10` +- Contacts: `gog contacts list --max 20` +- Sheets get: `gog sheets get "Tab!A1:D10" --json` +- Sheets update: `gog sheets update "Tab!A1:B2" --values-json '[["A","B"],["1","2"]]' --input USER_ENTERED` +- Sheets append: `gog sheets append "Tab!A:C" --values-json '[["x","y","z"]]' --insert INSERT_ROWS` +- Sheets clear: `gog sheets clear "Tab!A2:Z"` +- Sheets metadata: `gog sheets metadata --json` +- Docs export: `gog docs export --format txt --out /tmp/doc.txt` +- Docs cat: `gog docs cat ` + +Calendar Colors + +- Use `gog calendar colors` to see all available event colors (IDs 1-11) +- Add colors to events with `--event-color ` flag +- Event color IDs (from `gog calendar colors` output): + - 1: #a4bdfc + - 2: #7ae7bf + - 3: #dbadff + - 4: #ff887c + - 5: #fbd75b + - 6: #ffb878 + - 7: #46d6db + - 8: #e1e1e1 + - 9: #5484ed + - 10: #51b749 + - 11: #dc2127 + +Email Formatting + +- Prefer plain text. Use `--body-file` for multi-paragraph messages (or `--body-file -` for stdin). +- Same `--body-file` pattern works for drafts and replies. +- `--body` does not unescape `\n`. If you need inline newlines, use a heredoc or `$'Line 1\n\nLine 2'`. +- Use `--body-html` only when you need rich formatting. +- HTML tags: `

    ` for paragraphs, `
    ` for line breaks, `` for bold, `` for italic, `
    ` for links, `

      `/`
    • ` for lists. +- Example (plain text via stdin): + + ```bash + gog gmail send --to recipient@example.com \ + --subject "Meeting Follow-up" \ + --body-file - <<'EOF' + Hi Name, + + Thanks for meeting today. Next steps: + - Item one + - Item two + + Best regards, + Your Name + EOF + ``` + +- Example (HTML list): + ```bash + gog gmail send --to recipient@example.com \ + --subject "Meeting Follow-up" \ + --body-html "

      Hi Name,

      Thanks for meeting today. Here are the next steps:

      • Item one
      • Item two

      Best regards,
      Your Name

      " + ``` + +Notes + +- Set `GOG_ACCOUNT=you@gmail.com` to avoid repeating `--account`. +- For scripting, prefer `--json` plus `--no-input`. +- Sheets values can be passed via `--values-json` (recommended) or as inline rows. +- Docs supports export/cat/copy. In-place edits require a Docs API client (not in gog). +- Confirm before sending mail or creating events. +- `gog gmail search` returns one row per thread; use `gog gmail messages search` when you need every individual email returned separately. diff --git a/skills/goplaces/SKILL.md b/skills/goplaces/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..2d09d1df1083d2592686afdaa240553a3aae4e57 --- /dev/null +++ b/skills/goplaces/SKILL.md @@ -0,0 +1,52 @@ +--- +name: goplaces +description: Query Google Places API (New) via the goplaces CLI for text search, place details, resolve, and reviews. Use for human-friendly place lookup or JSON output for scripts. +homepage: https://github.com/steipete/goplaces +metadata: + { + "openclaw": + { + "emoji": "📍", + "requires": { "bins": ["goplaces"], "env": ["GOOGLE_PLACES_API_KEY"] }, + "primaryEnv": "GOOGLE_PLACES_API_KEY", + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "steipete/tap/goplaces", + "bins": ["goplaces"], + "label": "Install goplaces (brew)", + }, + ], + }, + } +--- + +# goplaces + +Modern Google Places API (New) CLI. Human output by default, `--json` for scripts. + +Install + +- Homebrew: `brew install steipete/tap/goplaces` + +Config + +- `GOOGLE_PLACES_API_KEY` required. +- Optional: `GOOGLE_PLACES_BASE_URL` for testing/proxying. + +Common commands + +- Search: `goplaces search "coffee" --open-now --min-rating 4 --limit 5` +- Bias: `goplaces search "pizza" --lat 40.8 --lng -73.9 --radius-m 3000` +- Pagination: `goplaces search "pizza" --page-token "NEXT_PAGE_TOKEN"` +- Resolve: `goplaces resolve "Soho, London" --limit 5` +- Details: `goplaces details --reviews` +- JSON: `goplaces search "sushi" --json` + +Notes + +- `--no-color` or `NO_COLOR` disables ANSI color. +- Price levels: 0..4 (free → very expensive). +- Type filter sends only the first `--type` value (API accepts one). diff --git a/skills/himalaya/SKILL.md b/skills/himalaya/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..55744e248d99290150d8f857d6dd8bb2a7835d32 --- /dev/null +++ b/skills/himalaya/SKILL.md @@ -0,0 +1,257 @@ +--- +name: himalaya +description: "CLI to manage emails via IMAP/SMTP. Use `himalaya` to list, read, write, reply, forward, search, and organize emails from the terminal. Supports multiple accounts and message composition with MML (MIME Meta Language)." +homepage: https://github.com/pimalaya/himalaya +metadata: + { + "openclaw": + { + "emoji": "📧", + "requires": { "bins": ["himalaya"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "himalaya", + "bins": ["himalaya"], + "label": "Install Himalaya (brew)", + }, + ], + }, + } +--- + +# Himalaya Email CLI + +Himalaya is a CLI email client that lets you manage emails from the terminal using IMAP, SMTP, Notmuch, or Sendmail backends. + +## References + +- `references/configuration.md` (config file setup + IMAP/SMTP authentication) +- `references/message-composition.md` (MML syntax for composing emails) + +## Prerequisites + +1. Himalaya CLI installed (`himalaya --version` to verify) +2. A configuration file at `~/.config/himalaya/config.toml` +3. IMAP/SMTP credentials configured (password stored securely) + +## Configuration Setup + +Run the interactive wizard to set up an account: + +```bash +himalaya account configure +``` + +Or create `~/.config/himalaya/config.toml` manually: + +```toml +[accounts.personal] +email = "you@example.com" +display-name = "Your Name" +default = true + +backend.type = "imap" +backend.host = "imap.example.com" +backend.port = 993 +backend.encryption.type = "tls" +backend.login = "you@example.com" +backend.auth.type = "password" +backend.auth.cmd = "pass show email/imap" # or use keyring + +message.send.backend.type = "smtp" +message.send.backend.host = "smtp.example.com" +message.send.backend.port = 587 +message.send.backend.encryption.type = "start-tls" +message.send.backend.login = "you@example.com" +message.send.backend.auth.type = "password" +message.send.backend.auth.cmd = "pass show email/smtp" +``` + +## Common Operations + +### List Folders + +```bash +himalaya folder list +``` + +### List Emails + +List emails in INBOX (default): + +```bash +himalaya envelope list +``` + +List emails in a specific folder: + +```bash +himalaya envelope list --folder "Sent" +``` + +List with pagination: + +```bash +himalaya envelope list --page 1 --page-size 20 +``` + +### Search Emails + +```bash +himalaya envelope list from john@example.com subject meeting +``` + +### Read an Email + +Read email by ID (shows plain text): + +```bash +himalaya message read 42 +``` + +Export raw MIME: + +```bash +himalaya message export 42 --full +``` + +### Reply to an Email + +Interactive reply (opens $EDITOR): + +```bash +himalaya message reply 42 +``` + +Reply-all: + +```bash +himalaya message reply 42 --all +``` + +### Forward an Email + +```bash +himalaya message forward 42 +``` + +### Write a New Email + +Interactive compose (opens $EDITOR): + +```bash +himalaya message write +``` + +Send directly using template: + +```bash +cat << 'EOF' | himalaya template send +From: you@example.com +To: recipient@example.com +Subject: Test Message + +Hello from Himalaya! +EOF +``` + +Or with headers flag: + +```bash +himalaya message write -H "To:recipient@example.com" -H "Subject:Test" "Message body here" +``` + +### Move/Copy Emails + +Move to folder: + +```bash +himalaya message move 42 "Archive" +``` + +Copy to folder: + +```bash +himalaya message copy 42 "Important" +``` + +### Delete an Email + +```bash +himalaya message delete 42 +``` + +### Manage Flags + +Add flag: + +```bash +himalaya flag add 42 --flag seen +``` + +Remove flag: + +```bash +himalaya flag remove 42 --flag seen +``` + +## Multiple Accounts + +List accounts: + +```bash +himalaya account list +``` + +Use a specific account: + +```bash +himalaya --account work envelope list +``` + +## Attachments + +Save attachments from a message: + +```bash +himalaya attachment download 42 +``` + +Save to specific directory: + +```bash +himalaya attachment download 42 --dir ~/Downloads +``` + +## Output Formats + +Most commands support `--output` for structured output: + +```bash +himalaya envelope list --output json +himalaya envelope list --output plain +``` + +## Debugging + +Enable debug logging: + +```bash +RUST_LOG=debug himalaya envelope list +``` + +Full trace with backtrace: + +```bash +RUST_LOG=trace RUST_BACKTRACE=1 himalaya envelope list +``` + +## Tips + +- Use `himalaya --help` or `himalaya --help` for detailed usage. +- Message IDs are relative to the current folder; re-list after folder changes. +- For composing rich emails with attachments, use MML syntax (see `references/message-composition.md`). +- Store passwords securely using `pass`, system keyring, or a command that outputs the password. diff --git a/skills/himalaya/references/configuration.md b/skills/himalaya/references/configuration.md new file mode 100644 index 0000000000000000000000000000000000000000..005a657d5294af48ca89edb95fed6d4fcd7dd2c6 --- /dev/null +++ b/skills/himalaya/references/configuration.md @@ -0,0 +1,184 @@ +# Himalaya Configuration Reference + +Configuration file location: `~/.config/himalaya/config.toml` + +## Minimal IMAP + SMTP Setup + +```toml +[accounts.default] +email = "user@example.com" +display-name = "Your Name" +default = true + +# IMAP backend for reading emails +backend.type = "imap" +backend.host = "imap.example.com" +backend.port = 993 +backend.encryption.type = "tls" +backend.login = "user@example.com" +backend.auth.type = "password" +backend.auth.raw = "your-password" + +# SMTP backend for sending emails +message.send.backend.type = "smtp" +message.send.backend.host = "smtp.example.com" +message.send.backend.port = 587 +message.send.backend.encryption.type = "start-tls" +message.send.backend.login = "user@example.com" +message.send.backend.auth.type = "password" +message.send.backend.auth.raw = "your-password" +``` + +## Password Options + +### Raw password (testing only, not recommended) + +```toml +backend.auth.raw = "your-password" +``` + +### Password from command (recommended) + +```toml +backend.auth.cmd = "pass show email/imap" +# backend.auth.cmd = "security find-generic-password -a user@example.com -s imap -w" +``` + +### System keyring (requires keyring feature) + +```toml +backend.auth.keyring = "imap-example" +``` + +Then run `himalaya account configure ` to store the password. + +## Gmail Configuration + +```toml +[accounts.gmail] +email = "you@gmail.com" +display-name = "Your Name" +default = true + +backend.type = "imap" +backend.host = "imap.gmail.com" +backend.port = 993 +backend.encryption.type = "tls" +backend.login = "you@gmail.com" +backend.auth.type = "password" +backend.auth.cmd = "pass show google/app-password" + +message.send.backend.type = "smtp" +message.send.backend.host = "smtp.gmail.com" +message.send.backend.port = 587 +message.send.backend.encryption.type = "start-tls" +message.send.backend.login = "you@gmail.com" +message.send.backend.auth.type = "password" +message.send.backend.auth.cmd = "pass show google/app-password" +``` + +**Note:** Gmail requires an App Password if 2FA is enabled. + +## iCloud Configuration + +```toml +[accounts.icloud] +email = "you@icloud.com" +display-name = "Your Name" + +backend.type = "imap" +backend.host = "imap.mail.me.com" +backend.port = 993 +backend.encryption.type = "tls" +backend.login = "you@icloud.com" +backend.auth.type = "password" +backend.auth.cmd = "pass show icloud/app-password" + +message.send.backend.type = "smtp" +message.send.backend.host = "smtp.mail.me.com" +message.send.backend.port = 587 +message.send.backend.encryption.type = "start-tls" +message.send.backend.login = "you@icloud.com" +message.send.backend.auth.type = "password" +message.send.backend.auth.cmd = "pass show icloud/app-password" +``` + +**Note:** Generate an app-specific password at appleid.apple.com + +## Folder Aliases + +Map custom folder names: + +```toml +[accounts.default.folder.alias] +inbox = "INBOX" +sent = "Sent" +drafts = "Drafts" +trash = "Trash" +``` + +## Multiple Accounts + +```toml +[accounts.personal] +email = "personal@example.com" +default = true +# ... backend config ... + +[accounts.work] +email = "work@company.com" +# ... backend config ... +``` + +Switch accounts with `--account`: + +```bash +himalaya --account work envelope list +``` + +## Notmuch Backend (local mail) + +```toml +[accounts.local] +email = "user@example.com" + +backend.type = "notmuch" +backend.db-path = "~/.mail/.notmuch" +``` + +## OAuth2 Authentication (for providers that support it) + +```toml +backend.auth.type = "oauth2" +backend.auth.client-id = "your-client-id" +backend.auth.client-secret.cmd = "pass show oauth/client-secret" +backend.auth.access-token.cmd = "pass show oauth/access-token" +backend.auth.refresh-token.cmd = "pass show oauth/refresh-token" +backend.auth.auth-url = "https://provider.com/oauth/authorize" +backend.auth.token-url = "https://provider.com/oauth/token" +``` + +## Additional Options + +### Signature + +```toml +[accounts.default] +signature = "Best regards,\nYour Name" +signature-delim = "-- \n" +``` + +### Downloads directory + +```toml +[accounts.default] +downloads-dir = "~/Downloads/himalaya" +``` + +### Editor for composing + +Set via environment variable: + +```bash +export EDITOR="vim" +``` diff --git a/skills/himalaya/references/message-composition.md b/skills/himalaya/references/message-composition.md new file mode 100644 index 0000000000000000000000000000000000000000..2dbd7a99d481092b0c1217e2975585677cadae6f --- /dev/null +++ b/skills/himalaya/references/message-composition.md @@ -0,0 +1,199 @@ +# Message Composition with MML (MIME Meta Language) + +Himalaya uses MML for composing emails. MML is a simple XML-based syntax that compiles to MIME messages. + +## Basic Message Structure + +An email message is a list of **headers** followed by a **body**, separated by a blank line: + +``` +From: sender@example.com +To: recipient@example.com +Subject: Hello World + +This is the message body. +``` + +## Headers + +Common headers: + +- `From`: Sender address +- `To`: Primary recipient(s) +- `Cc`: Carbon copy recipients +- `Bcc`: Blind carbon copy recipients +- `Subject`: Message subject +- `Reply-To`: Address for replies (if different from From) +- `In-Reply-To`: Message ID being replied to + +### Address Formats + +``` +To: user@example.com +To: John Doe +To: "John Doe" +To: user1@example.com, user2@example.com, "Jane" +``` + +## Plain Text Body + +Simple plain text email: + +``` +From: alice@localhost +To: bob@localhost +Subject: Plain Text Example + +Hello, this is a plain text email. +No special formatting needed. + +Best, +Alice +``` + +## MML for Rich Emails + +### Multipart Messages + +Alternative text/html parts: + +``` +From: alice@localhost +To: bob@localhost +Subject: Multipart Example + +<#multipart type=alternative> +This is the plain text version. +<#part type=text/html> +

      This is the HTML version

      +<#/multipart> +``` + +### Attachments + +Attach a file: + +``` +From: alice@localhost +To: bob@localhost +Subject: With Attachment + +Here is the document you requested. + +<#part filename=/path/to/document.pdf><#/part> +``` + +Attachment with custom name: + +``` +<#part filename=/path/to/file.pdf name=report.pdf><#/part> +``` + +Multiple attachments: + +``` +<#part filename=/path/to/doc1.pdf><#/part> +<#part filename=/path/to/doc2.pdf><#/part> +``` + +### Inline Images + +Embed an image inline: + +``` +From: alice@localhost +To: bob@localhost +Subject: Inline Image + +<#multipart type=related> +<#part type=text/html> + +

      Check out this image:

      + + +<#part disposition=inline id=image1 filename=/path/to/image.png><#/part> +<#/multipart> +``` + +### Mixed Content (Text + Attachments) + +``` +From: alice@localhost +To: bob@localhost +Subject: Mixed Content + +<#multipart type=mixed> +<#part type=text/plain> +Please find the attached files. + +Best, +Alice +<#part filename=/path/to/file1.pdf><#/part> +<#part filename=/path/to/file2.zip><#/part> +<#/multipart> +``` + +## MML Tag Reference + +### `<#multipart>` + +Groups multiple parts together. + +- `type=alternative`: Different representations of same content +- `type=mixed`: Independent parts (text + attachments) +- `type=related`: Parts that reference each other (HTML + images) + +### `<#part>` + +Defines a message part. + +- `type=`: Content type (e.g., `text/html`, `application/pdf`) +- `filename=`: File to attach +- `name=`: Display name for attachment +- `disposition=inline`: Display inline instead of as attachment +- `id=`: Content ID for referencing in HTML + +## Composing from CLI + +### Interactive compose + +Opens your `$EDITOR`: + +```bash +himalaya message write +``` + +### Reply (opens editor with quoted message) + +```bash +himalaya message reply 42 +himalaya message reply 42 --all # reply-all +``` + +### Forward + +```bash +himalaya message forward 42 +``` + +### Send from stdin + +```bash +cat message.txt | himalaya template send +``` + +### Prefill headers from CLI + +```bash +himalaya message write \ + -H "To:recipient@example.com" \ + -H "Subject:Quick Message" \ + "Message body here" +``` + +## Tips + +- The editor opens with a template; fill in headers and body. +- Save and exit the editor to send; exit without saving to cancel. +- MML parts are compiled to proper MIME when sending. +- Use `himalaya message export --full` to inspect the raw MIME structure of received emails. diff --git a/skills/imsg/SKILL.md b/skills/imsg/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..6df5f84712df356e8d1adba9db88aaca833b69eb --- /dev/null +++ b/skills/imsg/SKILL.md @@ -0,0 +1,46 @@ +--- +name: imsg +description: iMessage/SMS CLI for listing chats, history, watch, and sending. +homepage: https://imsg.to +metadata: + { + "openclaw": + { + "emoji": "📨", + "os": ["darwin"], + "requires": { "bins": ["imsg"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "steipete/tap/imsg", + "bins": ["imsg"], + "label": "Install imsg (brew)", + }, + ], + }, + } +--- + +# imsg + +Use `imsg` to read and send Messages.app iMessage/SMS on macOS. + +Requirements + +- Messages.app signed in +- Full Disk Access for your terminal +- Automation permission to control Messages.app (for sending) + +Common commands + +- List chats: `imsg chats --limit 10 --json` +- History: `imsg history --chat-id 1 --limit 20 --attachments --json` +- Watch: `imsg watch --chat-id 1 --attachments` +- Send: `imsg send --to "+14155551212" --text "hi" --file /path/pic.jpg` + +Notes + +- `--service imessage|sms|auto` controls delivery. +- Confirm recipient + message before sending. diff --git a/skills/local-places/SERVER_README.md b/skills/local-places/SERVER_README.md new file mode 100644 index 0000000000000000000000000000000000000000..1a69931f284b680e4f81b4377e9eda02ed8f4ee4 --- /dev/null +++ b/skills/local-places/SERVER_README.md @@ -0,0 +1,101 @@ +# Local Places + +This repo is a fusion of two pieces: + +- A FastAPI server that exposes endpoints for searching and resolving places via the Google Maps Places API. +- A companion agent skill that explains how to use the API and can call it to find places efficiently. + +Together, the skill and server let an agent turn natural-language place queries into structured results quickly. + +## Run locally + +```bash +# copy skill definition into the relevant folder (where the agent looks for it) +# then run the server + +uv venv +uv pip install -e ".[dev]" +uv run --env-file .env uvicorn local_places.main:app --host 0.0.0.0 --reload +``` + +Open the API docs at http://127.0.0.1:8000/docs. + +## Places API + +Set the Google Places API key before running: + +```bash +export GOOGLE_PLACES_API_KEY="your-key" +``` + +Endpoints: + +- `POST /places/search` (free-text query + filters) +- `GET /places/{place_id}` (place details) +- `POST /locations/resolve` (resolve a user-provided location string) + +Example search request: + +```json +{ + "query": "italian restaurant", + "filters": { + "types": ["restaurant"], + "open_now": true, + "min_rating": 4.0, + "price_levels": [1, 2] + }, + "limit": 10 +} +``` + +Notes: + +- `filters.types` supports a single type (mapped to Google `includedType`). + +Example search request (curl): + +```bash +curl -X POST http://127.0.0.1:8000/places/search \ + -H "Content-Type: application/json" \ + -d '{ + "query": "italian restaurant", + "location_bias": { + "lat": 40.8065, + "lng": -73.9719, + "radius_m": 3000 + }, + "filters": { + "types": ["restaurant"], + "open_now": true, + "min_rating": 4.0, + "price_levels": [1, 2, 3] + }, + "limit": 10 + }' +``` + +Example resolve request (curl): + +```bash +curl -X POST http://127.0.0.1:8000/locations/resolve \ + -H "Content-Type: application/json" \ + -d '{ + "location_text": "Riverside Park, New York", + "limit": 5 + }' +``` + +## Test + +```bash +uv run pytest +``` + +## OpenAPI + +Generate the OpenAPI schema: + +```bash +uv run python scripts/generate_openapi.py +``` diff --git a/skills/local-places/SKILL.md b/skills/local-places/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..486c890c969aea0d734d10bf931d50a4dfb1d4c0 --- /dev/null +++ b/skills/local-places/SKILL.md @@ -0,0 +1,102 @@ +--- +name: local-places +description: Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost. +homepage: https://github.com/Hyaxia/local_places +metadata: + { + "openclaw": + { + "emoji": "📍", + "requires": { "bins": ["uv"], "env": ["GOOGLE_PLACES_API_KEY"] }, + "primaryEnv": "GOOGLE_PLACES_API_KEY", + }, + } +--- + +# 📍 Local Places + +_Find places, Go fast_ + +Search for nearby places using a local Google Places API proxy. Two-step flow: resolve location first, then search. + +## Setup + +```bash +cd {baseDir} +echo "GOOGLE_PLACES_API_KEY=your-key" > .env +uv venv && uv pip install -e ".[dev]" +uv run --env-file .env uvicorn local_places.main:app --host 127.0.0.1 --port 8000 +``` + +Requires `GOOGLE_PLACES_API_KEY` in `.env` or environment. + +## Quick Start + +1. **Check server:** `curl http://127.0.0.1:8000/ping` + +2. **Resolve location:** + +```bash +curl -X POST http://127.0.0.1:8000/locations/resolve \ + -H "Content-Type: application/json" \ + -d '{"location_text": "Soho, London", "limit": 5}' +``` + +3. **Search places:** + +```bash +curl -X POST http://127.0.0.1:8000/places/search \ + -H "Content-Type: application/json" \ + -d '{ + "query": "coffee shop", + "location_bias": {"lat": 51.5137, "lng": -0.1366, "radius_m": 1000}, + "filters": {"open_now": true, "min_rating": 4.0}, + "limit": 10 + }' +``` + +4. **Get details:** + +```bash +curl http://127.0.0.1:8000/places/{place_id} +``` + +## Conversation Flow + +1. If user says "near me" or gives vague location → resolve it first +2. If multiple results → show numbered list, ask user to pick +3. Ask for preferences: type, open now, rating, price level +4. Search with `location_bias` from chosen location +5. Present results with name, rating, address, open status +6. Offer to fetch details or refine search + +## Filter Constraints + +- `filters.types`: exactly ONE type (e.g., "restaurant", "cafe", "gym") +- `filters.price_levels`: integers 0-4 (0=free, 4=very expensive) +- `filters.min_rating`: 0-5 in 0.5 increments +- `filters.open_now`: boolean +- `limit`: 1-20 for search, 1-10 for resolve +- `location_bias.radius_m`: must be > 0 + +## Response Format + +```json +{ + "results": [ + { + "place_id": "ChIJ...", + "name": "Coffee Shop", + "address": "123 Main St", + "location": { "lat": 51.5, "lng": -0.1 }, + "rating": 4.6, + "price_level": 2, + "types": ["cafe", "food"], + "open_now": true + } + ], + "next_page_token": "..." +} +``` + +Use `next_page_token` as `page_token` in next request for more results. diff --git a/skills/local-places/pyproject.toml b/skills/local-places/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..1b1d2e9530d430bdc2c04204e93cfa551d077237 --- /dev/null +++ b/skills/local-places/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "my-api" +version = "0.1.0" +description = "FastAPI server" +readme = "README.md" +requires-python = ">=3.11" +dependencies = ["fastapi>=0.110.0", "httpx>=0.27.0", "uvicorn[standard]>=0.29.0"] + +[project.optional-dependencies] +dev = ["pytest>=8.0.0"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/local_places"] + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] diff --git a/skills/local-places/src/local_places/__init__.py b/skills/local-places/src/local_places/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..07c5de9e2c430f9f659c069c2c1c72210ba8abee --- /dev/null +++ b/skills/local-places/src/local_places/__init__.py @@ -0,0 +1,2 @@ +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/skills/local-places/src/local_places/google_places.py b/skills/local-places/src/local_places/google_places.py new file mode 100644 index 0000000000000000000000000000000000000000..5a9bd60a3066e0b4d9c449502e6cc8da6e289197 --- /dev/null +++ b/skills/local-places/src/local_places/google_places.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import logging +import os +from typing import Any + +import httpx +from fastapi import HTTPException + +from local_places.schemas import ( + LatLng, + LocationResolveRequest, + LocationResolveResponse, + PlaceDetails, + PlaceSummary, + ResolvedLocation, + SearchRequest, + SearchResponse, +) + +GOOGLE_PLACES_BASE_URL = os.getenv( + "GOOGLE_PLACES_BASE_URL", "https://places.googleapis.com/v1" +) +logger = logging.getLogger("local_places.google_places") + +_PRICE_LEVEL_TO_ENUM = { + 0: "PRICE_LEVEL_FREE", + 1: "PRICE_LEVEL_INEXPENSIVE", + 2: "PRICE_LEVEL_MODERATE", + 3: "PRICE_LEVEL_EXPENSIVE", + 4: "PRICE_LEVEL_VERY_EXPENSIVE", +} +_ENUM_TO_PRICE_LEVEL = {value: key for key, value in _PRICE_LEVEL_TO_ENUM.items()} + +_SEARCH_FIELD_MASK = ( + "places.id," + "places.displayName," + "places.formattedAddress," + "places.location," + "places.rating," + "places.priceLevel," + "places.types," + "places.currentOpeningHours," + "nextPageToken" +) + +_DETAILS_FIELD_MASK = ( + "id," + "displayName," + "formattedAddress," + "location," + "rating," + "priceLevel," + "types," + "regularOpeningHours," + "currentOpeningHours," + "nationalPhoneNumber," + "websiteUri" +) + +_RESOLVE_FIELD_MASK = ( + "places.id," + "places.displayName," + "places.formattedAddress," + "places.location," + "places.types" +) + + +class _GoogleResponse: + def __init__(self, response: httpx.Response): + self.status_code = response.status_code + self._response = response + + def json(self) -> dict[str, Any]: + return self._response.json() + + @property + def text(self) -> str: + return self._response.text + + +def _api_headers(field_mask: str) -> dict[str, str]: + api_key = os.getenv("GOOGLE_PLACES_API_KEY") + if not api_key: + raise HTTPException( + status_code=500, + detail="GOOGLE_PLACES_API_KEY is not set.", + ) + return { + "Content-Type": "application/json", + "X-Goog-Api-Key": api_key, + "X-Goog-FieldMask": field_mask, + } + + +def _request( + method: str, url: str, payload: dict[str, Any] | None, field_mask: str +) -> _GoogleResponse: + try: + with httpx.Client(timeout=10.0) as client: + response = client.request( + method=method, + url=url, + headers=_api_headers(field_mask), + json=payload, + ) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail="Google Places API unavailable.") from exc + + return _GoogleResponse(response) + + +def _build_text_query(request: SearchRequest) -> str: + keyword = request.filters.keyword if request.filters else None + if keyword: + return f"{request.query} {keyword}".strip() + return request.query + + +def _build_search_body(request: SearchRequest) -> dict[str, Any]: + body: dict[str, Any] = { + "textQuery": _build_text_query(request), + "pageSize": request.limit, + } + + if request.page_token: + body["pageToken"] = request.page_token + + if request.location_bias: + body["locationBias"] = { + "circle": { + "center": { + "latitude": request.location_bias.lat, + "longitude": request.location_bias.lng, + }, + "radius": request.location_bias.radius_m, + } + } + + if request.filters: + filters = request.filters + if filters.types: + body["includedType"] = filters.types[0] + if filters.open_now is not None: + body["openNow"] = filters.open_now + if filters.min_rating is not None: + body["minRating"] = filters.min_rating + if filters.price_levels: + body["priceLevels"] = [ + _PRICE_LEVEL_TO_ENUM[level] for level in filters.price_levels + ] + + return body + + +def _parse_lat_lng(raw: dict[str, Any] | None) -> LatLng | None: + if not raw: + return None + latitude = raw.get("latitude") + longitude = raw.get("longitude") + if latitude is None or longitude is None: + return None + return LatLng(lat=latitude, lng=longitude) + + +def _parse_display_name(raw: dict[str, Any] | None) -> str | None: + if not raw: + return None + return raw.get("text") + + +def _parse_open_now(raw: dict[str, Any] | None) -> bool | None: + if not raw: + return None + return raw.get("openNow") + + +def _parse_hours(raw: dict[str, Any] | None) -> list[str] | None: + if not raw: + return None + return raw.get("weekdayDescriptions") + + +def _parse_price_level(raw: str | None) -> int | None: + if not raw: + return None + return _ENUM_TO_PRICE_LEVEL.get(raw) + + +def search_places(request: SearchRequest) -> SearchResponse: + url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText" + response = _request("POST", url, _build_search_body(request), _SEARCH_FIELD_MASK) + + if response.status_code >= 400: + logger.error( + "Google Places API error %s. response=%s", + response.status_code, + response.text, + ) + raise HTTPException( + status_code=502, + detail=f"Google Places API error ({response.status_code}).", + ) + + try: + payload = response.json() + except ValueError as exc: + logger.error( + "Google Places API returned invalid JSON. response=%s", + response.text, + ) + raise HTTPException(status_code=502, detail="Invalid Google response.") from exc + + places = payload.get("places", []) + results = [] + for place in places: + results.append( + PlaceSummary( + place_id=place.get("id", ""), + name=_parse_display_name(place.get("displayName")), + address=place.get("formattedAddress"), + location=_parse_lat_lng(place.get("location")), + rating=place.get("rating"), + price_level=_parse_price_level(place.get("priceLevel")), + types=place.get("types"), + open_now=_parse_open_now(place.get("currentOpeningHours")), + ) + ) + + return SearchResponse( + results=results, + next_page_token=payload.get("nextPageToken"), + ) + + +def get_place_details(place_id: str) -> PlaceDetails: + url = f"{GOOGLE_PLACES_BASE_URL}/places/{place_id}" + response = _request("GET", url, None, _DETAILS_FIELD_MASK) + + if response.status_code >= 400: + logger.error( + "Google Places API error %s. response=%s", + response.status_code, + response.text, + ) + raise HTTPException( + status_code=502, + detail=f"Google Places API error ({response.status_code}).", + ) + + try: + payload = response.json() + except ValueError as exc: + logger.error( + "Google Places API returned invalid JSON. response=%s", + response.text, + ) + raise HTTPException(status_code=502, detail="Invalid Google response.") from exc + + return PlaceDetails( + place_id=payload.get("id", place_id), + name=_parse_display_name(payload.get("displayName")), + address=payload.get("formattedAddress"), + location=_parse_lat_lng(payload.get("location")), + rating=payload.get("rating"), + price_level=_parse_price_level(payload.get("priceLevel")), + types=payload.get("types"), + phone=payload.get("nationalPhoneNumber"), + website=payload.get("websiteUri"), + hours=_parse_hours(payload.get("regularOpeningHours")), + open_now=_parse_open_now(payload.get("currentOpeningHours")), + ) + + +def resolve_locations(request: LocationResolveRequest) -> LocationResolveResponse: + url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText" + body = {"textQuery": request.location_text, "pageSize": request.limit} + response = _request("POST", url, body, _RESOLVE_FIELD_MASK) + + if response.status_code >= 400: + logger.error( + "Google Places API error %s. response=%s", + response.status_code, + response.text, + ) + raise HTTPException( + status_code=502, + detail=f"Google Places API error ({response.status_code}).", + ) + + try: + payload = response.json() + except ValueError as exc: + logger.error( + "Google Places API returned invalid JSON. response=%s", + response.text, + ) + raise HTTPException(status_code=502, detail="Invalid Google response.") from exc + + places = payload.get("places", []) + results = [] + for place in places: + results.append( + ResolvedLocation( + place_id=place.get("id", ""), + name=_parse_display_name(place.get("displayName")), + address=place.get("formattedAddress"), + location=_parse_lat_lng(place.get("location")), + types=place.get("types"), + ) + ) + + return LocationResolveResponse(results=results) diff --git a/skills/local-places/src/local_places/main.py b/skills/local-places/src/local_places/main.py new file mode 100644 index 0000000000000000000000000000000000000000..1197719debf46ca11c855c6a98ac38cd8a893f9d --- /dev/null +++ b/skills/local-places/src/local_places/main.py @@ -0,0 +1,65 @@ +import logging +import os + +from fastapi import FastAPI, Request +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from local_places.google_places import get_place_details, resolve_locations, search_places +from local_places.schemas import ( + LocationResolveRequest, + LocationResolveResponse, + PlaceDetails, + SearchRequest, + SearchResponse, +) + +app = FastAPI( + title="My API", + servers=[{"url": os.getenv("OPENAPI_SERVER_URL", "http://maxims-macbook-air:8000")}], +) +logger = logging.getLogger("local_places.validation") + + +@app.get("/ping") +def ping() -> dict[str, str]: + return {"message": "pong"} + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + logger.error( + "Validation error on %s %s. body=%s errors=%s", + request.method, + request.url.path, + exc.body, + exc.errors(), + ) + return JSONResponse( + status_code=422, + content=jsonable_encoder({"detail": exc.errors()}), + ) + + +@app.post("/places/search", response_model=SearchResponse) +def places_search(request: SearchRequest) -> SearchResponse: + return search_places(request) + + +@app.get("/places/{place_id}", response_model=PlaceDetails) +def places_details(place_id: str) -> PlaceDetails: + return get_place_details(place_id) + + +@app.post("/locations/resolve", response_model=LocationResolveResponse) +def locations_resolve(request: LocationResolveRequest) -> LocationResolveResponse: + return resolve_locations(request) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("local_places.main:app", host="0.0.0.0", port=8000) diff --git a/skills/local-places/src/local_places/schemas.py b/skills/local-places/src/local_places/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..e0590e659ebfba34ddc5901e11c5c7d97bf58f63 --- /dev/null +++ b/skills/local-places/src/local_places/schemas.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field, field_validator + + +class LatLng(BaseModel): + lat: float = Field(ge=-90, le=90) + lng: float = Field(ge=-180, le=180) + + +class LocationBias(BaseModel): + lat: float = Field(ge=-90, le=90) + lng: float = Field(ge=-180, le=180) + radius_m: float = Field(gt=0) + + +class Filters(BaseModel): + types: list[str] | None = None + open_now: bool | None = None + min_rating: float | None = Field(default=None, ge=0, le=5) + price_levels: list[int] | None = None + keyword: str | None = Field(default=None, min_length=1) + + @field_validator("types") + @classmethod + def validate_types(cls, value: list[str] | None) -> list[str] | None: + if value is None: + return value + if len(value) > 1: + raise ValueError( + "Only one type is supported. Use query/keyword for additional filtering." + ) + return value + + @field_validator("price_levels") + @classmethod + def validate_price_levels(cls, value: list[int] | None) -> list[int] | None: + if value is None: + return value + invalid = [level for level in value if level not in range(0, 5)] + if invalid: + raise ValueError("price_levels must be integers between 0 and 4.") + return value + + @field_validator("min_rating") + @classmethod + def validate_min_rating(cls, value: float | None) -> float | None: + if value is None: + return value + if (value * 2) % 1 != 0: + raise ValueError("min_rating must be in 0.5 increments.") + return value + + +class SearchRequest(BaseModel): + query: str = Field(min_length=1) + location_bias: LocationBias | None = None + filters: Filters | None = None + limit: int = Field(default=10, ge=1, le=20) + page_token: str | None = None + + +class PlaceSummary(BaseModel): + place_id: str + name: str | None = None + address: str | None = None + location: LatLng | None = None + rating: float | None = None + price_level: int | None = None + types: list[str] | None = None + open_now: bool | None = None + + +class SearchResponse(BaseModel): + results: list[PlaceSummary] + next_page_token: str | None = None + + +class LocationResolveRequest(BaseModel): + location_text: str = Field(min_length=1) + limit: int = Field(default=5, ge=1, le=10) + + +class ResolvedLocation(BaseModel): + place_id: str + name: str | None = None + address: str | None = None + location: LatLng | None = None + types: list[str] | None = None + + +class LocationResolveResponse(BaseModel): + results: list[ResolvedLocation] + + +class PlaceDetails(BaseModel): + place_id: str + name: str | None = None + address: str | None = None + location: LatLng | None = None + rating: float | None = None + price_level: int | None = None + types: list[str] | None = None + phone: str | None = None + website: str | None = None + hours: list[str] | None = None + open_now: bool | None = None diff --git a/skills/mcporter/SKILL.md b/skills/mcporter/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..b0c99837099365ab1f9b0107d2b4666319949585 --- /dev/null +++ b/skills/mcporter/SKILL.md @@ -0,0 +1,61 @@ +--- +name: mcporter +description: Use the mcporter CLI to list, configure, auth, and call MCP servers/tools directly (HTTP or stdio), including ad-hoc servers, config edits, and CLI/type generation. +homepage: http://mcporter.dev +metadata: + { + "openclaw": + { + "emoji": "📦", + "requires": { "bins": ["mcporter"] }, + "install": + [ + { + "id": "node", + "kind": "node", + "package": "mcporter", + "bins": ["mcporter"], + "label": "Install mcporter (node)", + }, + ], + }, + } +--- + +# mcporter + +Use `mcporter` to work with MCP servers directly. + +Quick start + +- `mcporter list` +- `mcporter list --schema` +- `mcporter call key=value` + +Call tools + +- Selector: `mcporter call linear.list_issues team=ENG limit:5` +- Function syntax: `mcporter call "linear.create_issue(title: \"Bug\")"` +- Full URL: `mcporter call https://api.example.com/mcp.fetch url:https://example.com` +- Stdio: `mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com` +- JSON payload: `mcporter call --args '{"limit":5}'` + +Auth + config + +- OAuth: `mcporter auth [--reset]` +- Config: `mcporter config list|get|add|remove|import|login|logout` + +Daemon + +- `mcporter daemon start|status|stop|restart` + +Codegen + +- CLI: `mcporter generate-cli --server ` or `--command ` +- Inspect: `mcporter inspect-cli [--json]` +- TS: `mcporter emit-ts --mode client|types` + +Notes + +- Config default: `./config/mcporter.json` (override with `--config`). +- Prefer `--output json` for machine-readable results. diff --git a/skills/model-usage/SKILL.md b/skills/model-usage/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..9315b3fd2e4d5f8ee25851987b9765fab5c0f119 --- /dev/null +++ b/skills/model-usage/SKILL.md @@ -0,0 +1,69 @@ +--- +name: model-usage +description: Use CodexBar CLI local cost usage to summarize per-model usage for Codex or Claude, including the current (most recent) model or a full model breakdown. Trigger when asked for model-level usage/cost data from codexbar, or when you need a scriptable per-model summary from codexbar cost JSON. +metadata: + { + "openclaw": + { + "emoji": "📊", + "os": ["darwin"], + "requires": { "bins": ["codexbar"] }, + "install": + [ + { + "id": "brew-cask", + "kind": "brew", + "cask": "steipete/tap/codexbar", + "bins": ["codexbar"], + "label": "Install CodexBar (brew cask)", + }, + ], + }, + } +--- + +# Model usage + +## Overview + +Get per-model usage cost from CodexBar's local cost logs. Supports "current model" (most recent daily entry) or "all models" summaries for Codex or Claude. + +TODO: add Linux CLI support guidance once CodexBar CLI install path is documented for Linux. + +## Quick start + +1. Fetch cost JSON via CodexBar CLI or pass a JSON file. +2. Use the bundled script to summarize by model. + +```bash +python {baseDir}/scripts/model_usage.py --provider codex --mode current +python {baseDir}/scripts/model_usage.py --provider codex --mode all +python {baseDir}/scripts/model_usage.py --provider claude --mode all --format json --pretty +``` + +## Current model logic + +- Uses the most recent daily row with `modelBreakdowns`. +- Picks the model with the highest cost in that row. +- Falls back to the last entry in `modelsUsed` when breakdowns are missing. +- Override with `--model ` when you need a specific model. + +## Inputs + +- Default: runs `codexbar cost --format json --provider `. +- File or stdin: + +```bash +codexbar cost --provider codex --format json > /tmp/cost.json +python {baseDir}/scripts/model_usage.py --input /tmp/cost.json --mode all +cat /tmp/cost.json | python {baseDir}/scripts/model_usage.py --input - --mode current +``` + +## Output + +- Text (default) or JSON (`--format json --pretty`). +- Values are cost-only per model; tokens are not split by model in CodexBar output. + +## References + +- Read `references/codexbar-cli.md` for CLI flags and cost JSON fields. diff --git a/skills/model-usage/references/codexbar-cli.md b/skills/model-usage/references/codexbar-cli.md new file mode 100644 index 0000000000000000000000000000000000000000..b7d9561b072aa9a73e154c9a4fe2a6a0f6894b68 --- /dev/null +++ b/skills/model-usage/references/codexbar-cli.md @@ -0,0 +1,33 @@ +# CodexBar CLI quick ref (usage + cost) + +## Install + +- App: Preferences -> Advanced -> Install CLI +- Repo: ./bin/install-codexbar-cli.sh + +## Commands + +- Usage snapshot (web/cli sources): + - codexbar usage --format json --pretty + - codexbar --provider all --format json +- Local cost usage (Codex + Claude only): + - codexbar cost --format json --pretty + - codexbar cost --provider codex|claude --format json + +## Cost JSON fields + +The payload is an array (one per provider). + +- provider, source, updatedAt +- sessionTokens, sessionCostUSD +- last30DaysTokens, last30DaysCostUSD +- daily[]: date, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, totalTokens, totalCost, modelsUsed, modelBreakdowns[] +- modelBreakdowns[]: modelName, cost +- totals: totalInputTokens, totalOutputTokens, cacheReadTokens, cacheCreationTokens, totalTokens, totalCost + +## Notes + +- Cost usage is local-only. It reads JSONL logs under: + - Codex: ~/.codex/sessions/\*_/_.jsonl + - Claude: ~/.config/claude/projects/**/\*.jsonl or ~/.claude/projects/**/\*.jsonl +- If web usage is required (non-local), use codexbar usage (not cost). diff --git a/skills/model-usage/scripts/model_usage.py b/skills/model-usage/scripts/model_usage.py new file mode 100644 index 0000000000000000000000000000000000000000..0b71f96ea0f68e7244f6e09a0b525b51aa015f20 --- /dev/null +++ b/skills/model-usage/scripts/model_usage.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +""" +Summarize CodexBar local cost usage by model. + +Defaults to current model (most recent daily entry), or list all models. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from typing import Any, Dict, Iterable, List, Optional, Tuple + + +def eprint(msg: str) -> None: + print(msg, file=sys.stderr) + + +def run_codexbar_cost(provider: str) -> List[Dict[str, Any]]: + cmd = ["codexbar", "cost", "--format", "json", "--provider", provider] + try: + output = subprocess.check_output(cmd, text=True) + except FileNotFoundError: + raise RuntimeError("codexbar not found on PATH. Install CodexBar CLI first.") + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"codexbar cost failed (exit {exc.returncode}).") + try: + payload = json.loads(output) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Failed to parse codexbar JSON output: {exc}") + if not isinstance(payload, list): + raise RuntimeError("Expected codexbar cost JSON array.") + return payload + + +def load_payload(input_path: Optional[str], provider: str) -> Dict[str, Any]: + if input_path: + if input_path == "-": + raw = sys.stdin.read() + else: + with open(input_path, "r", encoding="utf-8") as handle: + raw = handle.read() + data = json.loads(raw) + else: + data = run_codexbar_cost(provider) + + if isinstance(data, dict): + return data + + if isinstance(data, list): + for entry in data: + if isinstance(entry, dict) and entry.get("provider") == provider: + return entry + raise RuntimeError(f"Provider '{provider}' not found in codexbar payload.") + + raise RuntimeError("Unsupported JSON input format.") + + +@dataclass +class ModelCost: + model: str + cost: float + + +def parse_daily_entries(payload: Dict[str, Any]) -> List[Dict[str, Any]]: + daily = payload.get("daily") + if not daily: + return [] + if not isinstance(daily, list): + return [] + return [entry for entry in daily if isinstance(entry, dict)] + + +def parse_date(value: str) -> Optional[date]: + try: + return datetime.strptime(value, "%Y-%m-%d").date() + except Exception: + return None + + +def filter_by_days(entries: List[Dict[str, Any]], days: Optional[int]) -> List[Dict[str, Any]]: + if not days: + return entries + cutoff = date.today() - timedelta(days=days - 1) + filtered: List[Dict[str, Any]] = [] + for entry in entries: + day = entry.get("date") + if not isinstance(day, str): + continue + parsed = parse_date(day) + if parsed and parsed >= cutoff: + filtered.append(entry) + return filtered + + +def aggregate_costs(entries: Iterable[Dict[str, Any]]) -> Dict[str, float]: + totals: Dict[str, float] = {} + for entry in entries: + breakdowns = entry.get("modelBreakdowns") + if not breakdowns: + continue + if not isinstance(breakdowns, list): + continue + for item in breakdowns: + if not isinstance(item, dict): + continue + model = item.get("modelName") + cost = item.get("cost") + if not isinstance(model, str): + continue + if not isinstance(cost, (int, float)): + continue + totals[model] = totals.get(model, 0.0) + float(cost) + return totals + + +def pick_current_model(entries: List[Dict[str, Any]]) -> Tuple[Optional[str], Optional[str]]: + if not entries: + return None, None + sorted_entries = sorted( + entries, + key=lambda entry: entry.get("date") or "", + ) + for entry in reversed(sorted_entries): + breakdowns = entry.get("modelBreakdowns") + if isinstance(breakdowns, list) and breakdowns: + scored: List[ModelCost] = [] + for item in breakdowns: + if not isinstance(item, dict): + continue + model = item.get("modelName") + cost = item.get("cost") + if isinstance(model, str) and isinstance(cost, (int, float)): + scored.append(ModelCost(model=model, cost=float(cost))) + if scored: + scored.sort(key=lambda item: item.cost, reverse=True) + return scored[0].model, entry.get("date") if isinstance(entry.get("date"), str) else None + models_used = entry.get("modelsUsed") + if isinstance(models_used, list) and models_used: + last = models_used[-1] + if isinstance(last, str): + return last, entry.get("date") if isinstance(entry.get("date"), str) else None + return None, None + + +def usd(value: Optional[float]) -> str: + if value is None: + return "—" + return f"${value:,.2f}" + + +def latest_day_cost(entries: List[Dict[str, Any]], model: str) -> Tuple[Optional[str], Optional[float]]: + if not entries: + return None, None + sorted_entries = sorted( + entries, + key=lambda entry: entry.get("date") or "", + ) + for entry in reversed(sorted_entries): + breakdowns = entry.get("modelBreakdowns") + if not isinstance(breakdowns, list): + continue + for item in breakdowns: + if not isinstance(item, dict): + continue + if item.get("modelName") == model: + cost = item.get("cost") if isinstance(item.get("cost"), (int, float)) else None + day = entry.get("date") if isinstance(entry.get("date"), str) else None + return day, float(cost) if cost is not None else None + return None, None + + +def render_text_current( + provider: str, + model: str, + latest_date: Optional[str], + total_cost: Optional[float], + latest_cost: Optional[float], + latest_cost_date: Optional[str], + entry_count: int, +) -> str: + lines = [f"Provider: {provider}", f"Current model: {model}"] + if latest_date: + lines.append(f"Latest model date: {latest_date}") + lines.append(f"Total cost (rows): {usd(total_cost)}") + if latest_cost_date: + lines.append(f"Latest day cost: {usd(latest_cost)} ({latest_cost_date})") + lines.append(f"Daily rows: {entry_count}") + return "\n".join(lines) + + +def render_text_all(provider: str, totals: Dict[str, float]) -> str: + lines = [f"Provider: {provider}", "Models:"] + for model, cost in sorted(totals.items(), key=lambda item: item[1], reverse=True): + lines.append(f"- {model}: {usd(cost)}") + return "\n".join(lines) + + +def build_json_current( + provider: str, + model: str, + latest_date: Optional[str], + total_cost: Optional[float], + latest_cost: Optional[float], + latest_cost_date: Optional[str], + entry_count: int, +) -> Dict[str, Any]: + return { + "provider": provider, + "mode": "current", + "model": model, + "latestModelDate": latest_date, + "totalCostUSD": total_cost, + "latestDayCostUSD": latest_cost, + "latestDayCostDate": latest_cost_date, + "dailyRowCount": entry_count, + } + + +def build_json_all(provider: str, totals: Dict[str, float]) -> Dict[str, Any]: + return { + "provider": provider, + "mode": "all", + "models": [ + {"model": model, "totalCostUSD": cost} + for model, cost in sorted(totals.items(), key=lambda item: item[1], reverse=True) + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Summarize CodexBar model usage from local cost logs.") + parser.add_argument("--provider", choices=["codex", "claude"], default="codex") + parser.add_argument("--mode", choices=["current", "all"], default="current") + parser.add_argument("--model", help="Explicit model name to report instead of auto-current.") + parser.add_argument("--input", help="Path to codexbar cost JSON (or '-' for stdin).") + parser.add_argument("--days", type=int, help="Limit to last N days (based on daily rows).") + parser.add_argument("--format", choices=["text", "json"], default="text") + parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output.") + + args = parser.parse_args() + + try: + payload = load_payload(args.input, args.provider) + except Exception as exc: + eprint(str(exc)) + return 1 + + entries = parse_daily_entries(payload) + entries = filter_by_days(entries, args.days) + + if args.mode == "current": + model = args.model + latest_date = None + if not model: + model, latest_date = pick_current_model(entries) + if not model: + eprint("No model data found in codexbar cost payload.") + return 2 + totals = aggregate_costs(entries) + total_cost = totals.get(model) + latest_cost_date, latest_cost = latest_day_cost(entries, model) + + if args.format == "json": + payload_out = build_json_current( + provider=args.provider, + model=model, + latest_date=latest_date, + total_cost=total_cost, + latest_cost=latest_cost, + latest_cost_date=latest_cost_date, + entry_count=len(entries), + ) + indent = 2 if args.pretty else None + print(json.dumps(payload_out, indent=indent, sort_keys=args.pretty)) + else: + print( + render_text_current( + provider=args.provider, + model=model, + latest_date=latest_date, + total_cost=total_cost, + latest_cost=latest_cost, + latest_cost_date=latest_cost_date, + entry_count=len(entries), + ) + ) + return 0 + + totals = aggregate_costs(entries) + if not totals: + eprint("No model breakdowns found in codexbar cost payload.") + return 2 + + if args.format == "json": + payload_out = build_json_all(provider=args.provider, totals=totals) + indent = 2 if args.pretty else None + print(json.dumps(payload_out, indent=indent, sort_keys=args.pretty)) + else: + print(render_text_all(provider=args.provider, totals=totals)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/nano-banana-pro/SKILL.md b/skills/nano-banana-pro/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..20bf59a2e92560316213e8520e83d62bfd62b917 --- /dev/null +++ b/skills/nano-banana-pro/SKILL.md @@ -0,0 +1,58 @@ +--- +name: nano-banana-pro +description: Generate or edit images via Gemini 3 Pro Image (Nano Banana Pro). +homepage: https://ai.google.dev/ +metadata: + { + "openclaw": + { + "emoji": "🍌", + "requires": { "bins": ["uv"], "env": ["GEMINI_API_KEY"] }, + "primaryEnv": "GEMINI_API_KEY", + "install": + [ + { + "id": "uv-brew", + "kind": "brew", + "formula": "uv", + "bins": ["uv"], + "label": "Install uv (brew)", + }, + ], + }, + } +--- + +# Nano Banana Pro (Gemini 3 Pro Image) + +Use the bundled script to generate or edit images. + +Generate + +```bash +uv run {baseDir}/scripts/generate_image.py --prompt "your image description" --filename "output.png" --resolution 1K +``` + +Edit (single image) + +```bash +uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" -i "/path/in.png" --resolution 2K +``` + +Multi-image composition (up to 14 images) + +```bash +uv run {baseDir}/scripts/generate_image.py --prompt "combine these into one scene" --filename "output.png" -i img1.png -i img2.png -i img3.png +``` + +API key + +- `GEMINI_API_KEY` env var +- Or set `skills."nano-banana-pro".apiKey` / `skills."nano-banana-pro".env.GEMINI_API_KEY` in `~/.openclaw/openclaw.json` + +Notes + +- Resolutions: `1K` (default), `2K`, `4K`. +- Use timestamps in filenames: `yyyy-mm-dd-hh-mm-ss-name.png`. +- The script prints a `MEDIA:` line for OpenClaw to auto-attach on supported chat providers. +- Do not read the image back; report the saved path only. diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py new file mode 100644 index 0000000000000000000000000000000000000000..3365c20077ffc94538e21b3f5ca6f6be39f91c3b --- /dev/null +++ b/skills/nano-banana-pro/scripts/generate_image.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "google-genai>=1.0.0", +# "pillow>=10.0.0", +# ] +# /// +""" +Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API. + +Usage: + uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY] + +Multi-image editing (up to 14 images): + uv run generate_image.py --prompt "combine these images" --filename "output.png" -i img1.png -i img2.png -i img3.png +""" + +import argparse +import os +import sys +from pathlib import Path + + +def get_api_key(provided_key: str | None) -> str | None: + """Get API key from argument first, then environment.""" + if provided_key: + return provided_key + return os.environ.get("GEMINI_API_KEY") + + +def main(): + parser = argparse.ArgumentParser( + description="Generate images using Nano Banana Pro (Gemini 3 Pro Image)" + ) + parser.add_argument( + "--prompt", "-p", + required=True, + help="Image description/prompt" + ) + parser.add_argument( + "--filename", "-f", + required=True, + help="Output filename (e.g., sunset-mountains.png)" + ) + parser.add_argument( + "--input-image", "-i", + action="append", + dest="input_images", + metavar="IMAGE", + help="Input image path(s) for editing/composition. Can be specified multiple times (up to 14 images)." + ) + parser.add_argument( + "--resolution", "-r", + choices=["1K", "2K", "4K"], + default="1K", + help="Output resolution: 1K (default), 2K, or 4K" + ) + parser.add_argument( + "--api-key", "-k", + help="Gemini API key (overrides GEMINI_API_KEY env var)" + ) + + args = parser.parse_args() + + # Get API key + api_key = get_api_key(args.api_key) + if not api_key: + print("Error: No API key provided.", file=sys.stderr) + print("Please either:", file=sys.stderr) + print(" 1. Provide --api-key argument", file=sys.stderr) + print(" 2. Set GEMINI_API_KEY environment variable", file=sys.stderr) + sys.exit(1) + + # Import here after checking API key to avoid slow import on error + from google import genai + from google.genai import types + from PIL import Image as PILImage + + # Initialise client + client = genai.Client(api_key=api_key) + + # Set up output path + output_path = Path(args.filename) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Load input images if provided (up to 14 supported by Nano Banana Pro) + input_images = [] + output_resolution = args.resolution + if args.input_images: + if len(args.input_images) > 14: + print(f"Error: Too many input images ({len(args.input_images)}). Maximum is 14.", file=sys.stderr) + sys.exit(1) + + max_input_dim = 0 + for img_path in args.input_images: + try: + img = PILImage.open(img_path) + input_images.append(img) + print(f"Loaded input image: {img_path}") + + # Track largest dimension for auto-resolution + width, height = img.size + max_input_dim = max(max_input_dim, width, height) + except Exception as e: + print(f"Error loading input image '{img_path}': {e}", file=sys.stderr) + sys.exit(1) + + # Auto-detect resolution from largest input if not explicitly set + if args.resolution == "1K" and max_input_dim > 0: # Default value + if max_input_dim >= 3000: + output_resolution = "4K" + elif max_input_dim >= 1500: + output_resolution = "2K" + else: + output_resolution = "1K" + print(f"Auto-detected resolution: {output_resolution} (from max input dimension {max_input_dim})") + + # Build contents (images first if editing, prompt only if generating) + if input_images: + contents = [*input_images, args.prompt] + img_count = len(input_images) + print(f"Processing {img_count} image{'s' if img_count > 1 else ''} with resolution {output_resolution}...") + else: + contents = args.prompt + print(f"Generating image with resolution {output_resolution}...") + + try: + response = client.models.generate_content( + model="gemini-3-pro-image-preview", + contents=contents, + config=types.GenerateContentConfig( + response_modalities=["TEXT", "IMAGE"], + image_config=types.ImageConfig( + image_size=output_resolution + ) + ) + ) + + # Process response and convert to PNG + image_saved = False + for part in response.parts: + if part.text is not None: + print(f"Model response: {part.text}") + elif part.inline_data is not None: + # Convert inline data to PIL Image and save as PNG + from io import BytesIO + + # inline_data.data is already bytes, not base64 + image_data = part.inline_data.data + if isinstance(image_data, str): + # If it's a string, it might be base64 + import base64 + image_data = base64.b64decode(image_data) + + image = PILImage.open(BytesIO(image_data)) + + # Ensure RGB mode for PNG (convert RGBA to RGB with white background if needed) + if image.mode == 'RGBA': + rgb_image = PILImage.new('RGB', image.size, (255, 255, 255)) + rgb_image.paste(image, mask=image.split()[3]) + rgb_image.save(str(output_path), 'PNG') + elif image.mode == 'RGB': + image.save(str(output_path), 'PNG') + else: + image.convert('RGB').save(str(output_path), 'PNG') + image_saved = True + + if image_saved: + full_path = output_path.resolve() + print(f"\nImage saved: {full_path}") + # OpenClaw parses MEDIA tokens and will attach the file on supported providers. + print(f"MEDIA: {full_path}") + else: + print("Error: No image was generated in the response.", file=sys.stderr) + sys.exit(1) + + except Exception as e: + print(f"Error generating image: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/nano-pdf/SKILL.md b/skills/nano-pdf/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..f703d45f86eb18f9880d20aadce82b10251aeb13 --- /dev/null +++ b/skills/nano-pdf/SKILL.md @@ -0,0 +1,38 @@ +--- +name: nano-pdf +description: Edit PDFs with natural-language instructions using the nano-pdf CLI. +homepage: https://pypi.org/project/nano-pdf/ +metadata: + { + "openclaw": + { + "emoji": "📄", + "requires": { "bins": ["nano-pdf"] }, + "install": + [ + { + "id": "uv", + "kind": "uv", + "package": "nano-pdf", + "bins": ["nano-pdf"], + "label": "Install nano-pdf (uv)", + }, + ], + }, + } +--- + +# nano-pdf + +Use `nano-pdf` to apply edits to a specific page in a PDF using a natural-language instruction. + +## Quick start + +```bash +nano-pdf edit deck.pdf 1 "Change the title to 'Q3 Results' and fix the typo in the subtitle" +``` + +Notes: + +- Page numbers are 0-based or 1-based depending on the tool’s version/config; if the result looks off by one, retry with the other. +- Always sanity-check the output PDF before sending it out. diff --git a/skills/notion/SKILL.md b/skills/notion/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..52b2ef5245d262beb20074eacbc7540b8a5b5b78 --- /dev/null +++ b/skills/notion/SKILL.md @@ -0,0 +1,172 @@ +--- +name: notion +description: Notion API for creating and managing pages, databases, and blocks. +homepage: https://developers.notion.com +metadata: + { + "openclaw": + { "emoji": "📝", "requires": { "env": ["NOTION_API_KEY"] }, "primaryEnv": "NOTION_API_KEY" }, + } +--- + +# notion + +Use the Notion API to create/read/update pages, data sources (databases), and blocks. + +## Setup + +1. Create an integration at https://notion.so/my-integrations +2. Copy the API key (starts with `ntn_` or `secret_`) +3. Store it: + +```bash +mkdir -p ~/.config/notion +echo "ntn_your_key_here" > ~/.config/notion/api_key +``` + +4. Share target pages/databases with your integration (click "..." → "Connect to" → your integration name) + +## API Basics + +All requests need: + +```bash +NOTION_KEY=$(cat ~/.config/notion/api_key) +curl -X GET "https://api.notion.com/v1/..." \ + -H "Authorization: Bearer $NOTION_KEY" \ + -H "Notion-Version: 2025-09-03" \ + -H "Content-Type: application/json" +``` + +> **Note:** The `Notion-Version` header is required. This skill uses `2025-09-03` (latest). In this version, databases are called "data sources" in the API. + +## Common Operations + +**Search for pages and data sources:** + +```bash +curl -X POST "https://api.notion.com/v1/search" \ + -H "Authorization: Bearer $NOTION_KEY" \ + -H "Notion-Version: 2025-09-03" \ + -H "Content-Type: application/json" \ + -d '{"query": "page title"}' +``` + +**Get page:** + +```bash +curl "https://api.notion.com/v1/pages/{page_id}" \ + -H "Authorization: Bearer $NOTION_KEY" \ + -H "Notion-Version: 2025-09-03" +``` + +**Get page content (blocks):** + +```bash +curl "https://api.notion.com/v1/blocks/{page_id}/children" \ + -H "Authorization: Bearer $NOTION_KEY" \ + -H "Notion-Version: 2025-09-03" +``` + +**Create page in a data source:** + +```bash +curl -X POST "https://api.notion.com/v1/pages" \ + -H "Authorization: Bearer $NOTION_KEY" \ + -H "Notion-Version: 2025-09-03" \ + -H "Content-Type: application/json" \ + -d '{ + "parent": {"database_id": "xxx"}, + "properties": { + "Name": {"title": [{"text": {"content": "New Item"}}]}, + "Status": {"select": {"name": "Todo"}} + } + }' +``` + +**Query a data source (database):** + +```bash +curl -X POST "https://api.notion.com/v1/data_sources/{data_source_id}/query" \ + -H "Authorization: Bearer $NOTION_KEY" \ + -H "Notion-Version: 2025-09-03" \ + -H "Content-Type: application/json" \ + -d '{ + "filter": {"property": "Status", "select": {"equals": "Active"}}, + "sorts": [{"property": "Date", "direction": "descending"}] + }' +``` + +**Create a data source (database):** + +```bash +curl -X POST "https://api.notion.com/v1/data_sources" \ + -H "Authorization: Bearer $NOTION_KEY" \ + -H "Notion-Version: 2025-09-03" \ + -H "Content-Type: application/json" \ + -d '{ + "parent": {"page_id": "xxx"}, + "title": [{"text": {"content": "My Database"}}], + "properties": { + "Name": {"title": {}}, + "Status": {"select": {"options": [{"name": "Todo"}, {"name": "Done"}]}}, + "Date": {"date": {}} + } + }' +``` + +**Update page properties:** + +```bash +curl -X PATCH "https://api.notion.com/v1/pages/{page_id}" \ + -H "Authorization: Bearer $NOTION_KEY" \ + -H "Notion-Version: 2025-09-03" \ + -H "Content-Type: application/json" \ + -d '{"properties": {"Status": {"select": {"name": "Done"}}}}' +``` + +**Add blocks to page:** + +```bash +curl -X PATCH "https://api.notion.com/v1/blocks/{page_id}/children" \ + -H "Authorization: Bearer $NOTION_KEY" \ + -H "Notion-Version: 2025-09-03" \ + -H "Content-Type: application/json" \ + -d '{ + "children": [ + {"object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"text": {"content": "Hello"}}]}} + ] + }' +``` + +## Property Types + +Common property formats for database items: + +- **Title:** `{"title": [{"text": {"content": "..."}}]}` +- **Rich text:** `{"rich_text": [{"text": {"content": "..."}}]}` +- **Select:** `{"select": {"name": "Option"}}` +- **Multi-select:** `{"multi_select": [{"name": "A"}, {"name": "B"}]}` +- **Date:** `{"date": {"start": "2024-01-15", "end": "2024-01-16"}}` +- **Checkbox:** `{"checkbox": true}` +- **Number:** `{"number": 42}` +- **URL:** `{"url": "https://..."}` +- **Email:** `{"email": "a@b.com"}` +- **Relation:** `{"relation": [{"id": "page_id"}]}` + +## Key Differences in 2025-09-03 + +- **Databases → Data Sources:** Use `/data_sources/` endpoints for queries and retrieval +- **Two IDs:** Each database now has both a `database_id` and a `data_source_id` + - Use `database_id` when creating pages (`parent: {"database_id": "..."}`) + - Use `data_source_id` when querying (`POST /v1/data_sources/{id}/query`) +- **Search results:** Databases return as `"object": "data_source"` with their `data_source_id` +- **Parent in responses:** Pages show `parent.data_source_id` alongside `parent.database_id` +- **Finding the data_source_id:** Search for the database, or call `GET /v1/data_sources/{data_source_id}` + +## Notes + +- Page/database IDs are UUIDs (with or without dashes) +- The API cannot set database view filters — that's UI-only +- Rate limit: ~3 requests/second average +- Use `is_inline: true` when creating data sources to embed them in pages diff --git a/skills/obsidian/SKILL.md b/skills/obsidian/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..624178a60f9b4007b4394dde642678d60c984e42 --- /dev/null +++ b/skills/obsidian/SKILL.md @@ -0,0 +1,81 @@ +--- +name: obsidian +description: Work with Obsidian vaults (plain Markdown notes) and automate via obsidian-cli. +homepage: https://help.obsidian.md +metadata: + { + "openclaw": + { + "emoji": "💎", + "requires": { "bins": ["obsidian-cli"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "yakitrak/yakitrak/obsidian-cli", + "bins": ["obsidian-cli"], + "label": "Install obsidian-cli (brew)", + }, + ], + }, + } +--- + +# Obsidian + +Obsidian vault = a normal folder on disk. + +Vault structure (typical) + +- Notes: `*.md` (plain text Markdown; edit with any editor) +- Config: `.obsidian/` (workspace + plugin settings; usually don’t touch from scripts) +- Canvases: `*.canvas` (JSON) +- Attachments: whatever folder you chose in Obsidian settings (images/PDFs/etc.) + +## Find the active vault(s) + +Obsidian desktop tracks vaults here (source of truth): + +- `~/Library/Application Support/obsidian/obsidian.json` + +`obsidian-cli` resolves vaults from that file; vault name is typically the **folder name** (path suffix). + +Fast “what vault is active / where are the notes?” + +- If you’ve already set a default: `obsidian-cli print-default --path-only` +- Otherwise, read `~/Library/Application Support/obsidian/obsidian.json` and use the vault entry with `"open": true`. + +Notes + +- Multiple vaults common (iCloud vs `~/Documents`, work/personal, etc.). Don’t guess; read config. +- Avoid writing hardcoded vault paths into scripts; prefer reading the config or using `print-default`. + +## obsidian-cli quick start + +Pick a default vault (once): + +- `obsidian-cli set-default ""` +- `obsidian-cli print-default` / `obsidian-cli print-default --path-only` + +Search + +- `obsidian-cli search "query"` (note names) +- `obsidian-cli search-content "query"` (inside notes; shows snippets + lines) + +Create + +- `obsidian-cli create "Folder/New note" --content "..." --open` +- Requires Obsidian URI handler (`obsidian://…`) working (Obsidian installed). +- Avoid creating notes under “hidden” dot-folders (e.g. `.something/...`) via URI; Obsidian may refuse. + +Move/rename (safe refactor) + +- `obsidian-cli move "old/path/note" "new/path/note"` +- Updates `[[wikilinks]]` and common Markdown links across the vault (this is the main win vs `mv`). + +Delete + +- `obsidian-cli delete "path/note"` + +Prefer direct edits when appropriate: open the `.md` file and change it; Obsidian will pick it up. diff --git a/skills/openai-image-gen/SKILL.md b/skills/openai-image-gen/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..215b45ac4d7a8d500ecfa4deac94bb17f21b93ce --- /dev/null +++ b/skills/openai-image-gen/SKILL.md @@ -0,0 +1,89 @@ +--- +name: openai-image-gen +description: Batch-generate images via OpenAI Images API. Random prompt sampler + `index.html` gallery. +homepage: https://platform.openai.com/docs/api-reference/images +metadata: + { + "openclaw": + { + "emoji": "🖼️", + "requires": { "bins": ["python3"], "env": ["OPENAI_API_KEY"] }, + "primaryEnv": "OPENAI_API_KEY", + "install": + [ + { + "id": "python-brew", + "kind": "brew", + "formula": "python", + "bins": ["python3"], + "label": "Install Python (brew)", + }, + ], + }, + } +--- + +# OpenAI Image Gen + +Generate a handful of “random but structured” prompts and render them via the OpenAI Images API. + +## Run + +```bash +python3 {baseDir}/scripts/gen.py +open ~/Projects/tmp/openai-image-gen-*/index.html # if ~/Projects/tmp exists; else ./tmp/... +``` + +Useful flags: + +```bash +# GPT image models with various options +python3 {baseDir}/scripts/gen.py --count 16 --model gpt-image-1 +python3 {baseDir}/scripts/gen.py --prompt "ultra-detailed studio photo of a lobster astronaut" --count 4 +python3 {baseDir}/scripts/gen.py --size 1536x1024 --quality high --out-dir ./out/images +python3 {baseDir}/scripts/gen.py --model gpt-image-1.5 --background transparent --output-format webp + +# DALL-E 3 (note: count is automatically limited to 1) +python3 {baseDir}/scripts/gen.py --model dall-e-3 --quality hd --size 1792x1024 --style vivid +python3 {baseDir}/scripts/gen.py --model dall-e-3 --style natural --prompt "serene mountain landscape" + +# DALL-E 2 +python3 {baseDir}/scripts/gen.py --model dall-e-2 --size 512x512 --count 4 +``` + +## Model-Specific Parameters + +Different models support different parameter values. The script automatically selects appropriate defaults based on the model. + +### Size + +- **GPT image models** (`gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5`): `1024x1024`, `1536x1024` (landscape), `1024x1536` (portrait), or `auto` + - Default: `1024x1024` +- **dall-e-3**: `1024x1024`, `1792x1024`, or `1024x1792` + - Default: `1024x1024` +- **dall-e-2**: `256x256`, `512x512`, or `1024x1024` + - Default: `1024x1024` + +### Quality + +- **GPT image models**: `auto`, `high`, `medium`, or `low` + - Default: `high` +- **dall-e-3**: `hd` or `standard` + - Default: `standard` +- **dall-e-2**: `standard` only + - Default: `standard` + +### Other Notable Differences + +- **dall-e-3** only supports generating 1 image at a time (`n=1`). The script automatically limits count to 1 when using this model. +- **GPT image models** support additional parameters: + - `--background`: `transparent`, `opaque`, or `auto` (default) + - `--output-format`: `png` (default), `jpeg`, or `webp` + - Note: `stream` and `moderation` are available via API but not yet implemented in this script +- **dall-e-3** has a `--style` parameter: `vivid` (hyper-real, dramatic) or `natural` (more natural looking) + +## Output + +- `*.png`, `*.jpeg`, or `*.webp` images (output format depends on model + `--output-format`) +- `prompts.json` (prompt → file mapping) +- `index.html` (thumbnail gallery) diff --git a/skills/openai-image-gen/scripts/gen.py b/skills/openai-image-gen/scripts/gen.py new file mode 100644 index 0000000000000000000000000000000000000000..7bd59e361264b6a9743262284d31ca53197e7d50 --- /dev/null +++ b/skills/openai-image-gen/scripts/gen.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +import argparse +import base64 +import datetime as dt +import json +import os +import random +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + + +def slugify(text: str) -> str: + text = text.lower().strip() + text = re.sub(r"[^a-z0-9]+", "-", text) + text = re.sub(r"-{2,}", "-", text).strip("-") + return text or "image" + + +def default_out_dir() -> Path: + now = dt.datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + preferred = Path.home() / "Projects" / "tmp" + base = preferred if preferred.is_dir() else Path("./tmp") + base.mkdir(parents=True, exist_ok=True) + return base / f"openai-image-gen-{now}" + + +def pick_prompts(count: int) -> list[str]: + subjects = [ + "a lobster astronaut", + "a brutalist lighthouse", + "a cozy reading nook", + "a cyberpunk noodle shop", + "a Vienna street at dusk", + "a minimalist product photo", + "a surreal underwater library", + ] + styles = [ + "ultra-detailed studio photo", + "35mm film still", + "isometric illustration", + "editorial photography", + "soft watercolor", + "architectural render", + "high-contrast monochrome", + ] + lighting = [ + "golden hour", + "overcast soft light", + "neon lighting", + "dramatic rim light", + "candlelight", + "foggy atmosphere", + ] + prompts: list[str] = [] + for _ in range(count): + prompts.append( + f"{random.choice(styles)} of {random.choice(subjects)}, {random.choice(lighting)}" + ) + return prompts + + +def get_model_defaults(model: str) -> tuple[str, str]: + """Return (default_size, default_quality) for the given model.""" + if model == "dall-e-2": + # quality will be ignored + return ("1024x1024", "standard") + elif model == "dall-e-3": + return ("1024x1024", "standard") + else: + # GPT image or future models + return ("1024x1024", "high") + + +def request_images( + api_key: str, + prompt: str, + model: str, + size: str, + quality: str, + background: str = "", + output_format: str = "", + style: str = "", +) -> dict: + url = "https://api.openai.com/v1/images/generations" + args = { + "model": model, + "prompt": prompt, + "size": size, + "n": 1, + } + + # Quality parameter - dall-e-2 doesn't accept this parameter + if model != "dall-e-2": + args["quality"] = quality + + # Note: response_format no longer supported by OpenAI Images API + # dall-e models now return URLs by default + + if model.startswith("gpt-image"): + if background: + args["background"] = background + if output_format: + args["output_format"] = output_format + + if model == "dall-e-3" and style: + args["style"] = style + + body = json.dumps(args).encode("utf-8") + req = urllib.request.Request( + url, + method="POST", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + data=body, + ) + try: + with urllib.request.urlopen(req, timeout=300) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + payload = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"OpenAI Images API failed ({e.code}): {payload}") from e + + +def write_gallery(out_dir: Path, items: list[dict]) -> None: + thumbs = "\n".join( + [ + f""" +
      + +
      {it["prompt"]}
      +
      +""".strip() + for it in items + ] + ) + html = f""" + +openai-image-gen + +

      openai-image-gen

      +

      Output: {out_dir.as_posix()}

      +
      +{thumbs} +
      +""" + (out_dir / "index.html").write_text(html, encoding="utf-8") + + +def main() -> int: + ap = argparse.ArgumentParser(description="Generate images via OpenAI Images API.") + ap.add_argument("--prompt", help="Single prompt. If omitted, random prompts are generated.") + ap.add_argument("--count", type=int, default=8, help="How many images to generate.") + ap.add_argument("--model", default="gpt-image-1", help="Image model id.") + ap.add_argument("--size", default="", help="Image size (e.g. 1024x1024, 1536x1024). Defaults based on model if not specified.") + ap.add_argument("--quality", default="", help="Image quality (e.g. high, standard). Defaults based on model if not specified.") + ap.add_argument("--background", default="", help="Background transparency (GPT models only): transparent, opaque, or auto.") + ap.add_argument("--output-format", default="", help="Output format (GPT models only): png, jpeg, or webp.") + ap.add_argument("--style", default="", help="Image style (dall-e-3 only): vivid or natural.") + ap.add_argument("--out-dir", default="", help="Output directory (default: ./tmp/openai-image-gen-).") + args = ap.parse_args() + + api_key = (os.environ.get("OPENAI_API_KEY") or "").strip() + if not api_key: + print("Missing OPENAI_API_KEY", file=sys.stderr) + return 2 + + # Apply model-specific defaults if not specified + default_size, default_quality = get_model_defaults(args.model) + size = args.size or default_size + quality = args.quality or default_quality + + count = args.count + if args.model == "dall-e-3" and count > 1: + print(f"Warning: dall-e-3 only supports generating 1 image at a time. Reducing count from {count} to 1.", file=sys.stderr) + count = 1 + + out_dir = Path(args.out_dir).expanduser() if args.out_dir else default_out_dir() + out_dir.mkdir(parents=True, exist_ok=True) + + prompts = [args.prompt] * count if args.prompt else pick_prompts(count) + + # Determine file extension based on output format + if args.model.startswith("gpt-image") and args.output_format: + file_ext = args.output_format + else: + file_ext = "png" + + items: list[dict] = [] + for idx, prompt in enumerate(prompts, start=1): + print(f"[{idx}/{len(prompts)}] {prompt}") + res = request_images( + api_key, + prompt, + args.model, + size, + quality, + args.background, + args.output_format, + args.style, + ) + data = res.get("data", [{}])[0] + image_b64 = data.get("b64_json") + image_url = data.get("url") + if not image_b64 and not image_url: + raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}") + + filename = f"{idx:03d}-{slugify(prompt)[:40]}.{file_ext}" + filepath = out_dir / filename + if image_b64: + filepath.write_bytes(base64.b64decode(image_b64)) + else: + try: + urllib.request.urlretrieve(image_url, filepath) + except urllib.error.URLError as e: + raise RuntimeError(f"Failed to download image from {image_url}: {e}") from e + + items.append({"prompt": prompt, "file": filename}) + + (out_dir / "prompts.json").write_text(json.dumps(items, indent=2), encoding="utf-8") + write_gallery(out_dir, items) + print(f"\nWrote: {(out_dir / 'index.html').as_posix()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/openai-whisper-api/SKILL.md b/skills/openai-whisper-api/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..798b679e3ea2a0a7682e81005dd11930a3fecc67 --- /dev/null +++ b/skills/openai-whisper-api/SKILL.md @@ -0,0 +1,52 @@ +--- +name: openai-whisper-api +description: Transcribe audio via OpenAI Audio Transcriptions API (Whisper). +homepage: https://platform.openai.com/docs/guides/speech-to-text +metadata: + { + "openclaw": + { + "emoji": "☁️", + "requires": { "bins": ["curl"], "env": ["OPENAI_API_KEY"] }, + "primaryEnv": "OPENAI_API_KEY", + }, + } +--- + +# OpenAI Whisper API (curl) + +Transcribe an audio file via OpenAI’s `/v1/audio/transcriptions` endpoint. + +## Quick start + +```bash +{baseDir}/scripts/transcribe.sh /path/to/audio.m4a +``` + +Defaults: + +- Model: `whisper-1` +- Output: `.txt` + +## Useful flags + +```bash +{baseDir}/scripts/transcribe.sh /path/to/audio.ogg --model whisper-1 --out /tmp/transcript.txt +{baseDir}/scripts/transcribe.sh /path/to/audio.m4a --language en +{baseDir}/scripts/transcribe.sh /path/to/audio.m4a --prompt "Speaker names: Peter, Daniel" +{baseDir}/scripts/transcribe.sh /path/to/audio.m4a --json --out /tmp/transcript.json +``` + +## API key + +Set `OPENAI_API_KEY`, or configure it in `~/.openclaw/openclaw.json`: + +```json5 +{ + skills: { + "openai-whisper-api": { + apiKey: "OPENAI_KEY_HERE", + }, + }, +} +``` diff --git a/skills/openai-whisper-api/scripts/transcribe.sh b/skills/openai-whisper-api/scripts/transcribe.sh new file mode 100644 index 0000000000000000000000000000000000000000..551c7b473e2778afb82c892d85586426a19c3918 --- /dev/null +++ b/skills/openai-whisper-api/scripts/transcribe.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: + transcribe.sh [--model whisper-1] [--out /path/to/out.txt] [--language en] [--prompt "hint"] [--json] +EOF + exit 2 +} + +if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage +fi + +in="${1:-}" +shift || true + +model="whisper-1" +out="" +language="" +prompt="" +response_format="text" + +while [[ $# -gt 0 ]]; do + case "$1" in + --model) + model="${2:-}" + shift 2 + ;; + --out) + out="${2:-}" + shift 2 + ;; + --language) + language="${2:-}" + shift 2 + ;; + --prompt) + prompt="${2:-}" + shift 2 + ;; + --json) + response_format="json" + shift 1 + ;; + *) + echo "Unknown arg: $1" >&2 + usage + ;; + esac +done + +if [[ ! -f "$in" ]]; then + echo "File not found: $in" >&2 + exit 1 +fi + +if [[ "${OPENAI_API_KEY:-}" == "" ]]; then + echo "Missing OPENAI_API_KEY" >&2 + exit 1 +fi + +if [[ "$out" == "" ]]; then + base="${in%.*}" + if [[ "$response_format" == "json" ]]; then + out="${base}.json" + else + out="${base}.txt" + fi +fi + +mkdir -p "$(dirname "$out")" + +curl -sS https://api.openai.com/v1/audio/transcriptions \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Accept: application/json" \ + -F "file=@${in}" \ + -F "model=${model}" \ + -F "response_format=${response_format}" \ + ${language:+-F "language=${language}"} \ + ${prompt:+-F "prompt=${prompt}"} \ + >"$out" + +echo "$out" diff --git a/skills/openai-whisper/SKILL.md b/skills/openai-whisper/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..1c9411a3ff61de9a3201b96cb94f7310a74bc87b --- /dev/null +++ b/skills/openai-whisper/SKILL.md @@ -0,0 +1,38 @@ +--- +name: openai-whisper +description: Local speech-to-text with the Whisper CLI (no API key). +homepage: https://openai.com/research/whisper +metadata: + { + "openclaw": + { + "emoji": "🎙️", + "requires": { "bins": ["whisper"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "openai-whisper", + "bins": ["whisper"], + "label": "Install OpenAI Whisper (brew)", + }, + ], + }, + } +--- + +# Whisper (CLI) + +Use `whisper` to transcribe audio locally. + +Quick start + +- `whisper /path/audio.mp3 --model medium --output_format txt --output_dir .` +- `whisper /path/audio.m4a --task translate --output_format srt` + +Notes + +- Models download to `~/.cache/whisper` on first run. +- `--model` defaults to `turbo` on this install. +- Use smaller models for speed, larger for accuracy. diff --git a/skills/openhue/SKILL.md b/skills/openhue/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..83e62739aaa93479139c6711e11345d0ca558fa0 --- /dev/null +++ b/skills/openhue/SKILL.md @@ -0,0 +1,51 @@ +--- +name: openhue +description: Control Philips Hue lights/scenes via the OpenHue CLI. +homepage: https://www.openhue.io/cli +metadata: + { + "openclaw": + { + "emoji": "💡", + "requires": { "bins": ["openhue"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "openhue/cli/openhue-cli", + "bins": ["openhue"], + "label": "Install OpenHue CLI (brew)", + }, + ], + }, + } +--- + +# OpenHue CLI + +Use `openhue` to control Hue lights and scenes via a Hue Bridge. + +Setup + +- Discover bridges: `openhue discover` +- Guided setup: `openhue setup` + +Read + +- `openhue get light --json` +- `openhue get room --json` +- `openhue get scene --json` + +Write + +- Turn on: `openhue set light --on` +- Turn off: `openhue set light --off` +- Brightness: `openhue set light --on --brightness 50` +- Color: `openhue set light --on --rgb #3399FF` +- Scene: `openhue set scene ` + +Notes + +- You may need to press the Hue Bridge button during setup. +- Use `--room "Room Name"` when light names are ambiguous. diff --git a/skills/oracle/SKILL.md b/skills/oracle/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..727ce760a55569f513e300514e14be74d40d9701 --- /dev/null +++ b/skills/oracle/SKILL.md @@ -0,0 +1,125 @@ +--- +name: oracle +description: Best practices for using the oracle CLI (prompt + file bundling, engines, sessions, and file attachment patterns). +homepage: https://askoracle.dev +metadata: + { + "openclaw": + { + "emoji": "🧿", + "requires": { "bins": ["oracle"] }, + "install": + [ + { + "id": "node", + "kind": "node", + "package": "@steipete/oracle", + "bins": ["oracle"], + "label": "Install oracle (node)", + }, + ], + }, + } +--- + +# oracle — best use + +Oracle bundles your prompt + selected files into one “one-shot” request so another model can answer with real repo context (API or browser automation). Treat output as advisory: verify against code + tests. + +## Main use case (browser, GPT‑5.2 Pro) + +Default workflow here: `--engine browser` with GPT‑5.2 Pro in ChatGPT. This is the common “long think” path: ~10 minutes to ~1 hour is normal; expect a stored session you can reattach to. + +Recommended defaults: + +- Engine: browser (`--engine browser`) +- Model: GPT‑5.2 Pro (`--model gpt-5.2-pro` or `--model "5.2 Pro"`) + +## Golden path + +1. Pick a tight file set (fewest files that still contain the truth). +2. Preview payload + token spend (`--dry-run` + `--files-report`). +3. Use browser mode for the usual GPT‑5.2 Pro workflow; use API only when you explicitly want it. +4. If the run detaches/timeouts: reattach to the stored session (don’t re-run). + +## Commands (preferred) + +- Help: + - `oracle --help` + - If the binary isn’t installed: `npx -y @steipete/oracle --help` (avoid `pnpx` here; sqlite bindings). + +- Preview (no tokens): + - `oracle --dry-run summary -p "" --file "src/**" --file "!**/*.test.*"` + - `oracle --dry-run full -p "" --file "src/**"` + +- Token sanity: + - `oracle --dry-run summary --files-report -p "" --file "src/**"` + +- Browser run (main path; long-running is normal): + - `oracle --engine browser --model gpt-5.2-pro -p "" --file "src/**"` + +- Manual paste fallback: + - `oracle --render --copy -p "" --file "src/**"` + - Note: `--copy` is a hidden alias for `--copy-markdown`. + +## Attaching files (`--file`) + +`--file` accepts files, directories, and globs. You can pass it multiple times; entries can be comma-separated. + +- Include: + - `--file "src/**"` + - `--file src/index.ts` + - `--file docs --file README.md` + +- Exclude: + - `--file "src/**" --file "!src/**/*.test.ts" --file "!**/*.snap"` + +- Defaults (implementation behavior): + - Default-ignored dirs: `node_modules`, `dist`, `coverage`, `.git`, `.turbo`, `.next`, `build`, `tmp` (skipped unless explicitly passed as literal dirs/files). + - Honors `.gitignore` when expanding globs. + - Does not follow symlinks. + - Dotfiles filtered unless opted in via pattern (e.g. `--file ".github/**"`). + - Files > 1 MB rejected. + +## Engines (API vs browser) + +- Auto-pick: `api` when `OPENAI_API_KEY` is set; otherwise `browser`. +- Browser supports GPT + Gemini only; use `--engine api` for Claude/Grok/Codex or multi-model runs. +- Browser attachments: + - `--browser-attachments auto|never|always` (auto pastes inline up to ~60k chars then uploads). +- Remote browser host: + - Host: `oracle serve --host 0.0.0.0 --port 9473 --token ` + - Client: `oracle --engine browser --remote-host --remote-token -p "" --file "src/**"` + +## Sessions + slugs + +- Stored under `~/.oracle/sessions` (override with `ORACLE_HOME_DIR`). +- Runs may detach or take a long time (browser + GPT‑5.2 Pro often does). If the CLI times out: don’t re-run; reattach. + - List: `oracle status --hours 72` + - Attach: `oracle session --render` +- Use `--slug "<3-5 words>"` to keep session IDs readable. +- Duplicate prompt guard exists; use `--force` only when you truly want a fresh run. + +## Prompt template (high signal) + +Oracle starts with **zero** project knowledge. Assume the model cannot infer your stack, build tooling, conventions, or “obvious” paths. Include: + +- Project briefing (stack + build/test commands + platform constraints). +- “Where things live” (key directories, entrypoints, config files, boundaries). +- Exact question + what you tried + the error text (verbatim). +- Constraints (“don’t change X”, “must keep public API”, etc). +- Desired output (“return patch plan + tests”, “give 3 options with tradeoffs”). + +## Safety + +- Don’t attach secrets by default (`.env`, key files, auth tokens). Redact aggressively; share only what’s required. + +## “Exhaustive prompt” restoration pattern + +For long investigations, write a standalone prompt + file set so you can rerun days later: + +- 6–30 sentence project briefing + the goal. +- Repro steps + exact errors + what you tried. +- Attach all context files needed (entrypoints, configs, key modules, docs). + +Oracle runs are one-shot; the model doesn’t remember prior runs. “Restoring context” means re-running with the same prompt + `--file …` set (or reattaching a still-running stored session). diff --git a/skills/ordercli/SKILL.md b/skills/ordercli/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..013fbd3ca9bf41ad2308b69a674200b908ac2100 --- /dev/null +++ b/skills/ordercli/SKILL.md @@ -0,0 +1,78 @@ +--- +name: ordercli +description: Foodora-only CLI for checking past orders and active order status (Deliveroo WIP). +homepage: https://ordercli.sh +metadata: + { + "openclaw": + { + "emoji": "🛵", + "requires": { "bins": ["ordercli"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "steipete/tap/ordercli", + "bins": ["ordercli"], + "label": "Install ordercli (brew)", + }, + { + "id": "go", + "kind": "go", + "module": "github.com/steipete/ordercli/cmd/ordercli@latest", + "bins": ["ordercli"], + "label": "Install ordercli (go)", + }, + ], + }, + } +--- + +# ordercli + +Use `ordercli` to check past orders and track active order status (Foodora only right now). + +Quick start (Foodora) + +- `ordercli foodora countries` +- `ordercli foodora config set --country AT` +- `ordercli foodora login --email you@example.com --password-stdin` +- `ordercli foodora orders` +- `ordercli foodora history --limit 20` +- `ordercli foodora history show ` + +Orders + +- Active list (arrival/status): `ordercli foodora orders` +- Watch: `ordercli foodora orders --watch` +- Active order detail: `ordercli foodora order ` +- History detail JSON: `ordercli foodora history show --json` + +Reorder (adds to cart) + +- Preview: `ordercli foodora reorder ` +- Confirm: `ordercli foodora reorder --confirm` +- Address: `ordercli foodora reorder --confirm --address-id ` + +Cloudflare / bot protection + +- Browser login: `ordercli foodora login --email you@example.com --password-stdin --browser` +- Reuse profile: `--browser-profile "$HOME/Library/Application Support/ordercli/browser-profile"` +- Import Chrome cookies: `ordercli foodora cookies chrome --profile "Default"` + +Session import (no password) + +- `ordercli foodora session chrome --url https://www.foodora.at/ --profile "Default"` +- `ordercli foodora session refresh --client-id android` + +Deliveroo (WIP, not working yet) + +- Requires `DELIVEROO_BEARER_TOKEN` (optional `DELIVEROO_COOKIE`). +- `ordercli deliveroo config set --market uk` +- `ordercli deliveroo history` + +Notes + +- Use `--config /tmp/ordercli.json` for testing. +- Confirm before any reorder or cart-changing action. diff --git a/skills/peekaboo/SKILL.md b/skills/peekaboo/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..ca888d2914d2ef714d315d541ddac9fa2306850e --- /dev/null +++ b/skills/peekaboo/SKILL.md @@ -0,0 +1,190 @@ +--- +name: peekaboo +description: Capture and automate macOS UI with the Peekaboo CLI. +homepage: https://peekaboo.boo +metadata: + { + "openclaw": + { + "emoji": "👀", + "os": ["darwin"], + "requires": { "bins": ["peekaboo"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "steipete/tap/peekaboo", + "bins": ["peekaboo"], + "label": "Install Peekaboo (brew)", + }, + ], + }, + } +--- + +# Peekaboo + +Peekaboo is a full macOS UI automation CLI: capture/inspect screens, target UI +elements, drive input, and manage apps/windows/menus. Commands share a snapshot +cache and support `--json`/`-j` for scripting. Run `peekaboo` or +`peekaboo --help` for flags; `peekaboo --version` prints build metadata. +Tip: run via `polter peekaboo` to ensure fresh builds. + +## Features (all CLI capabilities, excluding agent/MCP) + +Core + +- `bridge`: inspect Peekaboo Bridge host connectivity +- `capture`: live capture or video ingest + frame extraction +- `clean`: prune snapshot cache and temp files +- `config`: init/show/edit/validate, providers, models, credentials +- `image`: capture screenshots (screen/window/menu bar regions) +- `learn`: print the full agent guide + tool catalog +- `list`: apps, windows, screens, menubar, permissions +- `permissions`: check Screen Recording/Accessibility status +- `run`: execute `.peekaboo.json` scripts +- `sleep`: pause execution for a duration +- `tools`: list available tools with filtering/display options + +Interaction + +- `click`: target by ID/query/coords with smart waits +- `drag`: drag & drop across elements/coords/Dock +- `hotkey`: modifier combos like `cmd,shift,t` +- `move`: cursor positioning with optional smoothing +- `paste`: set clipboard -> paste -> restore +- `press`: special-key sequences with repeats +- `scroll`: directional scrolling (targeted + smooth) +- `swipe`: gesture-style drags between targets +- `type`: text + control keys (`--clear`, delays) + +System + +- `app`: launch/quit/relaunch/hide/unhide/switch/list apps +- `clipboard`: read/write clipboard (text/images/files) +- `dialog`: click/input/file/dismiss/list system dialogs +- `dock`: launch/right-click/hide/show/list Dock items +- `menu`: click/list application menus + menu extras +- `menubar`: list/click status bar items +- `open`: enhanced `open` with app targeting + JSON payloads +- `space`: list/switch/move-window (Spaces) +- `visualizer`: exercise Peekaboo visual feedback animations +- `window`: close/minimize/maximize/move/resize/focus/list + +Vision + +- `see`: annotated UI maps, snapshot IDs, optional analysis + +Global runtime flags + +- `--json`/`-j`, `--verbose`/`-v`, `--log-level ` +- `--no-remote`, `--bridge-socket ` + +## Quickstart (happy path) + +```bash +peekaboo permissions +peekaboo list apps --json +peekaboo see --annotate --path /tmp/peekaboo-see.png +peekaboo click --on B1 +peekaboo type "Hello" --return +``` + +## Common targeting parameters (most interaction commands) + +- App/window: `--app`, `--pid`, `--window-title`, `--window-id`, `--window-index` +- Snapshot targeting: `--snapshot` (ID from `see`; defaults to latest) +- Element/coords: `--on`/`--id` (element ID), `--coords x,y` +- Focus control: `--no-auto-focus`, `--space-switch`, `--bring-to-current-space`, + `--focus-timeout-seconds`, `--focus-retry-count` + +## Common capture parameters + +- Output: `--path`, `--format png|jpg`, `--retina` +- Targeting: `--mode screen|window|frontmost`, `--screen-index`, + `--window-title`, `--window-id` +- Analysis: `--analyze "prompt"`, `--annotate` +- Capture engine: `--capture-engine auto|classic|cg|modern|sckit` + +## Common motion/typing parameters + +- Timing: `--duration` (drag/swipe), `--steps`, `--delay` (type/scroll/press) +- Human-ish movement: `--profile human|linear`, `--wpm` (typing) +- Scroll: `--direction up|down|left|right`, `--amount `, `--smooth` + +## Examples + +### See -> click -> type (most reliable flow) + +```bash +peekaboo see --app Safari --window-title "Login" --annotate --path /tmp/see.png +peekaboo click --on B3 --app Safari +peekaboo type "user@example.com" --app Safari +peekaboo press tab --count 1 --app Safari +peekaboo type "supersecret" --app Safari --return +``` + +### Target by window id + +```bash +peekaboo list windows --app "Visual Studio Code" --json +peekaboo click --window-id 12345 --coords 120,160 +peekaboo type "Hello from Peekaboo" --window-id 12345 +``` + +### Capture screenshots + analyze + +```bash +peekaboo image --mode screen --screen-index 0 --retina --path /tmp/screen.png +peekaboo image --app Safari --window-title "Dashboard" --analyze "Summarize KPIs" +peekaboo see --mode screen --screen-index 0 --analyze "Summarize the dashboard" +``` + +### Live capture (motion-aware) + +```bash +peekaboo capture live --mode region --region 100,100,800,600 --duration 30 \ + --active-fps 8 --idle-fps 2 --highlight-changes --path /tmp/capture +``` + +### App + window management + +```bash +peekaboo app launch "Safari" --open https://example.com +peekaboo window focus --app Safari --window-title "Example" +peekaboo window set-bounds --app Safari --x 50 --y 50 --width 1200 --height 800 +peekaboo app quit --app Safari +``` + +### Menus, menubar, dock + +```bash +peekaboo menu click --app Safari --item "New Window" +peekaboo menu click --app TextEdit --path "Format > Font > Show Fonts" +peekaboo menu click-extra --title "WiFi" +peekaboo dock launch Safari +peekaboo menubar list --json +``` + +### Mouse + gesture input + +```bash +peekaboo move 500,300 --smooth +peekaboo drag --from B1 --to T2 +peekaboo swipe --from-coords 100,500 --to-coords 100,200 --duration 800 +peekaboo scroll --direction down --amount 6 --smooth +``` + +### Keyboard input + +```bash +peekaboo hotkey --keys "cmd,shift,t" +peekaboo press escape +peekaboo type "Line 1\nLine 2" --delay 10 +``` + +Notes + +- Requires Screen Recording + Accessibility permissions. +- Use `peekaboo see --annotate` to identify targets before clicking. diff --git a/skills/sag/SKILL.md b/skills/sag/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..a12e8a6d62874872adb6e237b60d4a7c2b1de3bb --- /dev/null +++ b/skills/sag/SKILL.md @@ -0,0 +1,87 @@ +--- +name: sag +description: ElevenLabs text-to-speech with mac-style say UX. +homepage: https://sag.sh +metadata: + { + "openclaw": + { + "emoji": "🗣️", + "requires": { "bins": ["sag"], "env": ["ELEVENLABS_API_KEY"] }, + "primaryEnv": "ELEVENLABS_API_KEY", + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "steipete/tap/sag", + "bins": ["sag"], + "label": "Install sag (brew)", + }, + ], + }, + } +--- + +# sag + +Use `sag` for ElevenLabs TTS with local playback. + +API key (required) + +- `ELEVENLABS_API_KEY` (preferred) +- `SAG_API_KEY` also supported by the CLI + +Quick start + +- `sag "Hello there"` +- `sag speak -v "Roger" "Hello"` +- `sag voices` +- `sag prompting` (model-specific tips) + +Model notes + +- Default: `eleven_v3` (expressive) +- Stable: `eleven_multilingual_v2` +- Fast: `eleven_flash_v2_5` + +Pronunciation + delivery rules + +- First fix: respell (e.g. "key-note"), add hyphens, adjust casing. +- Numbers/units/URLs: `--normalize auto` (or `off` if it harms names). +- Language bias: `--lang en|de|fr|...` to guide normalization. +- v3: SSML `` not supported; use `[pause]`, `[short pause]`, `[long pause]`. +- v2/v2.5: SSML `` supported; `` not exposed in `sag`. + +v3 audio tags (put at the entrance of a line) + +- `[whispers]`, `[shouts]`, `[sings]` +- `[laughs]`, `[starts laughing]`, `[sighs]`, `[exhales]` +- `[sarcastic]`, `[curious]`, `[excited]`, `[crying]`, `[mischievously]` +- Example: `sag "[whispers] keep this quiet. [short pause] ok?"` + +Voice defaults + +- `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` + +Confirm voice + speaker before long output. + +## Chat voice responses + +When Peter asks for a "voice" reply (e.g., "crazy scientist voice", "explain in voice"), generate audio and send it: + +```bash +# Generate audio file +sag -v Clawd -o /tmp/voice-reply.mp3 "Your message here" + +# Then include in reply: +# MEDIA:/tmp/voice-reply.mp3 +``` + +Voice character tips: + +- Crazy scientist: Use `[excited]` tags, dramatic pauses `[short pause]`, vary intensity +- Calm: Use `[whispers]` or slower pacing +- Dramatic: Use `[sings]` or `[shouts]` sparingly + +Default voice for Clawd: `lj2rcrvANS3gaWWnczSX` (or just `-v Clawd`) diff --git a/skills/session-logs/SKILL.md b/skills/session-logs/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..5fd4a5b8cac3a765339f39e81f906b5a16a82e5e --- /dev/null +++ b/skills/session-logs/SKILL.md @@ -0,0 +1,115 @@ +--- +name: session-logs +description: Search and analyze your own session logs (older/parent conversations) using jq. +metadata: { "openclaw": { "emoji": "📜", "requires": { "bins": ["jq", "rg"] } } } +--- + +# session-logs + +Search your complete conversation history stored in session JSONL files. Use this when a user references older/parent conversations or asks what was said before. + +## Trigger + +Use this skill when the user asks about prior chats, parent conversations, or historical context that isn't in memory files. + +## Location + +Session logs live at: `~/.openclaw/agents//sessions/` (use the `agent=` value from the system prompt Runtime line). + +- **`sessions.json`** - Index mapping session keys to session IDs +- **`.jsonl`** - Full conversation transcript per session + +## Structure + +Each `.jsonl` file contains messages with: + +- `type`: "session" (metadata) or "message" +- `timestamp`: ISO timestamp +- `message.role`: "user", "assistant", or "toolResult" +- `message.content[]`: Text, thinking, or tool calls (filter `type=="text"` for human-readable content) +- `message.usage.cost.total`: Cost per response + +## Common Queries + +### List all sessions by date and size + +```bash +for f in ~/.openclaw/agents//sessions/*.jsonl; do + date=$(head -1 "$f" | jq -r '.timestamp' | cut -dT -f1) + size=$(ls -lh "$f" | awk '{print $5}') + echo "$date $size $(basename $f)" +done | sort -r +``` + +### Find sessions from a specific day + +```bash +for f in ~/.openclaw/agents//sessions/*.jsonl; do + head -1 "$f" | jq -r '.timestamp' | grep -q "2026-01-06" && echo "$f" +done +``` + +### Extract user messages from a session + +```bash +jq -r 'select(.message.role == "user") | .message.content[]? | select(.type == "text") | .text' .jsonl +``` + +### Search for keyword in assistant responses + +```bash +jq -r 'select(.message.role == "assistant") | .message.content[]? | select(.type == "text") | .text' .jsonl | rg -i "keyword" +``` + +### Get total cost for a session + +```bash +jq -s '[.[] | .message.usage.cost.total // 0] | add' .jsonl +``` + +### Daily cost summary + +```bash +for f in ~/.openclaw/agents//sessions/*.jsonl; do + date=$(head -1 "$f" | jq -r '.timestamp' | cut -dT -f1) + cost=$(jq -s '[.[] | .message.usage.cost.total // 0] | add' "$f") + echo "$date $cost" +done | awk '{a[$1]+=$2} END {for(d in a) print d, "$"a[d]}' | sort -r +``` + +### Count messages and tokens in a session + +```bash +jq -s '{ + messages: length, + user: [.[] | select(.message.role == "user")] | length, + assistant: [.[] | select(.message.role == "assistant")] | length, + first: .[0].timestamp, + last: .[-1].timestamp +}' .jsonl +``` + +### Tool usage breakdown + +```bash +jq -r '.message.content[]? | select(.type == "toolCall") | .name' .jsonl | sort | uniq -c | sort -rn +``` + +### Search across ALL sessions for a phrase + +```bash +rg -l "phrase" ~/.openclaw/agents//sessions/*.jsonl +``` + +## Tips + +- Sessions are append-only JSONL (one JSON object per line) +- Large sessions can be several MB - use `head`/`tail` for sampling +- The `sessions.json` index maps chat providers (discord, whatsapp, etc.) to session IDs +- Deleted sessions have `.deleted.` suffix + +## Fast text-only hint (low noise) + +```bash +jq -r 'select(.type=="message") | .message.content[]? | select(.type=="text") | .text' ~/.openclaw/agents//sessions/.jsonl | rg 'keyword' +``` diff --git a/skills/sherpa-onnx-tts/SKILL.md b/skills/sherpa-onnx-tts/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..ee5daeae97b2639ff1da201df9b0ab5196f30165 --- /dev/null +++ b/skills/sherpa-onnx-tts/SKILL.md @@ -0,0 +1,103 @@ +--- +name: sherpa-onnx-tts +description: Local text-to-speech via sherpa-onnx (offline, no cloud) +metadata: + { + "openclaw": + { + "emoji": "🗣️", + "os": ["darwin", "linux", "win32"], + "requires": { "env": ["SHERPA_ONNX_RUNTIME_DIR", "SHERPA_ONNX_MODEL_DIR"] }, + "install": + [ + { + "id": "download-runtime-macos", + "kind": "download", + "os": ["darwin"], + "url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-osx-universal2-shared.tar.bz2", + "archive": "tar.bz2", + "extract": true, + "stripComponents": 1, + "targetDir": "~/.openclaw/tools/sherpa-onnx-tts/runtime", + "label": "Download sherpa-onnx runtime (macOS)", + }, + { + "id": "download-runtime-linux-x64", + "kind": "download", + "os": ["linux"], + "url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-linux-x64-shared.tar.bz2", + "archive": "tar.bz2", + "extract": true, + "stripComponents": 1, + "targetDir": "~/.openclaw/tools/sherpa-onnx-tts/runtime", + "label": "Download sherpa-onnx runtime (Linux x64)", + }, + { + "id": "download-runtime-win-x64", + "kind": "download", + "os": ["win32"], + "url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-win-x64-shared.tar.bz2", + "archive": "tar.bz2", + "extract": true, + "stripComponents": 1, + "targetDir": "~/.openclaw/tools/sherpa-onnx-tts/runtime", + "label": "Download sherpa-onnx runtime (Windows x64)", + }, + { + "id": "download-model-lessac", + "kind": "download", + "url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-lessac-high.tar.bz2", + "archive": "tar.bz2", + "extract": true, + "targetDir": "~/.openclaw/tools/sherpa-onnx-tts/models", + "label": "Download Piper en_US lessac (high)", + }, + ], + }, + } +--- + +# sherpa-onnx-tts + +Local TTS using the sherpa-onnx offline CLI. + +## Install + +1. Download the runtime for your OS (extracts into `~/.openclaw/tools/sherpa-onnx-tts/runtime`) +2. Download a voice model (extracts into `~/.openclaw/tools/sherpa-onnx-tts/models`) + +Update `~/.openclaw/openclaw.json`: + +```json5 +{ + skills: { + entries: { + "sherpa-onnx-tts": { + env: { + SHERPA_ONNX_RUNTIME_DIR: "~/.openclaw/tools/sherpa-onnx-tts/runtime", + SHERPA_ONNX_MODEL_DIR: "~/.openclaw/tools/sherpa-onnx-tts/models/vits-piper-en_US-lessac-high", + }, + }, + }, + }, +} +``` + +The wrapper lives in this skill folder. Run it directly, or add the wrapper to PATH: + +```bash +export PATH="{baseDir}/bin:$PATH" +``` + +## Usage + +```bash +{baseDir}/bin/sherpa-onnx-tts -o ./tts.wav "Hello from local TTS." +``` + +Notes: + +- Pick a different model from the sherpa-onnx `tts-models` release if you want another voice. +- If the model dir has multiple `.onnx` files, set `SHERPA_ONNX_MODEL_FILE` or pass `--model-file`. +- You can also pass `--tokens-file` or `--data-dir` to override the defaults. +- Windows: run `node {baseDir}\\bin\\sherpa-onnx-tts -o tts.wav "Hello from local TTS."` diff --git a/skills/sherpa-onnx-tts/bin/sherpa-onnx-tts b/skills/sherpa-onnx-tts/bin/sherpa-onnx-tts new file mode 100644 index 0000000000000000000000000000000000000000..82a7cceaf160e80d75e7f1b8bbdd924c88b05b98 --- /dev/null +++ b/skills/sherpa-onnx-tts/bin/sherpa-onnx-tts @@ -0,0 +1,178 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +function usage(message) { + if (message) { + console.error(message); + } + console.error( + "\nUsage: sherpa-onnx-tts [--runtime-dir ] [--model-dir ] [--model-file ] [--tokens-file ] [--data-dir ] [--output ] \"text\"", + ); + console.error("\nRequired env (or flags):\n SHERPA_ONNX_RUNTIME_DIR\n SHERPA_ONNX_MODEL_DIR"); + process.exit(1); +} + +function resolveRuntimeDir(explicit) { + const value = explicit || process.env.SHERPA_ONNX_RUNTIME_DIR || ""; + return value.trim(); +} + +function resolveModelDir(explicit) { + const value = explicit || process.env.SHERPA_ONNX_MODEL_DIR || ""; + return value.trim(); +} + +function resolveModelFile(modelDir, explicitFlag) { + const explicit = (explicitFlag || process.env.SHERPA_ONNX_MODEL_FILE || "").trim(); + if (explicit) return explicit; + try { + const candidates = fs + .readdirSync(modelDir) + .filter((entry) => entry.endsWith(".onnx")) + .map((entry) => path.join(modelDir, entry)); + if (candidates.length === 1) return candidates[0]; + } catch { + return ""; + } + return ""; +} + +function resolveTokensFile(modelDir, explicitFlag) { + const explicit = (explicitFlag || process.env.SHERPA_ONNX_TOKENS_FILE || "").trim(); + if (explicit) return explicit; + const candidate = path.join(modelDir, "tokens.txt"); + return fs.existsSync(candidate) ? candidate : ""; +} + +function resolveDataDir(modelDir, explicitFlag) { + const explicit = (explicitFlag || process.env.SHERPA_ONNX_DATA_DIR || "").trim(); + if (explicit) return explicit; + const candidate = path.join(modelDir, "espeak-ng-data"); + return fs.existsSync(candidate) ? candidate : ""; +} + +function resolveBinary(runtimeDir) { + const binName = process.platform === "win32" ? "sherpa-onnx-offline-tts.exe" : "sherpa-onnx-offline-tts"; + return path.join(runtimeDir, "bin", binName); +} + +function prependEnvPath(current, next) { + if (!next) return current; + if (!current) return next; + return `${next}${path.delimiter}${current}`; +} + +const args = process.argv.slice(2); +let runtimeDir = ""; +let modelDir = ""; +let modelFile = ""; +let tokensFile = ""; +let dataDir = ""; +let output = "tts.wav"; +const textParts = []; + +for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "--runtime-dir") { + runtimeDir = args[i + 1] || ""; + i += 1; + continue; + } + if (arg === "--model-dir") { + modelDir = args[i + 1] || ""; + i += 1; + continue; + } + if (arg === "--model-file") { + modelFile = args[i + 1] || ""; + i += 1; + continue; + } + if (arg === "--tokens-file") { + tokensFile = args[i + 1] || ""; + i += 1; + continue; + } + if (arg === "--data-dir") { + dataDir = args[i + 1] || ""; + i += 1; + continue; + } + if (arg === "-o" || arg === "--output") { + output = args[i + 1] || output; + i += 1; + continue; + } + if (arg === "--text") { + textParts.push(args[i + 1] || ""); + i += 1; + continue; + } + textParts.push(arg); +} + +runtimeDir = resolveRuntimeDir(runtimeDir); +modelDir = resolveModelDir(modelDir); + +if (!runtimeDir || !modelDir) { + usage("Missing runtime/model directory."); +} + +modelFile = resolveModelFile(modelDir, modelFile); +tokensFile = resolveTokensFile(modelDir, tokensFile); +dataDir = resolveDataDir(modelDir, dataDir); + +if (!modelFile || !tokensFile || !dataDir) { + usage( + "Model directory is missing required files. Set SHERPA_ONNX_MODEL_FILE, SHERPA_ONNX_TOKENS_FILE, SHERPA_ONNX_DATA_DIR or pass --model-file/--tokens-file/--data-dir.", + ); +} + +const text = textParts.join(" ").trim(); +if (!text) { + usage("Missing text."); +} + +const bin = resolveBinary(runtimeDir); +if (!fs.existsSync(bin)) { + usage(`TTS binary not found: ${bin}`); +} + +const env = { ...process.env }; +const libDir = path.join(runtimeDir, "lib"); +if (process.platform === "darwin") { + env.DYLD_LIBRARY_PATH = prependEnvPath(env.DYLD_LIBRARY_PATH || "", libDir); +} else if (process.platform === "win32") { + env.PATH = prependEnvPath(env.PATH || "", [path.join(runtimeDir, "bin"), libDir].join(path.delimiter)); +} else { + env.LD_LIBRARY_PATH = prependEnvPath(env.LD_LIBRARY_PATH || "", libDir); +} + +const outputPath = path.isAbsolute(output) ? output : path.join(process.cwd(), output); +fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + +const child = spawnSync( + bin, + [ + `--vits-model=${modelFile}`, + `--vits-tokens=${tokensFile}`, + `--vits-data-dir=${dataDir}`, + `--output-filename=${outputPath}`, + text, + ], + { + stdio: "inherit", + env, + }, +); + +if (typeof child.status === "number") { + process.exit(child.status); +} +if (child.error) { + console.error(child.error.message || String(child.error)); +} +process.exit(1); diff --git a/skills/skill-creator/SKILL.md b/skills/skill-creator/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..ca9da73ae693027dfd50fb336f5c603aae20e781 --- /dev/null +++ b/skills/skill-creator/SKILL.md @@ -0,0 +1,370 @@ +--- +name: skill-creator +description: Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets. +--- + +# Skill Creator + +This skill provides guidance for creating effective skills. + +## About Skills + +Skills are modular, self-contained packages that extend Codex's capabilities by providing +specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific +domains or tasks—they transform Codex from a general-purpose agent into a specialized agent +equipped with procedural knowledge that no model can fully possess. + +### What Skills Provide + +1. Specialized workflows - Multi-step procedures for specific domains +2. Tool integrations - Instructions for working with specific file formats or APIs +3. Domain expertise - Company-specific knowledge, schemas, business logic +4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks + +## Core Principles + +### Concise is Key + +The context window is a public good. Skills share the context window with everything else Codex needs: system prompt, conversation history, other Skills' metadata, and the actual user request. + +**Default assumption: Codex is already very smart.** Only add context Codex doesn't already have. Challenge each piece of information: "Does Codex really need this explanation?" and "Does this paragraph justify its token cost?" + +Prefer concise examples over verbose explanations. + +### Set Appropriate Degrees of Freedom + +Match the level of specificity to the task's fragility and variability: + +**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach. + +**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior. + +**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed. + +Think of Codex as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). + +### Anatomy of a Skill + +Every skill consists of a required SKILL.md file and optional bundled resources: + +``` +skill-name/ +├── SKILL.md (required) +│ ├── YAML frontmatter metadata (required) +│ │ ├── name: (required) +│ │ └── description: (required) +│ └── Markdown instructions (required) +└── Bundled Resources (optional) + ├── scripts/ - Executable code (Python/Bash/etc.) + ├── references/ - Documentation intended to be loaded into context as needed + └── assets/ - Files used in output (templates, icons, fonts, etc.) +``` + +#### SKILL.md (required) + +Every SKILL.md consists of: + +- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Codex reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. +- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all). + +#### Bundled Resources (optional) + +##### Scripts (`scripts/`) + +Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten. + +- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed +- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks +- **Benefits**: Token efficient, deterministic, may be executed without loading into context +- **Note**: Scripts may still need to be read by Codex for patching or environment-specific adjustments + +##### References (`references/`) + +Documentation and reference material intended to be loaded as needed into context to inform Codex's process and thinking. + +- **When to include**: For documentation that Codex should reference while working +- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications +- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides +- **Benefits**: Keeps SKILL.md lean, loaded only when Codex determines it's needed +- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md +- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. + +##### Assets (`assets/`) + +Files not intended to be loaded into context, but rather used within the output Codex produces. + +- **When to include**: When the skill needs files that will be used in the final output +- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography +- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified +- **Benefits**: Separates output resources from documentation, enables Codex to use files without loading them into context + +#### What to Not Include in a Skill + +A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including: + +- README.md +- INSTALLATION_GUIDE.md +- QUICK_REFERENCE.md +- CHANGELOG.md +- etc. + +The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxiliary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion. + +### Progressive Disclosure Design Principle + +Skills use a three-level loading system to manage context efficiently: + +1. **Metadata (name + description)** - Always in context (~100 words) +2. **SKILL.md body** - When skill triggers (<5k words) +3. **Bundled resources** - As needed by Codex (Unlimited because scripts can be executed without reading into context window) + +#### Progressive Disclosure Patterns + +Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them. + +**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files. + +**Pattern 1: High-level guide with references** + +```markdown +# PDF Processing + +## Quick start + +Extract text with pdfplumber: +[code example] + +## Advanced features + +- **Form filling**: See [FORMS.md](FORMS.md) for complete guide +- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods +- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns +``` + +Codex loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. + +**Pattern 2: Domain-specific organization** + +For Skills with multiple domains, organize content by domain to avoid loading irrelevant context: + +``` +bigquery-skill/ +├── SKILL.md (overview and navigation) +└── reference/ + ├── finance.md (revenue, billing metrics) + ├── sales.md (opportunities, pipeline) + ├── product.md (API usage, features) + └── marketing.md (campaigns, attribution) +``` + +When a user asks about sales metrics, Codex only reads sales.md. + +Similarly, for skills supporting multiple frameworks or variants, organize by variant: + +``` +cloud-deploy/ +├── SKILL.md (workflow + provider selection) +└── references/ + ├── aws.md (AWS deployment patterns) + ├── gcp.md (GCP deployment patterns) + └── azure.md (Azure deployment patterns) +``` + +When the user chooses AWS, Codex only reads aws.md. + +**Pattern 3: Conditional details** + +Show basic content, link to advanced content: + +```markdown +# DOCX Processing + +## Creating documents + +Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md). + +## Editing documents + +For simple edits, modify the XML directly. + +**For tracked changes**: See [REDLINING.md](REDLINING.md) +**For OOXML details**: See [OOXML.md](OOXML.md) +``` + +Codex reads REDLINING.md or OOXML.md only when the user needs those features. + +**Important guidelines:** + +- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md. +- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Codex can see the full scope when previewing. + +## Skill Creation Process + +Skill creation involves these steps: + +1. Understand the skill with concrete examples +2. Plan reusable skill contents (scripts, references, assets) +3. Initialize the skill (run init_skill.py) +4. Edit the skill (implement resources and write SKILL.md) +5. Package the skill (run package_skill.py) +6. Iterate based on real usage + +Follow these steps in order, skipping only if there is a clear reason why they are not applicable. + +### Skill Naming + +- Use lowercase letters, digits, and hyphens only; normalize user-provided titles to hyphen-case (e.g., "Plan Mode" -> `plan-mode`). +- When generating names, generate a name under 64 characters (letters, digits, hyphens). +- Prefer short, verb-led phrases that describe the action. +- Namespace by tool when it improves clarity or triggering (e.g., `gh-address-comments`, `linear-address-issue`). +- Name the skill folder exactly after the skill name. + +### Step 1: Understanding the Skill with Concrete Examples + +Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill. + +To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback. + +For example, when building an image-editor skill, relevant questions include: + +- "What functionality should the image-editor skill support? Editing, rotating, anything else?" +- "Can you give some examples of how this skill would be used?" +- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?" +- "What would a user say that should trigger this skill?" + +To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness. + +Conclude this step when there is a clear sense of the functionality the skill should support. + +### Step 2: Planning the Reusable Skill Contents + +To turn concrete examples into an effective skill, analyze each example by: + +1. Considering how to execute on the example from scratch +2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly + +Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows: + +1. Rotating a PDF requires re-writing the same code each time +2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill + +Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows: + +1. Writing a frontend webapp requires the same boilerplate HTML/React each time +2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill + +Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows: + +1. Querying BigQuery requires re-discovering the table schemas and relationships each time +2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill + +To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets. + +### Step 3: Initializing the Skill + +At this point, it is time to actually create the skill. + +Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step. + +When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable. + +Usage: + +```bash +scripts/init_skill.py --path [--resources scripts,references,assets] [--examples] +``` + +Examples: + +```bash +scripts/init_skill.py my-skill --path skills/public +scripts/init_skill.py my-skill --path skills/public --resources scripts,references +scripts/init_skill.py my-skill --path skills/public --resources scripts --examples +``` + +The script: + +- Creates the skill directory at the specified path +- Generates a SKILL.md template with proper frontmatter and TODO placeholders +- Optionally creates resource directories based on `--resources` +- Optionally adds example files when `--examples` is set + +After initialization, customize the SKILL.md and add resources as needed. If you used `--examples`, replace or delete placeholder files. + +### Step 4: Edit the Skill + +When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Codex to use. Include information that would be beneficial and non-obvious to Codex. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Codex instance execute these tasks more effectively. + +#### Learn Proven Design Patterns + +Consult these helpful guides based on your skill's needs: + +- **Multi-step processes**: See references/workflows.md for sequential workflows and conditional logic +- **Specific output formats or quality standards**: See references/output-patterns.md for template and example patterns + +These files contain established best practices for effective skill design. + +#### Start with Reusable Skill Contents + +To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`. + +Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion. + +If you used `--examples`, delete any placeholder files that are not needed for the skill. Only create resource directories that are actually required. + +#### Update SKILL.md + +**Writing Guidelines:** Always use imperative/infinitive form. + +##### Frontmatter + +Write the YAML frontmatter with `name` and `description`: + +- `name`: The skill name +- `description`: This is the primary triggering mechanism for your skill, and helps Codex understand when to use the skill. + - Include both what the Skill does and specific triggers/contexts for when to use it. + - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Codex. + - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Codex needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" + +Do not include any other fields in YAML frontmatter. + +##### Body + +Write instructions for using the skill and its bundled resources. + +### Step 5: Packaging a Skill + +Once development of the skill is complete, it must be packaged into a distributable .skill file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements: + +```bash +scripts/package_skill.py +``` + +Optional output directory specification: + +```bash +scripts/package_skill.py ./dist +``` + +The packaging script will: + +1. **Validate** the skill automatically, checking: + - YAML frontmatter format and required fields + - Skill naming conventions and directory structure + - Description completeness and quality + - File organization and resource references + +2. **Package** the skill if validation passes, creating a .skill file named after the skill (e.g., `my-skill.skill`) that includes all files and maintains the proper directory structure for distribution. The .skill file is a zip file with a .skill extension. + +If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again. + +### Step 6: Iterate + +After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed. + +**Iteration workflow:** + +1. Use the skill on real tasks +2. Notice struggles or inefficiencies +3. Identify how SKILL.md or bundled resources should be updated +4. Implement changes and test again diff --git a/skills/skill-creator/license.txt b/skills/skill-creator/license.txt new file mode 100644 index 0000000000000000000000000000000000000000..d645695673349e3947e8e5ae42332d0ac3164cd7 --- /dev/null +++ b/skills/skill-creator/license.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/skill-creator/scripts/init_skill.py b/skills/skill-creator/scripts/init_skill.py new file mode 100644 index 0000000000000000000000000000000000000000..8633fe9e3f2d783566c131da0774e2203f1abcbf --- /dev/null +++ b/skills/skill-creator/scripts/init_skill.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +""" +Skill Initializer - Creates a new skill from template + +Usage: + init_skill.py --path [--resources scripts,references,assets] [--examples] + +Examples: + init_skill.py my-new-skill --path skills/public + init_skill.py my-new-skill --path skills/public --resources scripts,references + init_skill.py my-api-helper --path skills/private --resources scripts --examples + init_skill.py custom-skill --path /custom/location +""" + +import argparse +import re +import sys +from pathlib import Path + +MAX_SKILL_NAME_LENGTH = 64 +ALLOWED_RESOURCES = {"scripts", "references", "assets"} + +SKILL_TEMPLATE = """--- +name: {skill_name} +description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.] +--- + +# {skill_title} + +## Overview + +[TODO: 1-2 sentences explaining what this skill enables] + +## Structuring This Skill + +[TODO: Choose the structure that best fits this skill's purpose. Common patterns: + +**1. Workflow-Based** (best for sequential processes) +- Works well when there are clear step-by-step procedures +- Example: DOCX skill with "Workflow Decision Tree" -> "Reading" -> "Creating" -> "Editing" +- Structure: ## Overview -> ## Workflow Decision Tree -> ## Step 1 -> ## Step 2... + +**2. Task-Based** (best for tool collections) +- Works well when the skill offers different operations/capabilities +- Example: PDF skill with "Quick Start" -> "Merge PDFs" -> "Split PDFs" -> "Extract Text" +- Structure: ## Overview -> ## Quick Start -> ## Task Category 1 -> ## Task Category 2... + +**3. Reference/Guidelines** (best for standards or specifications) +- Works well for brand guidelines, coding standards, or requirements +- Example: Brand styling with "Brand Guidelines" -> "Colors" -> "Typography" -> "Features" +- Structure: ## Overview -> ## Guidelines -> ## Specifications -> ## Usage... + +**4. Capabilities-Based** (best for integrated systems) +- Works well when the skill provides multiple interrelated features +- Example: Product Management with "Core Capabilities" -> numbered capability list +- Structure: ## Overview -> ## Core Capabilities -> ### 1. Feature -> ### 2. Feature... + +Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations). + +Delete this entire "Structuring This Skill" section when done - it's just guidance.] + +## [TODO: Replace with the first main section based on chosen structure] + +[TODO: Add content here. See examples in existing skills: +- Code samples for technical skills +- Decision trees for complex workflows +- Concrete examples with realistic user requests +- References to scripts/templates/references as needed] + +## Resources (optional) + +Create only the resource directories this skill actually needs. Delete this section if no resources are required. + +### scripts/ +Executable code (Python/Bash/etc.) that can be run directly to perform specific operations. + +**Examples from other skills:** +- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation +- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing + +**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. + +**Note:** Scripts may be executed without loading into context, but can still be read by Codex for patching or environment adjustments. + +### references/ +Documentation and reference material intended to be loaded into context to inform Codex's process and thinking. + +**Examples from other skills:** +- Product management: `communication.md`, `context_building.md` - detailed workflow guides +- BigQuery: API reference documentation and query examples +- Finance: Schema documentation, company policies + +**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Codex should reference while working. + +### assets/ +Files not intended to be loaded into context, but rather used within the output Codex produces. + +**Examples from other skills:** +- Brand styling: PowerPoint template files (.pptx), logo files +- Frontend builder: HTML/React boilerplate project directories +- Typography: Font files (.ttf, .woff2) + +**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output. + +--- + +**Not every skill requires all three types of resources.** +""" + +EXAMPLE_SCRIPT = '''#!/usr/bin/env python3 +""" +Example helper script for {skill_name} + +This is a placeholder script that can be executed directly. +Replace with actual implementation or delete if not needed. + +Example real scripts from other skills: +- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields +- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images +""" + +def main(): + print("This is an example script for {skill_name}") + # TODO: Add actual script logic here + # This could be data processing, file conversion, API calls, etc. + +if __name__ == "__main__": + main() +''' + +EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title} + +This is a placeholder for detailed reference documentation. +Replace with actual reference content or delete if not needed. + +Example real reference docs from other skills: +- product-management/references/communication.md - Comprehensive guide for status updates +- product-management/references/context_building.md - Deep-dive on gathering context +- bigquery/references/ - API references and query examples + +## When Reference Docs Are Useful + +Reference docs are ideal for: +- Comprehensive API documentation +- Detailed workflow guides +- Complex multi-step processes +- Information too lengthy for main SKILL.md +- Content that's only needed for specific use cases + +## Structure Suggestions + +### API Reference Example +- Overview +- Authentication +- Endpoints with examples +- Error codes +- Rate limits + +### Workflow Guide Example +- Prerequisites +- Step-by-step instructions +- Common patterns +- Troubleshooting +- Best practices +""" + +EXAMPLE_ASSET = """# Example Asset File + +This placeholder represents where asset files would be stored. +Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. + +Asset files are NOT intended to be loaded into context, but rather used within +the output Codex produces. + +Example asset files from other skills: +- Brand guidelines: logo.png, slides_template.pptx +- Frontend builder: hello-world/ directory with HTML/React boilerplate +- Typography: custom-font.ttf, font-family.woff2 +- Data: sample_data.csv, test_dataset.json + +## Common Asset Types + +- Templates: .pptx, .docx, boilerplate directories +- Images: .png, .jpg, .svg, .gif +- Fonts: .ttf, .otf, .woff, .woff2 +- Boilerplate code: Project directories, starter files +- Icons: .ico, .svg +- Data files: .csv, .json, .xml, .yaml + +Note: This is a text placeholder. Actual assets can be any file type. +""" + + +def normalize_skill_name(skill_name): + """Normalize a skill name to lowercase hyphen-case.""" + normalized = skill_name.strip().lower() + normalized = re.sub(r"[^a-z0-9]+", "-", normalized) + normalized = normalized.strip("-") + normalized = re.sub(r"-{2,}", "-", normalized) + return normalized + + +def title_case_skill_name(skill_name): + """Convert hyphenated skill name to Title Case for display.""" + return " ".join(word.capitalize() for word in skill_name.split("-")) + + +def parse_resources(raw_resources): + if not raw_resources: + return [] + resources = [item.strip() for item in raw_resources.split(",") if item.strip()] + invalid = sorted({item for item in resources if item not in ALLOWED_RESOURCES}) + if invalid: + allowed = ", ".join(sorted(ALLOWED_RESOURCES)) + print(f"[ERROR] Unknown resource type(s): {', '.join(invalid)}") + print(f" Allowed: {allowed}") + sys.exit(1) + deduped = [] + seen = set() + for resource in resources: + if resource not in seen: + deduped.append(resource) + seen.add(resource) + return deduped + + +def create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples): + for resource in resources: + resource_dir = skill_dir / resource + resource_dir.mkdir(exist_ok=True) + if resource == "scripts": + if include_examples: + example_script = resource_dir / "example.py" + example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) + example_script.chmod(0o755) + print("[OK] Created scripts/example.py") + else: + print("[OK] Created scripts/") + elif resource == "references": + if include_examples: + example_reference = resource_dir / "api_reference.md" + example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) + print("[OK] Created references/api_reference.md") + else: + print("[OK] Created references/") + elif resource == "assets": + if include_examples: + example_asset = resource_dir / "example_asset.txt" + example_asset.write_text(EXAMPLE_ASSET) + print("[OK] Created assets/example_asset.txt") + else: + print("[OK] Created assets/") + + +def init_skill(skill_name, path, resources, include_examples): + """ + Initialize a new skill directory with template SKILL.md. + + Args: + skill_name: Name of the skill + path: Path where the skill directory should be created + resources: Resource directories to create + include_examples: Whether to create example files in resource directories + + Returns: + Path to created skill directory, or None if error + """ + # Determine skill directory path + skill_dir = Path(path).resolve() / skill_name + + # Check if directory already exists + if skill_dir.exists(): + print(f"[ERROR] Skill directory already exists: {skill_dir}") + return None + + # Create skill directory + try: + skill_dir.mkdir(parents=True, exist_ok=False) + print(f"[OK] Created skill directory: {skill_dir}") + except Exception as e: + print(f"[ERROR] Error creating directory: {e}") + return None + + # Create SKILL.md from template + skill_title = title_case_skill_name(skill_name) + skill_content = SKILL_TEMPLATE.format(skill_name=skill_name, skill_title=skill_title) + + skill_md_path = skill_dir / "SKILL.md" + try: + skill_md_path.write_text(skill_content) + print("[OK] Created SKILL.md") + except Exception as e: + print(f"[ERROR] Error creating SKILL.md: {e}") + return None + + # Create resource directories if requested + if resources: + try: + create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples) + except Exception as e: + print(f"[ERROR] Error creating resource directories: {e}") + return None + + # Print next steps + print(f"\n[OK] Skill '{skill_name}' initialized successfully at {skill_dir}") + print("\nNext steps:") + print("1. Edit SKILL.md to complete the TODO items and update the description") + if resources: + if include_examples: + print("2. Customize or delete the example files in scripts/, references/, and assets/") + else: + print("2. Add resources to scripts/, references/, and assets/ as needed") + else: + print("2. Create resource directories only if needed (scripts/, references/, assets/)") + print("3. Run the validator when ready to check the skill structure") + + return skill_dir + + +def main(): + parser = argparse.ArgumentParser( + description="Create a new skill directory with a SKILL.md template.", + ) + parser.add_argument("skill_name", help="Skill name (normalized to hyphen-case)") + parser.add_argument("--path", required=True, help="Output directory for the skill") + parser.add_argument( + "--resources", + default="", + help="Comma-separated list: scripts,references,assets", + ) + parser.add_argument( + "--examples", + action="store_true", + help="Create example files inside the selected resource directories", + ) + args = parser.parse_args() + + raw_skill_name = args.skill_name + skill_name = normalize_skill_name(raw_skill_name) + if not skill_name: + print("[ERROR] Skill name must include at least one letter or digit.") + sys.exit(1) + if len(skill_name) > MAX_SKILL_NAME_LENGTH: + print( + f"[ERROR] Skill name '{skill_name}' is too long ({len(skill_name)} characters). " + f"Maximum is {MAX_SKILL_NAME_LENGTH} characters." + ) + sys.exit(1) + if skill_name != raw_skill_name: + print(f"Note: Normalized skill name from '{raw_skill_name}' to '{skill_name}'.") + + resources = parse_resources(args.resources) + if args.examples and not resources: + print("[ERROR] --examples requires --resources to be set.") + sys.exit(1) + + path = args.path + + print(f"Initializing skill: {skill_name}") + print(f" Location: {path}") + if resources: + print(f" Resources: {', '.join(resources)}") + if args.examples: + print(" Examples: enabled") + else: + print(" Resources: none (create as needed)") + print() + + result = init_skill(skill_name, path, resources, args.examples) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/skill-creator/scripts/package_skill.py b/skills/skill-creator/scripts/package_skill.py new file mode 100644 index 0000000000000000000000000000000000000000..9a039958bb6d45d42169d7af74bc722d954c27b4 --- /dev/null +++ b/skills/skill-creator/scripts/package_skill.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +Skill Packager - Creates a distributable .skill file of a skill folder + +Usage: + python utils/package_skill.py [output-directory] + +Example: + python utils/package_skill.py skills/public/my-skill + python utils/package_skill.py skills/public/my-skill ./dist +""" + +import sys +import zipfile +from pathlib import Path + +from quick_validate import validate_skill + + +def package_skill(skill_path, output_dir=None): + """ + Package a skill folder into a .skill file. + + Args: + skill_path: Path to the skill folder + output_dir: Optional output directory for the .skill file (defaults to current directory) + + Returns: + Path to the created .skill file, or None if error + """ + skill_path = Path(skill_path).resolve() + + # Validate skill folder exists + if not skill_path.exists(): + print(f"[ERROR] Skill folder not found: {skill_path}") + return None + + if not skill_path.is_dir(): + print(f"[ERROR] Path is not a directory: {skill_path}") + return None + + # Validate SKILL.md exists + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + print(f"[ERROR] SKILL.md not found in {skill_path}") + return None + + # Run validation before packaging + print("Validating skill...") + valid, message = validate_skill(skill_path) + if not valid: + print(f"[ERROR] Validation failed: {message}") + print(" Please fix the validation errors before packaging.") + return None + print(f"[OK] {message}\n") + + # Determine output location + skill_name = skill_path.name + if output_dir: + output_path = Path(output_dir).resolve() + output_path.mkdir(parents=True, exist_ok=True) + else: + output_path = Path.cwd() + + skill_filename = output_path / f"{skill_name}.skill" + + # Create the .skill file (zip format) + try: + with zipfile.ZipFile(skill_filename, "w", zipfile.ZIP_DEFLATED) as zipf: + # Walk through the skill directory + for file_path in skill_path.rglob("*"): + if file_path.is_file(): + # Calculate the relative path within the zip + arcname = file_path.relative_to(skill_path.parent) + zipf.write(file_path, arcname) + print(f" Added: {arcname}") + + print(f"\n[OK] Successfully packaged skill to: {skill_filename}") + return skill_filename + + except Exception as e: + print(f"[ERROR] Error creating .skill file: {e}") + return None + + +def main(): + if len(sys.argv) < 2: + print("Usage: python utils/package_skill.py [output-directory]") + print("\nExample:") + print(" python utils/package_skill.py skills/public/my-skill") + print(" python utils/package_skill.py skills/public/my-skill ./dist") + sys.exit(1) + + skill_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + print(f"Packaging skill: {skill_path}") + if output_dir: + print(f" Output directory: {output_dir}") + print() + + result = package_skill(skill_path, output_dir) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/skill-creator/scripts/quick_validate.py b/skills/skill-creator/scripts/quick_validate.py new file mode 100644 index 0000000000000000000000000000000000000000..0547b4041a5f58fa19892079a114a1df98286406 --- /dev/null +++ b/skills/skill-creator/scripts/quick_validate.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Quick validation script for skills - minimal version +""" + +import re +import sys +from pathlib import Path + +import yaml + +MAX_SKILL_NAME_LENGTH = 64 + + +def validate_skill(skill_path): + """Basic validation of a skill""" + skill_path = Path(skill_path) + + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + return False, "SKILL.md not found" + + content = skill_md.read_text() + if not content.startswith("---"): + return False, "No YAML frontmatter found" + + match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) + if not match: + return False, "Invalid frontmatter format" + + frontmatter_text = match.group(1) + + try: + frontmatter = yaml.safe_load(frontmatter_text) + if not isinstance(frontmatter, dict): + return False, "Frontmatter must be a YAML dictionary" + except yaml.YAMLError as e: + return False, f"Invalid YAML in frontmatter: {e}" + + allowed_properties = {"name", "description", "license", "allowed-tools", "metadata"} + + unexpected_keys = set(frontmatter.keys()) - allowed_properties + if unexpected_keys: + allowed = ", ".join(sorted(allowed_properties)) + unexpected = ", ".join(sorted(unexpected_keys)) + return ( + False, + f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}", + ) + + if "name" not in frontmatter: + return False, "Missing 'name' in frontmatter" + if "description" not in frontmatter: + return False, "Missing 'description' in frontmatter" + + name = frontmatter.get("name", "") + if not isinstance(name, str): + return False, f"Name must be a string, got {type(name).__name__}" + name = name.strip() + if name: + if not re.match(r"^[a-z0-9-]+$", name): + return ( + False, + f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)", + ) + if name.startswith("-") or name.endswith("-") or "--" in name: + return ( + False, + f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens", + ) + if len(name) > MAX_SKILL_NAME_LENGTH: + return ( + False, + f"Name is too long ({len(name)} characters). " + f"Maximum is {MAX_SKILL_NAME_LENGTH} characters.", + ) + + description = frontmatter.get("description", "") + if not isinstance(description, str): + return False, f"Description must be a string, got {type(description).__name__}" + description = description.strip() + if description: + if "<" in description or ">" in description: + return False, "Description cannot contain angle brackets (< or >)" + if len(description) > 1024: + return ( + False, + f"Description is too long ({len(description)} characters). Maximum is 1024 characters.", + ) + + return True, "Skill is valid!" + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..b7f86ac8285299a5204dca8eec46b9259fb8e9e1 --- /dev/null +++ b/skills/slack/SKILL.md @@ -0,0 +1,144 @@ +--- +name: slack +description: Use when you need to control Slack from OpenClaw via the slack tool, including reacting to messages or pinning/unpinning items in Slack channels or DMs. +metadata: { "openclaw": { "emoji": "💬", "requires": { "config": ["channels.slack"] } } } +--- + +# Slack Actions + +## Overview + +Use `slack` to react, manage pins, send/edit/delete messages, and fetch member info. The tool uses the bot token configured for OpenClaw. + +## Inputs to collect + +- `channelId` and `messageId` (Slack message timestamp, e.g. `1712023032.1234`). +- For reactions, an `emoji` (Unicode or `:name:`). +- For message sends, a `to` target (`channel:` or `user:`) and `content`. + +Message context lines include `slack message id` and `channel` fields you can reuse directly. + +## Actions + +### Action groups + +| Action group | Default | Notes | +| ------------ | ------- | ---------------------- | +| reactions | enabled | React + list reactions | +| messages | enabled | Read/send/edit/delete | +| pins | enabled | Pin/unpin/list | +| memberInfo | enabled | Member info | +| emojiList | enabled | Custom emoji list | + +### React to a message + +```json +{ + "action": "react", + "channelId": "C123", + "messageId": "1712023032.1234", + "emoji": "✅" +} +``` + +### List reactions + +```json +{ + "action": "reactions", + "channelId": "C123", + "messageId": "1712023032.1234" +} +``` + +### Send a message + +```json +{ + "action": "sendMessage", + "to": "channel:C123", + "content": "Hello from OpenClaw" +} +``` + +### Edit a message + +```json +{ + "action": "editMessage", + "channelId": "C123", + "messageId": "1712023032.1234", + "content": "Updated text" +} +``` + +### Delete a message + +```json +{ + "action": "deleteMessage", + "channelId": "C123", + "messageId": "1712023032.1234" +} +``` + +### Read recent messages + +```json +{ + "action": "readMessages", + "channelId": "C123", + "limit": 20 +} +``` + +### Pin a message + +```json +{ + "action": "pinMessage", + "channelId": "C123", + "messageId": "1712023032.1234" +} +``` + +### Unpin a message + +```json +{ + "action": "unpinMessage", + "channelId": "C123", + "messageId": "1712023032.1234" +} +``` + +### List pinned items + +```json +{ + "action": "listPins", + "channelId": "C123" +} +``` + +### Member info + +```json +{ + "action": "memberInfo", + "userId": "U123" +} +``` + +### Emoji list + +```json +{ + "action": "emojiList" +} +``` + +## Ideas to try + +- React with ✅ to mark completed tasks. +- Pin key decisions or weekly status updates. diff --git a/skills/songsee/SKILL.md b/skills/songsee/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..156114ffd36503c783bbaa483070f6dc9104332e --- /dev/null +++ b/skills/songsee/SKILL.md @@ -0,0 +1,49 @@ +--- +name: songsee +description: Generate spectrograms and feature-panel visualizations from audio with the songsee CLI. +homepage: https://github.com/steipete/songsee +metadata: + { + "openclaw": + { + "emoji": "🌊", + "requires": { "bins": ["songsee"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "steipete/tap/songsee", + "bins": ["songsee"], + "label": "Install songsee (brew)", + }, + ], + }, + } +--- + +# songsee + +Generate spectrograms + feature panels from audio. + +Quick start + +- Spectrogram: `songsee track.mp3` +- Multi-panel: `songsee track.mp3 --viz spectrogram,mel,chroma,hpss,selfsim,loudness,tempogram,mfcc,flux` +- Time slice: `songsee track.mp3 --start 12.5 --duration 8 -o slice.jpg` +- Stdin: `cat track.mp3 | songsee - --format png -o out.png` + +Common flags + +- `--viz` list (repeatable or comma-separated) +- `--style` palette (classic, magma, inferno, viridis, gray) +- `--width` / `--height` output size +- `--window` / `--hop` FFT settings +- `--min-freq` / `--max-freq` frequency range +- `--start` / `--duration` time slice +- `--format` jpg|png + +Notes + +- WAV/MP3 decode native; other formats use ffmpeg if available. +- Multiple `--viz` renders a grid. diff --git a/skills/sonoscli/SKILL.md b/skills/sonoscli/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..d416552152f34b2978a7c6d5a814ca771303f22b --- /dev/null +++ b/skills/sonoscli/SKILL.md @@ -0,0 +1,46 @@ +--- +name: sonoscli +description: Control Sonos speakers (discover/status/play/volume/group). +homepage: https://sonoscli.sh +metadata: + { + "openclaw": + { + "emoji": "🔊", + "requires": { "bins": ["sonos"] }, + "install": + [ + { + "id": "go", + "kind": "go", + "module": "github.com/steipete/sonoscli/cmd/sonos@latest", + "bins": ["sonos"], + "label": "Install sonoscli (go)", + }, + ], + }, + } +--- + +# Sonos CLI + +Use `sonos` to control Sonos speakers on the local network. + +Quick start + +- `sonos discover` +- `sonos status --name "Kitchen"` +- `sonos play|pause|stop --name "Kitchen"` +- `sonos volume set 15 --name "Kitchen"` + +Common tasks + +- Grouping: `sonos group status|join|unjoin|party|solo` +- Favorites: `sonos favorites list|open` +- Queue: `sonos queue list|play|clear` +- Spotify search (via SMAPI): `sonos smapi search --service "Spotify" --category tracks "query"` + +Notes + +- If SSDP fails, specify `--ip `. +- Spotify Web API search is optional and requires `SPOTIFY_CLIENT_ID/SECRET`. diff --git a/skills/spotify-player/SKILL.md b/skills/spotify-player/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..040da793fd869f9bd56803715c81648a9f5f0347 --- /dev/null +++ b/skills/spotify-player/SKILL.md @@ -0,0 +1,64 @@ +--- +name: spotify-player +description: Terminal Spotify playback/search via spogo (preferred) or spotify_player. +homepage: https://www.spotify.com +metadata: + { + "openclaw": + { + "emoji": "🎵", + "requires": { "anyBins": ["spogo", "spotify_player"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "spogo", + "tap": "steipete/tap", + "bins": ["spogo"], + "label": "Install spogo (brew)", + }, + { + "id": "brew", + "kind": "brew", + "formula": "spotify_player", + "bins": ["spotify_player"], + "label": "Install spotify_player (brew)", + }, + ], + }, + } +--- + +# spogo / spotify_player + +Use `spogo` **(preferred)** for Spotify playback/search. Fall back to `spotify_player` if needed. + +Requirements + +- Spotify Premium account. +- Either `spogo` or `spotify_player` installed. + +spogo setup + +- Import cookies: `spogo auth import --browser chrome` + +Common CLI commands + +- Search: `spogo search track "query"` +- Playback: `spogo play|pause|next|prev` +- Devices: `spogo device list`, `spogo device set ""` +- Status: `spogo status` + +spotify_player commands (fallback) + +- Search: `spotify_player search "query"` +- Playback: `spotify_player playback play|pause|next|previous` +- Connect device: `spotify_player connect` +- Like track: `spotify_player like` + +Notes + +- Config folder: `~/.config/spotify-player` (e.g., `app.toml`). +- For Spotify Connect integration, set a user `client_id` in config. +- TUI shortcuts are available via `?` in the app. diff --git a/skills/summarize/SKILL.md b/skills/summarize/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..0f42e0bfe45fbfbb52c458b58298cce7ffd7c416 --- /dev/null +++ b/skills/summarize/SKILL.md @@ -0,0 +1,87 @@ +--- +name: summarize +description: Summarize or extract text/transcripts from URLs, podcasts, and local files (great fallback for “transcribe this YouTube/video”). +homepage: https://summarize.sh +metadata: + { + "openclaw": + { + "emoji": "🧾", + "requires": { "bins": ["summarize"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "steipete/tap/summarize", + "bins": ["summarize"], + "label": "Install summarize (brew)", + }, + ], + }, + } +--- + +# Summarize + +Fast CLI to summarize URLs, local files, and YouTube links. + +## When to use (trigger phrases) + +Use this skill immediately when the user asks any of: + +- “use summarize.sh” +- “what’s this link/video about?” +- “summarize this URL/article” +- “transcribe this YouTube/video” (best-effort transcript extraction; no `yt-dlp` needed) + +## Quick start + +```bash +summarize "https://example.com" --model google/gemini-3-flash-preview +summarize "/path/to/file.pdf" --model google/gemini-3-flash-preview +summarize "https://youtu.be/dQw4w9WgXcQ" --youtube auto +``` + +## YouTube: summary vs transcript + +Best-effort transcript (URLs only): + +```bash +summarize "https://youtu.be/dQw4w9WgXcQ" --youtube auto --extract-only +``` + +If the user asked for a transcript but it’s huge, return a tight summary first, then ask which section/time range to expand. + +## Model + keys + +Set the API key for your chosen provider: + +- OpenAI: `OPENAI_API_KEY` +- Anthropic: `ANTHROPIC_API_KEY` +- xAI: `XAI_API_KEY` +- Google: `GEMINI_API_KEY` (aliases: `GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY`) + +Default model is `google/gemini-3-flash-preview` if none is set. + +## Useful flags + +- `--length short|medium|long|xl|xxl|` +- `--max-output-tokens ` +- `--extract-only` (URLs only) +- `--json` (machine readable) +- `--firecrawl auto|off|always` (fallback extraction) +- `--youtube auto` (Apify fallback if `APIFY_API_TOKEN` set) + +## Config + +Optional config file: `~/.summarize/config.json` + +```json +{ "model": "openai/gpt-5.2" } +``` + +Optional services: + +- `FIRECRAWL_API_KEY` for blocked sites +- `APIFY_API_TOKEN` for YouTube fallback diff --git a/skills/things-mac/SKILL.md b/skills/things-mac/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..47b8f885d0da170e980294e243b0cfb707c12ead --- /dev/null +++ b/skills/things-mac/SKILL.md @@ -0,0 +1,86 @@ +--- +name: things-mac +description: Manage Things 3 via the `things` CLI on macOS (add/update projects+todos via URL scheme; read/search/list from the local Things database). Use when a user asks OpenClaw to add a task to Things, list inbox/today/upcoming, search tasks, or inspect projects/areas/tags. +homepage: https://github.com/ossianhempel/things3-cli +metadata: + { + "openclaw": + { + "emoji": "✅", + "os": ["darwin"], + "requires": { "bins": ["things"] }, + "install": + [ + { + "id": "go", + "kind": "go", + "module": "github.com/ossianhempel/things3-cli/cmd/things@latest", + "bins": ["things"], + "label": "Install things3-cli (go)", + }, + ], + }, + } +--- + +# Things 3 CLI + +Use `things` to read your local Things database (inbox/today/search/projects/areas/tags) and to add/update todos via the Things URL scheme. + +Setup + +- Install (recommended, Apple Silicon): `GOBIN=/opt/homebrew/bin go install github.com/ossianhempel/things3-cli/cmd/things@latest` +- If DB reads fail: grant **Full Disk Access** to the calling app (Terminal for manual runs; `OpenClaw.app` for gateway runs). +- Optional: set `THINGSDB` (or pass `--db`) to point at your `ThingsData-*` folder. +- Optional: set `THINGS_AUTH_TOKEN` to avoid passing `--auth-token` for update ops. + +Read-only (DB) + +- `things inbox --limit 50` +- `things today` +- `things upcoming` +- `things search "query"` +- `things projects` / `things areas` / `things tags` + +Write (URL scheme) + +- Prefer safe preview: `things --dry-run add "Title"` +- Add: `things add "Title" --notes "..." --when today --deadline 2026-01-02` +- Bring Things to front: `things --foreground add "Title"` + +Examples: add a todo + +- Basic: `things add "Buy milk"` +- With notes: `things add "Buy milk" --notes "2% + bananas"` +- Into a project/area: `things add "Book flights" --list "Travel"` +- Into a project heading: `things add "Pack charger" --list "Travel" --heading "Before"` +- With tags: `things add "Call dentist" --tags "health,phone"` +- Checklist: `things add "Trip prep" --checklist-item "Passport" --checklist-item "Tickets"` +- From STDIN (multi-line => title + notes): + - `cat <<'EOF' | things add -` + - `Title line` + - `Notes line 1` + - `Notes line 2` + - `EOF` + +Examples: modify a todo (needs auth token) + +- First: get the ID (UUID column): `things search "milk" --limit 5` +- Auth: set `THINGS_AUTH_TOKEN` or pass `--auth-token ` +- Title: `things update --id --auth-token "New title"` +- Notes replace: `things update --id --auth-token --notes "New notes"` +- Notes append/prepend: `things update --id --auth-token --append-notes "..."` / `--prepend-notes "..."` +- Move lists: `things update --id --auth-token --list "Travel" --heading "Before"` +- Tags replace/add: `things update --id --auth-token --tags "a,b"` / `things update --id --auth-token --add-tags "a,b"` +- Complete/cancel (soft-delete-ish): `things update --id --auth-token --completed` / `--canceled` +- Safe preview: `things --dry-run update --id --auth-token --completed` + +Delete a todo? + +- Not supported by `things3-cli` right now (no “delete/move-to-trash” write command; `things trash` is read-only listing). +- Options: use Things UI to delete/trash, or mark as `--completed` / `--canceled` via `things update`. + +Notes + +- macOS-only. +- `--dry-run` prints the URL and does not open Things. diff --git a/skills/tmux/SKILL.md b/skills/tmux/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..5ca95cd898e5540a2a8629f2955fb2baa7c5d981 --- /dev/null +++ b/skills/tmux/SKILL.md @@ -0,0 +1,123 @@ +--- +name: tmux +description: Remote-control tmux sessions for interactive CLIs by sending keystrokes and scraping pane output. +metadata: + { "openclaw": { "emoji": "🧵", "os": ["darwin", "linux"], "requires": { "bins": ["tmux"] } } } +--- + +# tmux Skill (OpenClaw) + +Use tmux only when you need an interactive TTY. Prefer exec background mode for long-running, non-interactive tasks. + +## Quickstart (isolated socket, exec tool) + +```bash +SOCKET_DIR="${OPENCLAW_TMUX_SOCKET_DIR:-${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/openclaw-tmux-sockets}}" +mkdir -p "$SOCKET_DIR" +SOCKET="$SOCKET_DIR/openclaw.sock" +SESSION=openclaw-python + +tmux -S "$SOCKET" new -d -s "$SESSION" -n shell +tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- 'PYTHON_BASIC_REPL=1 python3 -q' Enter +tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200 +``` + +After starting a session, always print monitor commands: + +``` +To monitor: + tmux -S "$SOCKET" attach -t "$SESSION" + tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200 +``` + +## Socket convention + +- Use `OPENCLAW_TMUX_SOCKET_DIR` (legacy `CLAWDBOT_TMUX_SOCKET_DIR` also supported). +- Default socket path: `"$OPENCLAW_TMUX_SOCKET_DIR/openclaw.sock"`. + +## Targeting panes and naming + +- Target format: `session:window.pane` (defaults to `:0.0`). +- Keep names short; avoid spaces. +- Inspect: `tmux -S "$SOCKET" list-sessions`, `tmux -S "$SOCKET" list-panes -a`. + +## Finding sessions + +- List sessions on your socket: `{baseDir}/scripts/find-sessions.sh -S "$SOCKET"`. +- Scan all sockets: `{baseDir}/scripts/find-sessions.sh --all` (uses `OPENCLAW_TMUX_SOCKET_DIR`). + +## Sending input safely + +- Prefer literal sends: `tmux -S "$SOCKET" send-keys -t target -l -- "$cmd"`. +- Control keys: `tmux -S "$SOCKET" send-keys -t target C-c`. + +## Watching output + +- Capture recent history: `tmux -S "$SOCKET" capture-pane -p -J -t target -S -200`. +- Wait for prompts: `{baseDir}/scripts/wait-for-text.sh -t session:0.0 -p 'pattern'`. +- Attaching is OK; detach with `Ctrl+b d`. + +## Spawning processes + +- For python REPLs, set `PYTHON_BASIC_REPL=1` (non-basic REPL breaks send-keys flows). + +## Windows / WSL + +- tmux is supported on macOS/Linux. On Windows, use WSL and install tmux inside WSL. +- This skill is gated to `darwin`/`linux` and requires `tmux` on PATH. + +## Orchestrating Coding Agents (Codex, Claude Code) + +tmux excels at running multiple coding agents in parallel: + +```bash +SOCKET="${TMPDIR:-/tmp}/codex-army.sock" + +# Create multiple sessions +for i in 1 2 3 4 5; do + tmux -S "$SOCKET" new-session -d -s "agent-$i" +done + +# Launch agents in different workdirs +tmux -S "$SOCKET" send-keys -t agent-1 "cd /tmp/project1 && codex --yolo 'Fix bug X'" Enter +tmux -S "$SOCKET" send-keys -t agent-2 "cd /tmp/project2 && codex --yolo 'Fix bug Y'" Enter + +# Poll for completion (check if prompt returned) +for sess in agent-1 agent-2; do + if tmux -S "$SOCKET" capture-pane -p -t "$sess" -S -3 | grep -q "❯"; then + echo "$sess: DONE" + else + echo "$sess: Running..." + fi +done + +# Get full output from completed session +tmux -S "$SOCKET" capture-pane -p -t agent-1 -S -500 +``` + +**Tips:** + +- Use separate git worktrees for parallel fixes (no branch conflicts) +- `pnpm install` first before running codex in fresh clones +- Check for shell prompt (`❯` or `$`) to detect completion +- Codex needs `--yolo` or `--full-auto` for non-interactive fixes + +## Cleanup + +- Kill a session: `tmux -S "$SOCKET" kill-session -t "$SESSION"`. +- Kill all sessions on a socket: `tmux -S "$SOCKET" list-sessions -F '#{session_name}' | xargs -r -n1 tmux -S "$SOCKET" kill-session -t`. +- Remove everything on the private socket: `tmux -S "$SOCKET" kill-server`. + +## Helper: wait-for-text.sh + +`{baseDir}/scripts/wait-for-text.sh` polls a pane for a regex (or fixed string) with a timeout. + +```bash +{baseDir}/scripts/wait-for-text.sh -t session:0.0 -p 'pattern' [-F] [-T 20] [-i 0.5] [-l 2000] +``` + +- `-t`/`--target` pane target (required) +- `-p`/`--pattern` regex to match (required); add `-F` for fixed string +- `-T` timeout seconds (integer, default 15) +- `-i` poll interval seconds (default 0.5) +- `-l` history lines to search (integer, default 1000) diff --git a/skills/tmux/scripts/find-sessions.sh b/skills/tmux/scripts/find-sessions.sh new file mode 100644 index 0000000000000000000000000000000000000000..8387c16299d52a9a426aedbc921a2bf27fca5611 --- /dev/null +++ b/skills/tmux/scripts/find-sessions.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: find-sessions.sh [-L socket-name|-S socket-path|-A] [-q pattern] + +List tmux sessions on a socket (default tmux socket if none provided). + +Options: + -L, --socket tmux socket name (passed to tmux -L) + -S, --socket-path tmux socket path (passed to tmux -S) + -A, --all scan all sockets under OPENCLAW_TMUX_SOCKET_DIR + -q, --query case-insensitive substring to filter session names + -h, --help show this help +USAGE +} + +socket_name="" +socket_path="" +query="" +scan_all=false +socket_dir="${OPENCLAW_TMUX_SOCKET_DIR:-${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/openclaw-tmux-sockets}}" + +while [[ $# -gt 0 ]]; do + case "$1" in + -L|--socket) socket_name="${2-}"; shift 2 ;; + -S|--socket-path) socket_path="${2-}"; shift 2 ;; + -A|--all) scan_all=true; shift ;; + -q|--query) query="${2-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac +done + +if [[ "$scan_all" == true && ( -n "$socket_name" || -n "$socket_path" ) ]]; then + echo "Cannot combine --all with -L or -S" >&2 + exit 1 +fi + +if [[ -n "$socket_name" && -n "$socket_path" ]]; then + echo "Use either -L or -S, not both" >&2 + exit 1 +fi + +if ! command -v tmux >/dev/null 2>&1; then + echo "tmux not found in PATH" >&2 + exit 1 +fi + +list_sessions() { + local label="$1"; shift + local tmux_cmd=(tmux "$@") + + if ! sessions="$("${tmux_cmd[@]}" list-sessions -F '#{session_name}\t#{session_attached}\t#{session_created_string}' 2>/dev/null)"; then + echo "No tmux server found on $label" >&2 + return 1 + fi + + if [[ -n "$query" ]]; then + sessions="$(printf '%s\n' "$sessions" | grep -i -- "$query" || true)" + fi + + if [[ -z "$sessions" ]]; then + echo "No sessions found on $label" + return 0 + fi + + echo "Sessions on $label:" + printf '%s\n' "$sessions" | while IFS=$'\t' read -r name attached created; do + attached_label=$([[ "$attached" == "1" ]] && echo "attached" || echo "detached") + printf ' - %s (%s, started %s)\n' "$name" "$attached_label" "$created" + done +} + +if [[ "$scan_all" == true ]]; then + if [[ ! -d "$socket_dir" ]]; then + echo "Socket directory not found: $socket_dir" >&2 + exit 1 + fi + + shopt -s nullglob + sockets=("$socket_dir"/*) + shopt -u nullglob + + if [[ "${#sockets[@]}" -eq 0 ]]; then + echo "No sockets found under $socket_dir" >&2 + exit 1 + fi + + exit_code=0 + for sock in "${sockets[@]}"; do + if [[ ! -S "$sock" ]]; then + continue + fi + list_sessions "socket path '$sock'" -S "$sock" || exit_code=$? + done + exit "$exit_code" +fi + +tmux_cmd=(tmux) +socket_label="default socket" + +if [[ -n "$socket_name" ]]; then + tmux_cmd+=(-L "$socket_name") + socket_label="socket name '$socket_name'" +elif [[ -n "$socket_path" ]]; then + tmux_cmd+=(-S "$socket_path") + socket_label="socket path '$socket_path'" +fi + +list_sessions "$socket_label" "${tmux_cmd[@]:1}" diff --git a/skills/tmux/scripts/wait-for-text.sh b/skills/tmux/scripts/wait-for-text.sh new file mode 100644 index 0000000000000000000000000000000000000000..56354be835459c7614bfc96b5526e92dc1dbde5d --- /dev/null +++ b/skills/tmux/scripts/wait-for-text.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: wait-for-text.sh -t target -p pattern [options] + +Poll a tmux pane for text and exit when found. + +Options: + -t, --target tmux target (session:window.pane), required + -p, --pattern regex pattern to look for, required + -F, --fixed treat pattern as a fixed string (grep -F) + -T, --timeout seconds to wait (integer, default: 15) + -i, --interval poll interval in seconds (default: 0.5) + -l, --lines number of history lines to inspect (integer, default: 1000) + -h, --help show this help +USAGE +} + +target="" +pattern="" +grep_flag="-E" +timeout=15 +interval=0.5 +lines=1000 + +while [[ $# -gt 0 ]]; do + case "$1" in + -t|--target) target="${2-}"; shift 2 ;; + -p|--pattern) pattern="${2-}"; shift 2 ;; + -F|--fixed) grep_flag="-F"; shift ;; + -T|--timeout) timeout="${2-}"; shift 2 ;; + -i|--interval) interval="${2-}"; shift 2 ;; + -l|--lines) lines="${2-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac +done + +if [[ -z "$target" || -z "$pattern" ]]; then + echo "target and pattern are required" >&2 + usage + exit 1 +fi + +if ! [[ "$timeout" =~ ^[0-9]+$ ]]; then + echo "timeout must be an integer number of seconds" >&2 + exit 1 +fi + +if ! [[ "$lines" =~ ^[0-9]+$ ]]; then + echo "lines must be an integer" >&2 + exit 1 +fi + +if ! command -v tmux >/dev/null 2>&1; then + echo "tmux not found in PATH" >&2 + exit 1 +fi + +# End time in epoch seconds (integer, good enough for polling) +start_epoch=$(date +%s) +deadline=$((start_epoch + timeout)) + +while true; do + # -J joins wrapped lines, -S uses negative index to read last N lines + pane_text="$(tmux capture-pane -p -J -t "$target" -S "-${lines}" 2>/dev/null || true)" + + if printf '%s\n' "$pane_text" | grep $grep_flag -- "$pattern" >/dev/null 2>&1; then + exit 0 + fi + + now=$(date +%s) + if (( now >= deadline )); then + echo "Timed out after ${timeout}s waiting for pattern: $pattern" >&2 + echo "Last ${lines} lines from $target:" >&2 + printf '%s\n' "$pane_text" >&2 + exit 1 + fi + + sleep "$interval" +done diff --git a/skills/trello/SKILL.md b/skills/trello/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..3428be880f7073bc63c5d46be6707feac9d20826 --- /dev/null +++ b/skills/trello/SKILL.md @@ -0,0 +1,95 @@ +--- +name: trello +description: Manage Trello boards, lists, and cards via the Trello REST API. +homepage: https://developer.atlassian.com/cloud/trello/rest/ +metadata: + { + "openclaw": + { "emoji": "📋", "requires": { "bins": ["jq"], "env": ["TRELLO_API_KEY", "TRELLO_TOKEN"] } }, + } +--- + +# Trello Skill + +Manage Trello boards, lists, and cards directly from OpenClaw. + +## Setup + +1. Get your API key: https://trello.com/app-key +2. Generate a token (click "Token" link on that page) +3. Set environment variables: + ```bash + export TRELLO_API_KEY="your-api-key" + export TRELLO_TOKEN="your-token" + ``` + +## Usage + +All commands use curl to hit the Trello REST API. + +### List boards + +```bash +curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id}' +``` + +### List lists in a board + +```bash +curl -s "https://api.trello.com/1/boards/{boardId}/lists?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id}' +``` + +### List cards in a list + +```bash +curl -s "https://api.trello.com/1/lists/{listId}/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id, desc}' +``` + +### Create a card + +```bash +curl -s -X POST "https://api.trello.com/1/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \ + -d "idList={listId}" \ + -d "name=Card Title" \ + -d "desc=Card description" +``` + +### Move a card to another list + +```bash +curl -s -X PUT "https://api.trello.com/1/cards/{cardId}?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \ + -d "idList={newListId}" +``` + +### Add a comment to a card + +```bash +curl -s -X POST "https://api.trello.com/1/cards/{cardId}/actions/comments?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \ + -d "text=Your comment here" +``` + +### Archive a card + +```bash +curl -s -X PUT "https://api.trello.com/1/cards/{cardId}?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \ + -d "closed=true" +``` + +## Notes + +- Board/List/Card IDs can be found in the Trello URL or via the list commands +- The API key and token provide full access to your Trello account - keep them secret! +- Rate limits: 300 requests per 10 seconds per API key; 100 requests per 10 seconds per token; `/1/members` endpoints are limited to 100 requests per 900 seconds + +## Examples + +```bash +# Get all boards +curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN&fields=name,id" | jq + +# Find a specific board by name +curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | select(.name | contains("Work"))' + +# Get all cards on a board +curl -s "https://api.trello.com/1/boards/{boardId}/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, list: .idList}' +``` diff --git a/skills/video-frames/SKILL.md b/skills/video-frames/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..0aca9fbd1998981db29adb94ae91b9344cb3a090 --- /dev/null +++ b/skills/video-frames/SKILL.md @@ -0,0 +1,46 @@ +--- +name: video-frames +description: Extract frames or short clips from videos using ffmpeg. +homepage: https://ffmpeg.org +metadata: + { + "openclaw": + { + "emoji": "🎞️", + "requires": { "bins": ["ffmpeg"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "ffmpeg", + "bins": ["ffmpeg"], + "label": "Install ffmpeg (brew)", + }, + ], + }, + } +--- + +# Video Frames (ffmpeg) + +Extract a single frame from a video, or create quick thumbnails for inspection. + +## Quick start + +First frame: + +```bash +{baseDir}/scripts/frame.sh /path/to/video.mp4 --out /tmp/frame.jpg +``` + +At a timestamp: + +```bash +{baseDir}/scripts/frame.sh /path/to/video.mp4 --time 00:00:10 --out /tmp/frame-10s.jpg +``` + +## Notes + +- Prefer `--time` for “what is happening around here?”. +- Use a `.jpg` for quick share; use `.png` for crisp UI frames. diff --git a/skills/video-frames/scripts/frame.sh b/skills/video-frames/scripts/frame.sh new file mode 100644 index 0000000000000000000000000000000000000000..31b3adb34c068732d04cb0d951d519338458df98 --- /dev/null +++ b/skills/video-frames/scripts/frame.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: + frame.sh [--time HH:MM:SS] [--index N] --out /path/to/frame.jpg + +Examples: + frame.sh video.mp4 --out /tmp/frame.jpg + frame.sh video.mp4 --time 00:00:10 --out /tmp/frame-10s.jpg + frame.sh video.mp4 --index 0 --out /tmp/frame0.png +EOF + exit 2 +} + +if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage +fi + +in="${1:-}" +shift || true + +time="" +index="" +out="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --time) + time="${2:-}" + shift 2 + ;; + --index) + index="${2:-}" + shift 2 + ;; + --out) + out="${2:-}" + shift 2 + ;; + *) + echo "Unknown arg: $1" >&2 + usage + ;; + esac +done + +if [[ ! -f "$in" ]]; then + echo "File not found: $in" >&2 + exit 1 +fi + +if [[ "$out" == "" ]]; then + echo "Missing --out" >&2 + usage +fi + +mkdir -p "$(dirname "$out")" + +if [[ "$index" != "" ]]; then + ffmpeg -hide_banner -loglevel error -y \ + -i "$in" \ + -vf "select=eq(n\\,${index})" \ + -vframes 1 \ + "$out" +elif [[ "$time" != "" ]]; then + ffmpeg -hide_banner -loglevel error -y \ + -ss "$time" \ + -i "$in" \ + -frames:v 1 \ + "$out" +else + ffmpeg -hide_banner -loglevel error -y \ + -i "$in" \ + -vf "select=eq(n\\,0)" \ + -vframes 1 \ + "$out" +fi + +echo "$out" diff --git a/skills/voice-call/SKILL.md b/skills/voice-call/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..7cfb5769252ac5d2ad39e0bab0c27102a7bcf96f --- /dev/null +++ b/skills/voice-call/SKILL.md @@ -0,0 +1,45 @@ +--- +name: voice-call +description: Start voice calls via the OpenClaw voice-call plugin. +metadata: + { + "openclaw": + { + "emoji": "📞", + "skillKey": "voice-call", + "requires": { "config": ["plugins.entries.voice-call.enabled"] }, + }, + } +--- + +# Voice Call + +Use the voice-call plugin to start or inspect calls (Twilio, Telnyx, Plivo, or mock). + +## CLI + +```bash +openclaw voicecall call --to "+15555550123" --message "Hello from OpenClaw" +openclaw voicecall status --call-id +``` + +## Tool + +Use `voice_call` for agent-initiated calls. + +Actions: + +- `initiate_call` (message, to?, mode?) +- `continue_call` (callId, message) +- `speak_to_user` (callId, message) +- `end_call` (callId) +- `get_status` (callId) + +Notes: + +- Requires the voice-call plugin to be enabled. +- Plugin config lives under `plugins.entries.voice-call.config`. +- Twilio config: `provider: "twilio"` + `twilio.accountSid/authToken` + `fromNumber`. +- Telnyx config: `provider: "telnyx"` + `telnyx.apiKey/connectionId` + `fromNumber`. +- Plivo config: `provider: "plivo"` + `plivo.authId/authToken` + `fromNumber`. +- Dev fallback: `provider: "mock"` (no network). diff --git a/skills/wacli/SKILL.md b/skills/wacli/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..c1a9b8df0a99982a18fd102f401ef83f6af6c685 --- /dev/null +++ b/skills/wacli/SKILL.md @@ -0,0 +1,72 @@ +--- +name: wacli +description: Send WhatsApp messages to other people or search/sync WhatsApp history via the wacli CLI (not for normal user chats). +homepage: https://wacli.sh +metadata: + { + "openclaw": + { + "emoji": "📱", + "requires": { "bins": ["wacli"] }, + "install": + [ + { + "id": "brew", + "kind": "brew", + "formula": "steipete/tap/wacli", + "bins": ["wacli"], + "label": "Install wacli (brew)", + }, + { + "id": "go", + "kind": "go", + "module": "github.com/steipete/wacli/cmd/wacli@latest", + "bins": ["wacli"], + "label": "Install wacli (go)", + }, + ], + }, + } +--- + +# wacli + +Use `wacli` only when the user explicitly asks you to message someone else on WhatsApp or when they ask to sync/search WhatsApp history. +Do NOT use `wacli` for normal user chats; OpenClaw routes WhatsApp conversations automatically. +If the user is chatting with you on WhatsApp, you should not reach for this tool unless they ask you to contact a third party. + +Safety + +- Require explicit recipient + message text. +- Confirm recipient + message before sending. +- If anything is ambiguous, ask a clarifying question. + +Auth + sync + +- `wacli auth` (QR login + initial sync) +- `wacli sync --follow` (continuous sync) +- `wacli doctor` + +Find chats + messages + +- `wacli chats list --limit 20 --query "name or number"` +- `wacli messages search "query" --limit 20 --chat ` +- `wacli messages search "invoice" --after 2025-01-01 --before 2025-12-31` + +History backfill + +- `wacli history backfill --chat --requests 2 --count 50` + +Send + +- Text: `wacli send text --to "+14155551212" --message "Hello! Are you free at 3pm?"` +- Group: `wacli send text --to "1234567890-123456789@g.us" --message "Running 5 min late."` +- File: `wacli send file --to "+14155551212" --file /path/agenda.pdf --caption "Agenda"` + +Notes + +- Store dir: `~/.wacli` (override with `--store`). +- Use `--json` for machine-readable output when parsing. +- Backfill requires your phone online; results are best-effort. +- WhatsApp CLI is not needed for routine user chats; it’s for messaging other people. +- JIDs: direct chats look like `@s.whatsapp.net`; groups look like `@g.us` (use `wacli chats list` to find). diff --git a/skills/weather/SKILL.md b/skills/weather/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..15c88f45d8ad82ddedf2a8600bfb06fc488bbd97 --- /dev/null +++ b/skills/weather/SKILL.md @@ -0,0 +1,54 @@ +--- +name: weather +description: Get current weather and forecasts (no API key required). +homepage: https://wttr.in/:help +metadata: { "openclaw": { "emoji": "🌤️", "requires": { "bins": ["curl"] } } } +--- + +# Weather + +Two free services, no API keys needed. + +## wttr.in (primary) + +Quick one-liner: + +```bash +curl -s "wttr.in/London?format=3" +# Output: London: ⛅️ +8°C +``` + +Compact format: + +```bash +curl -s "wttr.in/London?format=%l:+%c+%t+%h+%w" +# Output: London: ⛅️ +8°C 71% ↙5km/h +``` + +Full forecast: + +```bash +curl -s "wttr.in/London?T" +``` + +Format codes: `%c` condition · `%t` temp · `%h` humidity · `%w` wind · `%l` location · `%m` moon + +Tips: + +- URL-encode spaces: `wttr.in/New+York` +- Airport codes: `wttr.in/JFK` +- Units: `?m` (metric) `?u` (USCS) +- Today only: `?1` · Current only: `?0` +- PNG: `curl -s "wttr.in/Berlin.png" -o /tmp/weather.png` + +## Open-Meteo (fallback, JSON) + +Free, no key, good for programmatic use: + +```bash +curl -s "https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.12¤t_weather=true" +``` + +Find coordinates for a city, then query. Returns JSON with temp, windspeed, weathercode. + +Docs: https://open-meteo.com/en/docs diff --git a/test/auto-reply.retry.test.ts b/test/auto-reply.retry.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3a773b2891635f56a77c8e65b1048aeb77ca3c6 --- /dev/null +++ b/test/auto-reply.retry.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../src/web/media.js", () => ({ + loadWebMedia: vi.fn(async () => ({ + buffer: Buffer.from("img"), + contentType: "image/jpeg", + kind: "image", + fileName: "img.jpg", + })), +})); + +import type { WebInboundMessage } from "../src/web/inbound.js"; +import { defaultRuntime } from "../src/runtime.js"; +import { deliverWebReply } from "../src/web/auto-reply.js"; + +const noopLogger = { + info: vi.fn(), + warn: vi.fn(), +}; + +function makeMsg(): WebInboundMessage { + const reply = vi.fn< + Parameters, + ReturnType + >(); + const sendMedia = vi.fn< + Parameters, + ReturnType + >(); + const sendComposing = vi.fn< + Parameters, + ReturnType + >(); + return { + from: "+10000000000", + conversationId: "+10000000000", + to: "+20000000000", + id: "abc", + body: "hello", + chatType: "direct", + chatId: "chat-1", + sendComposing, + reply, + sendMedia, + }; +} + +describe("deliverWebReply retry", () => { + it("retries text send on transient failure", async () => { + const msg = makeMsg(); + msg.reply.mockRejectedValueOnce(new Error("connection closed")); + msg.reply.mockResolvedValueOnce(undefined); + + await expect( + deliverWebReply({ + replyResult: { text: "hi" }, + msg, + maxMediaBytes: 5_000_000, + replyLogger: noopLogger, + runtime: defaultRuntime, + skipLog: true, + }), + ).resolves.toBeUndefined(); + + expect(msg.reply).toHaveBeenCalledTimes(2); + }); + + it("retries media send on transient failure", async () => { + const msg = makeMsg(); + msg.sendMedia.mockRejectedValueOnce(new Error("socket reset")); + msg.sendMedia.mockResolvedValueOnce(undefined); + + await expect( + deliverWebReply({ + replyResult: { + text: "caption", + mediaUrl: "http://example.com/img.jpg", + }, + msg, + maxMediaBytes: 5_000_000, + replyLogger: noopLogger, + runtime: defaultRuntime, + skipLog: true, + }), + ).resolves.toBeUndefined(); + + expect(msg.sendMedia).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/fixtures/child-process-bridge/child.js b/test/fixtures/child-process-bridge/child.js new file mode 100644 index 0000000000000000000000000000000000000000..9ef083e42b399fa7c7d41313a55d4998224ecdbf --- /dev/null +++ b/test/fixtures/child-process-bridge/child.js @@ -0,0 +1,21 @@ +import http from "node:http"; + +const server = http.createServer((_, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("ok"); +}); + +server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (!addr || typeof addr === "string") { + throw new Error("unexpected address"); + } + process.stdout.write(`${addr.port}\n`); +}); + +const shutdown = () => { + server.close(() => process.exit(0)); +}; + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); diff --git a/test/gateway.multi.e2e.test.ts b/test/gateway.multi.e2e.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7bbe7ecc305099a34b5cc2d0f549e341d6685d58 --- /dev/null +++ b/test/gateway.multi.e2e.test.ts @@ -0,0 +1,422 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import { request as httpRequest } from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { GatewayClient } from "../src/gateway/client.js"; +import { loadOrCreateDeviceIdentity } from "../src/infra/device-identity.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js"; + +type GatewayInstance = { + name: string; + port: number; + hookToken: string; + gatewayToken: string; + homeDir: string; + stateDir: string; + configPath: string; + child: ChildProcessWithoutNullStreams; + stdout: string[]; + stderr: string[]; +}; + +type NodeListPayload = { + nodes?: Array<{ nodeId?: string; connected?: boolean; paired?: boolean }>; +}; + +type HealthPayload = { ok?: boolean }; + +const GATEWAY_START_TIMEOUT_MS = 45_000; +const E2E_TIMEOUT_MS = 120_000; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const getFreePort = async () => { + const srv = net.createServer(); + await new Promise((resolve) => srv.listen(0, "127.0.0.1", resolve)); + const addr = srv.address(); + if (!addr || typeof addr === "string") { + srv.close(); + throw new Error("failed to bind ephemeral port"); + } + await new Promise((resolve) => srv.close(() => resolve())); + return addr.port; +}; + +const waitForPortOpen = async ( + proc: ChildProcessWithoutNullStreams, + chunksOut: string[], + chunksErr: string[], + port: number, + timeoutMs: number, +) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (proc.exitCode !== null) { + const stdout = chunksOut.join(""); + const stderr = chunksErr.join(""); + throw new Error( + `gateway exited before listening (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` + + `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, + ); + } + + try { + await new Promise((resolve, reject) => { + const socket = net.connect({ host: "127.0.0.1", port }); + socket.once("connect", () => { + socket.destroy(); + resolve(); + }); + socket.once("error", (err) => { + socket.destroy(); + reject(err); + }); + }); + return; + } catch { + // keep polling + } + + await sleep(25); + } + const stdout = chunksOut.join(""); + const stderr = chunksErr.join(""); + throw new Error( + `timeout waiting for gateway to listen on port ${port}\n` + + `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, + ); +}; + +const spawnGatewayInstance = async (name: string): Promise => { + const port = await getFreePort(); + const hookToken = `token-${name}-${randomUUID()}`; + const gatewayToken = `gateway-${name}-${randomUUID()}`; + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-e2e-${name}-`)); + const configDir = path.join(homeDir, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + const configPath = path.join(configDir, "openclaw.json"); + const stateDir = path.join(configDir, "state"); + const config = { + gateway: { port, auth: { mode: "token", token: gatewayToken } }, + hooks: { enabled: true, token: hookToken, path: "/hooks" }, + }; + await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); + + const stdout: string[] = []; + const stderr: string[] = []; + let child: ChildProcessWithoutNullStreams | null = null; + + try { + child = spawn( + "node", + [ + "dist/index.js", + "gateway", + "--port", + String(port), + "--bind", + "loopback", + "--allow-unconfigured", + ], + { + cwd: process.cwd(), + env: { + ...process.env, + HOME: homeDir, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_GATEWAY_TOKEN: "", + OPENCLAW_GATEWAY_PASSWORD: "", + OPENCLAW_SKIP_CHANNELS: "1", + OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1", + OPENCLAW_SKIP_CANVAS_HOST: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (d) => stdout.push(String(d))); + child.stderr?.on("data", (d) => stderr.push(String(d))); + + await waitForPortOpen(child, stdout, stderr, port, GATEWAY_START_TIMEOUT_MS); + + return { + name, + port, + hookToken, + gatewayToken, + homeDir, + stateDir, + configPath, + child, + stdout, + stderr, + }; + } catch (err) { + if (child && child.exitCode === null && !child.killed) { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + } + await fs.rm(homeDir, { recursive: true, force: true }); + throw err; + } +}; + +const stopGatewayInstance = async (inst: GatewayInstance) => { + if (inst.child.exitCode === null && !inst.child.killed) { + try { + inst.child.kill("SIGTERM"); + } catch { + // ignore + } + } + const exited = await Promise.race([ + new Promise((resolve) => { + if (inst.child.exitCode !== null) { + return resolve(true); + } + inst.child.once("exit", () => resolve(true)); + }), + sleep(5_000).then(() => false), + ]); + if (!exited && inst.child.exitCode === null && !inst.child.killed) { + try { + inst.child.kill("SIGKILL"); + } catch { + // ignore + } + } + await fs.rm(inst.homeDir, { recursive: true, force: true }); +}; + +const runCliJson = async (args: string[], env: NodeJS.ProcessEnv): Promise => { + const stdout: string[] = []; + const stderr: string[] = []; + const child = spawn("node", ["dist/index.js", ...args], { + cwd: process.cwd(), + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (d) => stdout.push(String(d))); + child.stderr?.on("data", (d) => stderr.push(String(d))); + const result = await new Promise<{ + code: number | null; + signal: string | null; + }>((resolve) => child.once("exit", (code, signal) => resolve({ code, signal }))); + const out = stdout.join("").trim(); + if (result.code !== 0) { + throw new Error( + `cli failed (code=${String(result.code)} signal=${String(result.signal)})\n` + + `--- stdout ---\n${out}\n--- stderr ---\n${stderr.join("")}`, + ); + } + try { + return out ? (JSON.parse(out) as unknown) : null; + } catch (err) { + throw new Error( + `cli returned non-json output: ${String(err)}\n` + + `--- stdout ---\n${out}\n--- stderr ---\n${stderr.join("")}`, + { cause: err }, + ); + } +}; + +const postJson = async (url: string, body: unknown) => { + const payload = JSON.stringify(body); + const parsed = new URL(url); + return await new Promise<{ status: number; json: unknown }>((resolve, reject) => { + const req = httpRequest( + { + method: "POST", + hostname: parsed.hostname, + port: Number(parsed.port), + path: `${parsed.pathname}${parsed.search}`, + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }, + }, + (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + let json: unknown = null; + if (data.trim()) { + try { + json = JSON.parse(data); + } catch { + json = data; + } + } + resolve({ status: res.statusCode ?? 0, json }); + }); + }, + ); + req.on("error", reject); + req.write(payload); + req.end(); + }); +}; + +const connectNode = async ( + inst: GatewayInstance, + label: string, +): Promise<{ client: GatewayClient; nodeId: string }> => { + const identityPath = path.join(inst.homeDir, `${label}-device.json`); + const deviceIdentity = loadOrCreateDeviceIdentity(identityPath); + const nodeId = deviceIdentity.deviceId; + let settled = false; + let resolveReady: (() => void) | null = null; + let rejectReady: ((err: Error) => void) | null = null; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + + const client = new GatewayClient({ + url: `ws://127.0.0.1:${inst.port}`, + token: inst.gatewayToken, + clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, + clientDisplayName: label, + clientVersion: "1.0.0", + platform: "ios", + mode: GATEWAY_CLIENT_MODES.NODE, + role: "node", + scopes: [], + caps: ["system"], + commands: ["system.run"], + deviceIdentity, + onHelloOk: () => { + if (settled) { + return; + } + settled = true; + resolveReady?.(); + }, + onConnectError: (err) => { + if (settled) { + return; + } + settled = true; + rejectReady?.(err); + }, + onClose: (code, reason) => { + if (settled) { + return; + } + settled = true; + rejectReady?.(new Error(`gateway closed (${code}): ${reason}`)); + }, + }); + + client.start(); + try { + await Promise.race([ + ready, + sleep(10_000).then(() => { + throw new Error(`timeout waiting for ${label} to connect`); + }), + ]); + } catch (err) { + client.stop(); + throw err; + } + return { client, nodeId }; +}; + +const waitForNodeStatus = async (inst: GatewayInstance, nodeId: string, timeoutMs = 10_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const list = (await runCliJson( + ["nodes", "status", "--json", "--url", `ws://127.0.0.1:${inst.port}`], + { + OPENCLAW_GATEWAY_TOKEN: inst.gatewayToken, + OPENCLAW_GATEWAY_PASSWORD: "", + }, + )) as NodeListPayload; + const match = list.nodes?.find((n) => n.nodeId === nodeId); + if (match?.connected && match?.paired) { + return; + } + await sleep(50); + } + throw new Error(`timeout waiting for node status for ${nodeId}`); +}; + +describe("gateway multi-instance e2e", () => { + const instances: GatewayInstance[] = []; + const nodeClients: GatewayClient[] = []; + + afterAll(async () => { + for (const client of nodeClients) { + client.stop(); + } + for (const inst of instances) { + await stopGatewayInstance(inst); + } + }); + + it( + "spins up two gateways and exercises WS + HTTP + node pairing", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const gwA = await spawnGatewayInstance("a"); + instances.push(gwA); + const gwB = await spawnGatewayInstance("b"); + instances.push(gwB); + + const [healthA, healthB] = (await Promise.all([ + runCliJson(["health", "--json", "--timeout", "10000"], { + OPENCLAW_GATEWAY_PORT: String(gwA.port), + OPENCLAW_GATEWAY_TOKEN: gwA.gatewayToken, + OPENCLAW_GATEWAY_PASSWORD: "", + }), + runCliJson(["health", "--json", "--timeout", "10000"], { + OPENCLAW_GATEWAY_PORT: String(gwB.port), + OPENCLAW_GATEWAY_TOKEN: gwB.gatewayToken, + OPENCLAW_GATEWAY_PASSWORD: "", + }), + ])) as [HealthPayload, HealthPayload]; + expect(healthA.ok).toBe(true); + expect(healthB.ok).toBe(true); + + const [hookResA, hookResB] = await Promise.all([ + postJson(`http://127.0.0.1:${gwA.port}/hooks/wake?token=${gwA.hookToken}`, { + text: "wake a", + mode: "now", + }), + postJson(`http://127.0.0.1:${gwB.port}/hooks/wake?token=${gwB.hookToken}`, { + text: "wake b", + mode: "now", + }), + ]); + expect(hookResA.status).toBe(200); + expect((hookResA.json as { ok?: boolean } | undefined)?.ok).toBe(true); + expect(hookResB.status).toBe(200); + expect((hookResB.json as { ok?: boolean } | undefined)?.ok).toBe(true); + + const nodeA = await connectNode(gwA, "node-a"); + const nodeB = await connectNode(gwB, "node-b"); + nodeClients.push(nodeA.client, nodeB.client); + + await Promise.all([ + waitForNodeStatus(gwA, nodeA.nodeId), + waitForNodeStatus(gwB, nodeB.nodeId), + ]); + }, + ); +}); diff --git a/test/global-setup.ts b/test/global-setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..289fd877b3357ca0b16a6f1634867a9df002d68a --- /dev/null +++ b/test/global-setup.ts @@ -0,0 +1,6 @@ +import { installTestEnv } from "./test-env"; + +export default async () => { + const { cleanup } = installTestEnv(); + return () => cleanup(); +}; diff --git a/test/helpers/envelope-timestamp.ts b/test/helpers/envelope-timestamp.ts new file mode 100644 index 0000000000000000000000000000000000000000..1bd7ad17ce5331f283288f97e885c772f56e341a --- /dev/null +++ b/test/helpers/envelope-timestamp.ts @@ -0,0 +1,59 @@ +type EnvelopeTimestampZone = string; + +function formatUtcTimestamp(date: Date): string { + const yyyy = String(date.getUTCFullYear()).padStart(4, "0"); + const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(date.getUTCDate()).padStart(2, "0"); + const hh = String(date.getUTCHours()).padStart(2, "0"); + const min = String(date.getUTCMinutes()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`; +} + +function formatZonedTimestamp(date: Date, timeZone?: string): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZoneName: "short", + }).formatToParts(date); + + const pick = (type: string) => parts.find((part) => part.type === type)?.value; + const yyyy = pick("year"); + const mm = pick("month"); + const dd = pick("day"); + const hh = pick("hour"); + const min = pick("minute"); + const tz = [...parts] + .toReversed() + .find((part) => part.type === "timeZoneName") + ?.value?.trim(); + + if (!yyyy || !mm || !dd || !hh || !min) { + throw new Error("Missing date parts for envelope timestamp formatting."); + } + + return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`; +} + +export function formatEnvelopeTimestamp(date: Date, zone: EnvelopeTimestampZone = "utc"): string { + const normalized = zone.trim().toLowerCase(); + if (normalized === "utc" || normalized === "gmt") { + return formatUtcTimestamp(date); + } + if (normalized === "local" || normalized === "host") { + return formatZonedTimestamp(date); + } + return formatZonedTimestamp(date, zone); +} + +export function formatLocalEnvelopeTimestamp(date: Date): string { + return formatEnvelopeTimestamp(date, "local"); +} + +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/test/helpers/inbound-contract.ts b/test/helpers/inbound-contract.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ac4c2cc51675757e4e70725fe922c0b8317820a --- /dev/null +++ b/test/helpers/inbound-contract.ts @@ -0,0 +1,19 @@ +import { expect } from "vitest"; +import type { MsgContext } from "../../src/auto-reply/templating.js"; +import { normalizeChatType } from "../../src/channels/chat-type.js"; +import { resolveConversationLabel } from "../../src/channels/conversation-label.js"; +import { validateSenderIdentity } from "../../src/channels/sender-identity.js"; + +export function expectInboundContextContract(ctx: MsgContext) { + expect(validateSenderIdentity(ctx)).toEqual([]); + + expect(ctx.Body).toBeTypeOf("string"); + expect(ctx.BodyForAgent).toBeTypeOf("string"); + expect(ctx.BodyForCommands).toBeTypeOf("string"); + + const chatType = normalizeChatType(ctx.ChatType); + if (chatType && chatType !== "direct") { + const label = ctx.ConversationLabel?.trim() || resolveConversationLabel(ctx); + expect(label).toBeTruthy(); + } +} diff --git a/test/helpers/normalize-text.ts b/test/helpers/normalize-text.ts new file mode 100644 index 0000000000000000000000000000000000000000..d81be0106cf596db863293e21cd04c49033431bc --- /dev/null +++ b/test/helpers/normalize-text.ts @@ -0,0 +1,37 @@ +function stripAnsi(input: string): string { + let out = ""; + for (let i = 0; i < input.length; i++) { + const code = input.charCodeAt(i); + if (code !== 27) { + out += input[i]; + continue; + } + + const next = input[i + 1]; + if (next !== "[") { + continue; + } + i += 1; + + while (i + 1 < input.length) { + i += 1; + const c = input[i]; + if (!c) { + break; + } + const isLetter = (c >= "A" && c <= "Z") || (c >= "a" && c <= "z") || c === "~"; + if (isLetter) { + break; + } + } + } + return out; +} + +export function normalizeTestText(input: string): string { + return stripAnsi(input) + .replaceAll("\r\n", "\n") + .replaceAll("…", "...") + .replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "?") + .replace(/[\uD800-\uDFFF]/g, "?"); +} diff --git a/test/helpers/paths.ts b/test/helpers/paths.ts new file mode 100644 index 0000000000000000000000000000000000000000..1893f39f16ed61b7f0e5941a27a5da1cf88200b5 --- /dev/null +++ b/test/helpers/paths.ts @@ -0,0 +1,16 @@ +import path from "node:path"; + +export function isPathWithinBase(base: string, target: string): boolean { + if (process.platform === "win32") { + const normalizedBase = path.win32.normalize(path.win32.resolve(base)); + const normalizedTarget = path.win32.normalize(path.win32.resolve(target)); + + const rel = path.win32.relative(normalizedBase.toLowerCase(), normalizedTarget.toLowerCase()); + return rel === "" || (!rel.startsWith("..") && !path.win32.isAbsolute(rel)); + } + + const normalizedBase = path.resolve(base); + const normalizedTarget = path.resolve(target); + const rel = path.relative(normalizedBase, normalizedTarget); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} diff --git a/test/helpers/poll.ts b/test/helpers/poll.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b1a212e9372ec808bca1e5808e184c14c8a9309 --- /dev/null +++ b/test/helpers/poll.ts @@ -0,0 +1,27 @@ +export type PollOptions = { + timeoutMs?: number; + intervalMs?: number; +}; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function pollUntil( + fn: () => Promise, + opts: PollOptions = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 2000; + const intervalMs = opts.intervalMs ?? 25; + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + const value = await fn(); + if (value !== null && value !== undefined) { + return value; + } + await sleep(intervalMs); + } + + return undefined; +} diff --git a/test/helpers/temp-home.ts b/test/helpers/temp-home.ts new file mode 100644 index 0000000000000000000000000000000000000000..30af94ca15dbf58ff68716340e891febef16e3eb --- /dev/null +++ b/test/helpers/temp-home.ts @@ -0,0 +1,117 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +type EnvValue = string | undefined | ((home: string) => string | undefined); + +type EnvSnapshot = { + home: string | undefined; + userProfile: string | undefined; + homeDrive: string | undefined; + homePath: string | undefined; + stateDir: string | undefined; +}; + +function snapshotEnv(): EnvSnapshot { + return { + home: process.env.HOME, + userProfile: process.env.USERPROFILE, + homeDrive: process.env.HOMEDRIVE, + homePath: process.env.HOMEPATH, + stateDir: process.env.OPENCLAW_STATE_DIR, + }; +} + +function restoreEnv(snapshot: EnvSnapshot) { + const restoreKey = (key: string, value: string | undefined) => { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }; + restoreKey("HOME", snapshot.home); + restoreKey("USERPROFILE", snapshot.userProfile); + restoreKey("HOMEDRIVE", snapshot.homeDrive); + restoreKey("HOMEPATH", snapshot.homePath); + restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir); +} + +function snapshotExtraEnv(keys: string[]): Record { + const snapshot: Record = {}; + for (const key of keys) { + snapshot[key] = process.env[key]; + } + return snapshot; +} + +function restoreExtraEnv(snapshot: Record) { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function setTempHome(base: string) { + process.env.HOME = base; + process.env.USERPROFILE = base; + process.env.OPENCLAW_STATE_DIR = path.join(base, ".openclaw"); + + if (process.platform !== "win32") { + return; + } + const match = base.match(/^([A-Za-z]:)(.*)$/); + if (!match) { + return; + } + process.env.HOMEDRIVE = match[1]; + process.env.HOMEPATH = match[2] || "\\"; +} + +export async function withTempHome( + fn: (home: string) => Promise, + opts: { env?: Record; prefix?: string } = {}, +): Promise { + const base = await fs.mkdtemp(path.join(os.tmpdir(), opts.prefix ?? "openclaw-test-home-")); + const snapshot = snapshotEnv(); + const envKeys = Object.keys(opts.env ?? {}); + for (const key of envKeys) { + if (key === "HOME" || key === "USERPROFILE" || key === "HOMEDRIVE" || key === "HOMEPATH") { + throw new Error(`withTempHome: use built-in home env (got ${key})`); + } + } + const envSnapshot = snapshotExtraEnv(envKeys); + + setTempHome(base); + await fs.mkdir(path.join(base, ".openclaw", "agents", "main", "sessions"), { recursive: true }); + if (opts.env) { + for (const [key, raw] of Object.entries(opts.env)) { + const value = typeof raw === "function" ? raw(base) : raw; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + + try { + return await fn(base); + } finally { + restoreExtraEnv(envSnapshot); + restoreEnv(snapshot); + try { + await fs.rm(base, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 50, + }); + } catch { + // ignore cleanup failures in tests + } + } +} diff --git a/test/inbound-contract.providers.test.ts b/test/inbound-contract.providers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e0100e16239a19f265a6fa309d61f1cfc892bb5 --- /dev/null +++ b/test/inbound-contract.providers.test.ts @@ -0,0 +1,161 @@ +import { describe, it } from "vitest"; +import type { MsgContext } from "../src/auto-reply/templating.js"; +import { finalizeInboundContext } from "../src/auto-reply/reply/inbound-context.js"; +import { expectInboundContextContract } from "./helpers/inbound-contract.js"; + +describe("inbound context contract (providers + extensions)", () => { + const cases: Array<{ name: string; ctx: MsgContext }> = [ + { + name: "whatsapp group", + ctx: { + Provider: "whatsapp", + Surface: "whatsapp", + ChatType: "group", + From: "123@g.us", + To: "+15550001111", + Body: "[WhatsApp 123@g.us] hi", + RawBody: "hi", + CommandBody: "hi", + SenderName: "Alice", + }, + }, + { + name: "telegram group", + ctx: { + Provider: "telegram", + Surface: "telegram", + ChatType: "group", + From: "group:123", + To: "telegram:123", + Body: "[Telegram group:123] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Telegram Group", + SenderName: "Alice", + }, + }, + { + name: "slack channel", + ctx: { + Provider: "slack", + Surface: "slack", + ChatType: "channel", + From: "slack:channel:C123", + To: "channel:C123", + Body: "[Slack #general] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "discord channel", + ctx: { + Provider: "discord", + Surface: "discord", + ChatType: "channel", + From: "group:123", + To: "channel:123", + Body: "[Discord #general] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "signal dm", + ctx: { + Provider: "signal", + Surface: "signal", + ChatType: "direct", + From: "signal:+15550001111", + To: "signal:+15550002222", + Body: "[Signal] hi", + RawBody: "hi", + CommandBody: "hi", + }, + }, + { + name: "imessage group", + ctx: { + Provider: "imessage", + Surface: "imessage", + ChatType: "group", + From: "group:chat_id:123", + To: "chat_id:123", + Body: "[iMessage Group] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "iMessage Group", + SenderName: "Alice", + }, + }, + { + name: "matrix channel", + ctx: { + Provider: "matrix", + Surface: "matrix", + ChatType: "channel", + From: "matrix:channel:!room:example.org", + To: "room:!room:example.org", + Body: "[Matrix] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "msteams channel", + ctx: { + Provider: "msteams", + Surface: "msteams", + ChatType: "channel", + From: "msteams:channel:19:abc@thread.tacv2", + To: "msteams:channel:19:abc@thread.tacv2", + Body: "[Teams] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Teams Channel", + SenderName: "Alice", + }, + }, + { + name: "zalo dm", + ctx: { + Provider: "zalo", + Surface: "zalo", + ChatType: "direct", + From: "zalo:123", + To: "zalo:123", + Body: "[Zalo] hi", + RawBody: "hi", + CommandBody: "hi", + }, + }, + { + name: "zalouser group", + ctx: { + Provider: "zalouser", + Surface: "zalouser", + ChatType: "group", + From: "group:123", + To: "zalouser:123", + Body: "[Zalo Personal] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Zalouser Group", + SenderName: "Alice", + }, + }, + ]; + + for (const entry of cases) { + it(entry.name, () => { + const ctx = finalizeInboundContext({ ...entry.ctx }); + expectInboundContextContract(ctx); + }); + } +}); diff --git a/test/media-understanding.auto.e2e.test.ts b/test/media-understanding.auto.e2e.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..98e2c88c5e11424a3ff69c969bc9c6a56915c75c --- /dev/null +++ b/test/media-understanding.auto.e2e.test.ts @@ -0,0 +1,167 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { MsgContext } from "../src/auto-reply/templating.js"; +import type { OpenClawConfig } from "../src/config/config.js"; + +const makeTempDir = async (prefix: string) => await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + +const writeExecutable = async (dir: string, name: string, content: string) => { + const filePath = path.join(dir, name); + await fs.writeFile(filePath, content, { mode: 0o755 }); + return filePath; +}; + +const makeTempMedia = async (ext: string) => { + const dir = await makeTempDir("openclaw-media-e2e-"); + const filePath = path.join(dir, `sample${ext}`); + await fs.writeFile(filePath, "audio"); + return { dir, filePath }; +}; + +const loadApply = async () => { + vi.resetModules(); + return await import("../src/media-understanding/apply.js"); +}; + +const envSnapshot = () => ({ + PATH: process.env.PATH, + SHERPA_ONNX_MODEL_DIR: process.env.SHERPA_ONNX_MODEL_DIR, + WHISPER_CPP_MODEL: process.env.WHISPER_CPP_MODEL, +}); + +const restoreEnv = (snapshot: ReturnType) => { + process.env.PATH = snapshot.PATH; + process.env.SHERPA_ONNX_MODEL_DIR = snapshot.SHERPA_ONNX_MODEL_DIR; + process.env.WHISPER_CPP_MODEL = snapshot.WHISPER_CPP_MODEL; +}; + +describe("media understanding auto-detect (e2e)", () => { + let tempPaths: string[] = []; + + afterEach(async () => { + for (const p of tempPaths) { + await fs.rm(p, { recursive: true, force: true }).catch(() => {}); + } + tempPaths = []; + }); + + it("uses sherpa-onnx-offline when available", async () => { + const snapshot = envSnapshot(); + try { + const binDir = await makeTempDir("openclaw-bin-sherpa-"); + const modelDir = await makeTempDir("openclaw-sherpa-model-"); + tempPaths.push(binDir, modelDir); + + await fs.writeFile(path.join(modelDir, "tokens.txt"), "a"); + await fs.writeFile(path.join(modelDir, "encoder.onnx"), "a"); + await fs.writeFile(path.join(modelDir, "decoder.onnx"), "a"); + await fs.writeFile(path.join(modelDir, "joiner.onnx"), "a"); + + await writeExecutable( + binDir, + "sherpa-onnx-offline", + `#!/usr/bin/env bash\necho "{\\"text\\":\\"sherpa ok\\"}"\n`, + ); + + process.env.PATH = `${binDir}:/usr/bin:/bin`; + process.env.SHERPA_ONNX_MODEL_DIR = modelDir; + + const { filePath } = await makeTempMedia(".wav"); + tempPaths.push(path.dirname(filePath)); + + const { applyMediaUnderstanding } = await loadApply(); + const ctx: MsgContext = { + Body: "", + MediaPath: filePath, + MediaType: "audio/wav", + }; + const cfg: OpenClawConfig = { tools: { media: { audio: {} } } }; + + await applyMediaUnderstanding({ ctx, cfg }); + + expect(ctx.Transcript).toBe("sherpa ok"); + } finally { + restoreEnv(snapshot); + } + }); + + it("uses whisper-cli when sherpa is missing", async () => { + const snapshot = envSnapshot(); + try { + const binDir = await makeTempDir("openclaw-bin-whispercpp-"); + const modelDir = await makeTempDir("openclaw-whispercpp-model-"); + tempPaths.push(binDir, modelDir); + + const modelPath = path.join(modelDir, "tiny.bin"); + await fs.writeFile(modelPath, "model"); + + await writeExecutable( + binDir, + "whisper-cli", + "#!/usr/bin/env bash\n" + + 'out=""\n' + + 'prev=""\n' + + 'for arg in "$@"; do\n' + + ' if [ "$prev" = "-of" ]; then out="$arg"; break; fi\n' + + ' prev="$arg"\n' + + "done\n" + + 'if [ -n "$out" ]; then echo \'whisper cpp ok\' > "${out}.txt"; fi\n', + ); + + process.env.PATH = `${binDir}:/usr/bin:/bin`; + process.env.WHISPER_CPP_MODEL = modelPath; + + const { filePath } = await makeTempMedia(".wav"); + tempPaths.push(path.dirname(filePath)); + + const { applyMediaUnderstanding } = await loadApply(); + const ctx: MsgContext = { + Body: "", + MediaPath: filePath, + MediaType: "audio/wav", + }; + const cfg: OpenClawConfig = { tools: { media: { audio: {} } } }; + + await applyMediaUnderstanding({ ctx, cfg }); + + expect(ctx.Transcript).toBe("whisper cpp ok"); + } finally { + restoreEnv(snapshot); + } + }); + + it("uses gemini CLI for images when available", async () => { + const snapshot = envSnapshot(); + try { + const binDir = await makeTempDir("openclaw-bin-gemini-"); + tempPaths.push(binDir); + + await writeExecutable( + binDir, + "gemini", + `#!/usr/bin/env bash\necho '{\\"response\\":\\"gemini ok\\"' + "}'\n`, + ); + + process.env.PATH = `${binDir}:/usr/bin:/bin`; + + const { filePath } = await makeTempMedia(".png"); + tempPaths.push(path.dirname(filePath)); + + const { applyMediaUnderstanding } = await loadApply(); + const ctx: MsgContext = { + Body: "", + MediaPath: filePath, + MediaType: "image/png", + }; + const cfg: OpenClawConfig = { tools: { media: { image: {} } } }; + + await applyMediaUnderstanding({ ctx, cfg }); + + expect(ctx.Body).toContain("gemini ok"); + } finally { + restoreEnv(snapshot); + } + }); +}); diff --git a/test/mocks/baileys.ts b/test/mocks/baileys.ts new file mode 100644 index 0000000000000000000000000000000000000000..e04ef1a2d325335e1b5edc0412fc29d34b3e6963 --- /dev/null +++ b/test/mocks/baileys.ts @@ -0,0 +1,67 @@ +import { EventEmitter } from "node:events"; +import { vi } from "vitest"; + +export type MockBaileysSocket = { + ev: EventEmitter; + ws: { close: ReturnType }; + sendPresenceUpdate: ReturnType; + sendMessage: ReturnType; + readMessages: ReturnType; + user?: { id?: string }; +}; + +export type MockBaileysModule = { + DisconnectReason: { loggedOut: number }; + fetchLatestBaileysVersion: ReturnType; + makeCacheableSignalKeyStore: ReturnType; + makeWASocket: ReturnType; + useMultiFileAuthState: ReturnType; + jidToE164?: (jid: string) => string | null; + proto?: unknown; + downloadMediaMessage?: ReturnType; +}; + +export function createMockBaileys(): { + mod: MockBaileysModule; + lastSocket: () => MockBaileysSocket; +} { + const sockets: MockBaileysSocket[] = []; + const makeWASocket = vi.fn((_opts: unknown) => { + const ev = new EventEmitter(); + const sock: MockBaileysSocket = { + ev, + ws: { close: vi.fn() }, + sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue({ key: { id: "msg123" } }), + readMessages: vi.fn().mockResolvedValue(undefined), + user: { id: "123@s.whatsapp.net" }, + }; + setImmediate(() => ev.emit("connection.update", { connection: "open" })); + sockets.push(sock); + return sock; + }); + + const mod: MockBaileysModule = { + DisconnectReason: { loggedOut: 401 }, + fetchLatestBaileysVersion: vi.fn().mockResolvedValue({ version: [1, 2, 3] }), + makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys), + makeWASocket, + useMultiFileAuthState: vi.fn(async () => ({ + state: { creds: {}, keys: {} }, + saveCreds: vi.fn(), + })), + jidToE164: (jid: string) => jid.replace(/@.*$/, "").replace(/^/, "+"), + downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("img")), + }; + + return { + mod, + lastSocket: () => { + const last = sockets.at(-1); + if (!last) { + throw new Error("No Baileys sockets created"); + } + return last; + }, + }; +} diff --git a/test/provider-timeout.e2e.test.ts b/test/provider-timeout.e2e.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..82779cb49833b01cdd9cd2abefb33a70bbaf9b4a --- /dev/null +++ b/test/provider-timeout.e2e.test.ts @@ -0,0 +1,309 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { GatewayClient } from "../src/gateway/client.js"; +import { startGatewayServer } from "../src/gateway/server.js"; +import { getDeterministicFreePortBlock } from "../src/test-utils/ports.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js"; + +type OpenAIResponseStreamEvent = + | { type: "response.output_item.added"; item: Record } + | { type: "response.output_item.done"; item: Record } + | { + type: "response.completed"; + response: { + status: "completed"; + usage: { + input_tokens: number; + output_tokens: number; + total_tokens: number; + }; + }; + }; + +function buildOpenAIResponsesSse(text: string): Response { + const events: OpenAIResponseStreamEvent[] = [ + { + type: "response.output_item.added", + item: { + type: "message", + id: "msg_test_1", + role: "assistant", + content: [], + status: "in_progress", + }, + }, + { + type: "response.output_item.done", + item: { + type: "message", + id: "msg_test_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text, annotations: [] }], + }, + }, + { + type: "response.completed", + response: { + status: "completed", + usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 }, + }, + }, + ]; + + const sse = `${events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")}data: [DONE]\n\n`; + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse)); + controller.close(); + }, + }); + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function extractPayloadText(result: unknown): string { + const record = result as Record; + const payloads = Array.isArray(record.payloads) ? record.payloads : []; + const texts = payloads + .map((p) => (p && typeof p === "object" ? (p as Record).text : undefined)) + .filter((t): t is string => typeof t === "string" && t.trim().length > 0); + return texts.join("\n").trim(); +} + +async function connectClient(params: { url: string; token: string }) { + return await new Promise>((resolve, reject) => { + let settled = false; + const stop = (err?: Error, client?: InstanceType) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + if (err) { + reject(err); + } else { + resolve(client as InstanceType); + } + }; + const client = new GatewayClient({ + url: params.url, + token: params.token, + clientName: GATEWAY_CLIENT_NAMES.TEST, + clientDisplayName: "vitest-timeout-fallback", + clientVersion: "dev", + mode: GATEWAY_CLIENT_MODES.TEST, + onHelloOk: () => stop(undefined, client), + onConnectError: (err) => stop(err), + onClose: (code, reason) => + stop(new Error(`gateway closed during connect (${code}): ${reason}`)), + }); + const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000); + timer.unref(); + client.start(); + }); +} + +async function getFreeGatewayPort(): Promise { + return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 3, 4] }); +} + +describe("provider timeouts (e2e)", () => { + it( + "falls back when the primary provider aborts with a timeout-like AbortError", + { timeout: 60_000 }, + async () => { + const prev = { + home: process.env.HOME, + configPath: process.env.OPENCLAW_CONFIG_PATH, + token: process.env.OPENCLAW_GATEWAY_TOKEN, + skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, + skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, + skipCron: process.env.OPENCLAW_SKIP_CRON, + skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, + }; + + const originalFetch = globalThis.fetch; + const primaryBaseUrl = "https://primary.example/v1"; + const fallbackBaseUrl = "https://fallback.example/v1"; + const counts = { primary: 0, fallback: 0 }; + const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + + if (url.startsWith(`${primaryBaseUrl}/responses`)) { + counts.primary += 1; + const err = new Error("request was aborted"); + err.name = "AbortError"; + throw err; + } + + if (url.startsWith(`${fallbackBaseUrl}/responses`)) { + counts.fallback += 1; + return buildOpenAIResponsesSse("fallback-ok"); + } + + if (!originalFetch) { + throw new Error(`fetch is not available (url=${url})`); + } + return await originalFetch(input, init); + }; + (globalThis as unknown as { fetch: unknown }).fetch = fetchImpl; + + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-timeout-e2e-")); + process.env.HOME = tempHome; + process.env.OPENCLAW_SKIP_CHANNELS = "1"; + process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; + process.env.OPENCLAW_SKIP_CRON = "1"; + process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; + + const token = `test-${randomUUID()}`; + process.env.OPENCLAW_GATEWAY_TOKEN = token; + + const configDir = path.join(tempHome, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + const configPath = path.join(configDir, "openclaw.json"); + + const cfg = { + agents: { + defaults: { + model: { + primary: "primary/gpt-5.2", + fallbacks: ["fallback/gpt-5.2"], + }, + }, + }, + models: { + mode: "replace", + providers: { + primary: { + baseUrl: primaryBaseUrl, + apiKey: "test", + api: "openai-responses", + models: [ + { + id: "gpt-5.2", + name: "gpt-5.2", + api: "openai-responses", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4096, + }, + ], + }, + fallback: { + baseUrl: fallbackBaseUrl, + apiKey: "test", + api: "openai-responses", + models: [ + { + id: "gpt-5.2", + name: "gpt-5.2", + api: "openai-responses", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4096, + }, + ], + }, + }, + }, + gateway: { auth: { token } }, + }; + + await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`); + process.env.OPENCLAW_CONFIG_PATH = configPath; + + const port = await getFreeGatewayPort(); + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "token", token }, + controlUiEnabled: false, + }); + + const client = await connectClient({ + url: `ws://127.0.0.1:${port}`, + token, + }); + + try { + const sessionKey = "agent:dev:timeout-fallback"; + await client.request("sessions.patch", { + key: sessionKey, + model: "primary/gpt-5.2", + }); + + const runId = randomUUID(); + const payload = await client.request<{ + status?: unknown; + result?: unknown; + }>( + "agent", + { + sessionKey, + idempotencyKey: `idem-${runId}`, + message: "say fallback-ok", + deliver: false, + }, + { expectFinal: true }, + ); + + expect(payload?.status).toBe("ok"); + const text = extractPayloadText(payload?.result); + expect(text).toContain("fallback-ok"); + expect(counts.primary).toBeGreaterThan(0); + expect(counts.fallback).toBeGreaterThan(0); + } finally { + client.stop(); + await server.close({ reason: "timeout fallback test complete" }); + await fs.rm(tempHome, { recursive: true, force: true }); + (globalThis as unknown as { fetch: unknown }).fetch = originalFetch; + if (prev.home === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = prev.home; + } + if (prev.configPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = prev.configPath; + } + if (prev.token === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prev.token; + } + if (prev.skipChannels === undefined) { + delete process.env.OPENCLAW_SKIP_CHANNELS; + } else { + process.env.OPENCLAW_SKIP_CHANNELS = prev.skipChannels; + } + if (prev.skipGmail === undefined) { + delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER; + } else { + process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prev.skipGmail; + } + if (prev.skipCron === undefined) { + delete process.env.OPENCLAW_SKIP_CRON; + } else { + process.env.OPENCLAW_SKIP_CRON = prev.skipCron; + } + if (prev.skipCanvas === undefined) { + delete process.env.OPENCLAW_SKIP_CANVAS_HOST; + } else { + process.env.OPENCLAW_SKIP_CANVAS_HOST = prev.skipCanvas; + } + } + }, + ); +}); diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..215935b930a395b29ce8e6b501e7da953865ef2d --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,166 @@ +import { afterAll, afterEach, beforeEach, vi } from "vitest"; + +// Ensure Vitest environment is properly set +process.env.VITEST = "true"; + +import type { + ChannelId, + ChannelOutboundAdapter, + ChannelPlugin, +} from "../src/channels/plugins/types.js"; +import type { OpenClawConfig } from "../src/config/config.js"; +import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js"; +import { installProcessWarningFilter } from "../src/infra/warnings.js"; +import { setActivePluginRegistry } from "../src/plugins/runtime.js"; +import { createTestRegistry } from "../src/test-utils/channel-plugins.js"; +import { withIsolatedTestHome } from "./test-env"; + +installProcessWarningFilter(); + +const testEnv = withIsolatedTestHome(); +afterAll(() => testEnv.cleanup()); +const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { + switch (id) { + case "discord": + return deps?.sendDiscord; + case "slack": + return deps?.sendSlack; + case "telegram": + return deps?.sendTelegram; + case "whatsapp": + return deps?.sendWhatsApp; + case "signal": + return deps?.sendSignal; + case "imessage": + return deps?.sendIMessage; + default: + return undefined; + } +}; + +const createStubOutbound = ( + id: ChannelId, + deliveryMode: ChannelOutboundAdapter["deliveryMode"] = "direct", +): ChannelOutboundAdapter => ({ + deliveryMode, + sendText: async ({ deps, to, text }) => { + const send = pickSendFn(id, deps); + if (send) { + const result = await send(to, text, {}); + return { channel: id, ...result }; + } + return { channel: id, messageId: "test" }; + }, + sendMedia: async ({ deps, to, text, mediaUrl }) => { + const send = pickSendFn(id, deps); + if (send) { + const result = await send(to, text, { mediaUrl }); + return { channel: id, ...result }; + } + return { channel: id, messageId: "test" }; + }, +}); + +const createStubPlugin = (params: { + id: ChannelId; + label?: string; + aliases?: string[]; + deliveryMode?: ChannelOutboundAdapter["deliveryMode"]; + preferSessionLookupForAnnounceTarget?: boolean; +}): ChannelPlugin => ({ + id: params.id, + meta: { + id: params.id, + label: params.label ?? String(params.id), + selectionLabel: params.label ?? String(params.id), + docsPath: `/channels/${params.id}`, + blurb: "test stub.", + aliases: params.aliases, + preferSessionLookupForAnnounceTarget: params.preferSessionLookupForAnnounceTarget, + }, + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: (cfg: OpenClawConfig) => { + const channels = cfg.channels as Record | undefined; + const entry = channels?.[params.id]; + if (!entry || typeof entry !== "object") { + return []; + } + const accounts = (entry as { accounts?: Record }).accounts; + const ids = accounts ? Object.keys(accounts).filter(Boolean) : []; + return ids.length > 0 ? ids : ["default"]; + }, + resolveAccount: (cfg: OpenClawConfig, accountId: string) => { + const channels = cfg.channels as Record | undefined; + const entry = channels?.[params.id]; + if (!entry || typeof entry !== "object") { + return {}; + } + const accounts = (entry as { accounts?: Record }).accounts; + const match = accounts?.[accountId]; + return (match && typeof match === "object") || typeof match === "string" ? match : entry; + }, + isConfigured: async (_account, cfg: OpenClawConfig) => { + const channels = cfg.channels as Record | undefined; + return Boolean(channels?.[params.id]); + }, + }, + outbound: createStubOutbound(params.id, params.deliveryMode), +}); + +const createDefaultRegistry = () => + createTestRegistry([ + { + pluginId: "discord", + plugin: createStubPlugin({ id: "discord", label: "Discord" }), + source: "test", + }, + { + pluginId: "slack", + plugin: createStubPlugin({ id: "slack", label: "Slack" }), + source: "test", + }, + { + pluginId: "telegram", + plugin: { + ...createStubPlugin({ id: "telegram", label: "Telegram" }), + status: { + buildChannelSummary: async () => ({ + configured: false, + tokenSource: process.env.TELEGRAM_BOT_TOKEN ? "env" : "none", + }), + }, + }, + source: "test", + }, + { + pluginId: "whatsapp", + plugin: createStubPlugin({ + id: "whatsapp", + label: "WhatsApp", + deliveryMode: "gateway", + preferSessionLookupForAnnounceTarget: true, + }), + source: "test", + }, + { + pluginId: "signal", + plugin: createStubPlugin({ id: "signal", label: "Signal" }), + source: "test", + }, + { + pluginId: "imessage", + plugin: createStubPlugin({ id: "imessage", label: "iMessage", aliases: ["imsg"] }), + source: "test", + }, + ]); + +beforeEach(() => { + setActivePluginRegistry(createDefaultRegistry()); +}); + +afterEach(() => { + setActivePluginRegistry(createDefaultRegistry()); + // Guard against leaked fake timers across test files/workers. + vi.useRealTimers(); +}); diff --git a/test/test-env.ts b/test/test-env.ts new file mode 100644 index 0000000000000000000000000000000000000000..a450689834edac5b3128d3958be5cf3561f4900f --- /dev/null +++ b/test/test-env.ts @@ -0,0 +1,147 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +type RestoreEntry = { key: string; value: string | undefined }; + +function restoreEnv(entries: RestoreEntry[]): void { + for (const { key, value } of entries) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function loadProfileEnv(): void { + const profilePath = path.join(os.homedir(), ".profile"); + if (!fs.existsSync(profilePath)) { + return; + } + try { + const output = execFileSync( + "/bin/bash", + ["-lc", `set -a; source "${profilePath}" >/dev/null 2>&1; env -0`], + { encoding: "utf8" }, + ); + const entries = output.split("\0"); + let applied = 0; + for (const entry of entries) { + if (!entry) { + continue; + } + const idx = entry.indexOf("="); + if (idx <= 0) { + continue; + } + const key = entry.slice(0, idx); + if (!key || (process.env[key] ?? "") !== "") { + continue; + } + process.env[key] = entry.slice(idx + 1); + applied += 1; + } + if (applied > 0) { + console.log(`[live] loaded ${applied} env vars from ~/.profile`); + } + } catch { + // ignore profile load failures + } +} + +export function installTestEnv(): { cleanup: () => void; tempHome: string } { + const live = + process.env.LIVE === "1" || + process.env.OPENCLAW_LIVE_TEST === "1" || + process.env.OPENCLAW_LIVE_GATEWAY === "1"; + + // Live tests must use the real user environment (keys, profiles, config). + // The default test env isolates HOME to avoid touching real state. + if (live) { + loadProfileEnv(); + return { cleanup: () => {}, tempHome: process.env.HOME ?? "" }; + } + + const restore: RestoreEntry[] = [ + { key: "OPENCLAW_TEST_FAST", value: process.env.OPENCLAW_TEST_FAST }, + { key: "HOME", value: process.env.HOME }, + { key: "USERPROFILE", value: process.env.USERPROFILE }, + { key: "XDG_CONFIG_HOME", value: process.env.XDG_CONFIG_HOME }, + { key: "XDG_DATA_HOME", value: process.env.XDG_DATA_HOME }, + { key: "XDG_STATE_HOME", value: process.env.XDG_STATE_HOME }, + { key: "XDG_CACHE_HOME", value: process.env.XDG_CACHE_HOME }, + { key: "OPENCLAW_STATE_DIR", value: process.env.OPENCLAW_STATE_DIR }, + { key: "OPENCLAW_CONFIG_PATH", value: process.env.OPENCLAW_CONFIG_PATH }, + { key: "OPENCLAW_GATEWAY_PORT", value: process.env.OPENCLAW_GATEWAY_PORT }, + { key: "OPENCLAW_BRIDGE_ENABLED", value: process.env.OPENCLAW_BRIDGE_ENABLED }, + { key: "OPENCLAW_BRIDGE_HOST", value: process.env.OPENCLAW_BRIDGE_HOST }, + { key: "OPENCLAW_BRIDGE_PORT", value: process.env.OPENCLAW_BRIDGE_PORT }, + { key: "OPENCLAW_CANVAS_HOST_PORT", value: process.env.OPENCLAW_CANVAS_HOST_PORT }, + { key: "OPENCLAW_TEST_HOME", value: process.env.OPENCLAW_TEST_HOME }, + { key: "TELEGRAM_BOT_TOKEN", value: process.env.TELEGRAM_BOT_TOKEN }, + { key: "DISCORD_BOT_TOKEN", value: process.env.DISCORD_BOT_TOKEN }, + { key: "SLACK_BOT_TOKEN", value: process.env.SLACK_BOT_TOKEN }, + { key: "SLACK_APP_TOKEN", value: process.env.SLACK_APP_TOKEN }, + { key: "SLACK_USER_TOKEN", value: process.env.SLACK_USER_TOKEN }, + { key: "COPILOT_GITHUB_TOKEN", value: process.env.COPILOT_GITHUB_TOKEN }, + { key: "GH_TOKEN", value: process.env.GH_TOKEN }, + { key: "GITHUB_TOKEN", value: process.env.GITHUB_TOKEN }, + { key: "NODE_OPTIONS", value: process.env.NODE_OPTIONS }, + ]; + + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-home-")); + + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + process.env.OPENCLAW_TEST_HOME = tempHome; + process.env.OPENCLAW_TEST_FAST = "1"; + + // Ensure test runs never touch the developer's real config/state, even if they have overrides set. + delete process.env.OPENCLAW_CONFIG_PATH; + // Prefer deriving state dir from HOME so nested tests that change HOME also isolate correctly. + delete process.env.OPENCLAW_STATE_DIR; + // Prefer test-controlled ports over developer overrides (avoid port collisions across tests/workers). + delete process.env.OPENCLAW_GATEWAY_PORT; + delete process.env.OPENCLAW_BRIDGE_ENABLED; + delete process.env.OPENCLAW_BRIDGE_HOST; + delete process.env.OPENCLAW_BRIDGE_PORT; + delete process.env.OPENCLAW_CANVAS_HOST_PORT; + // Avoid leaking real GitHub/Copilot tokens into non-live test runs. + delete process.env.TELEGRAM_BOT_TOKEN; + delete process.env.DISCORD_BOT_TOKEN; + delete process.env.SLACK_BOT_TOKEN; + delete process.env.SLACK_APP_TOKEN; + delete process.env.SLACK_USER_TOKEN; + delete process.env.COPILOT_GITHUB_TOKEN; + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + // Avoid leaking local dev tooling flags into tests (e.g. --inspect). + delete process.env.NODE_OPTIONS; + + // Windows: prefer the default state dir so auth/profile tests match real paths. + if (process.platform === "win32") { + process.env.OPENCLAW_STATE_DIR = path.join(tempHome, ".openclaw"); + } + + process.env.XDG_CONFIG_HOME = path.join(tempHome, ".config"); + process.env.XDG_DATA_HOME = path.join(tempHome, ".local", "share"); + process.env.XDG_STATE_HOME = path.join(tempHome, ".local", "state"); + process.env.XDG_CACHE_HOME = path.join(tempHome, ".cache"); + + const cleanup = () => { + restoreEnv(restore); + try { + fs.rmSync(tempHome, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }; + + return { cleanup, tempHome }; +} + +export function withIsolatedTestHome(): { cleanup: () => void; tempHome: string } { + return installTestEnv(); +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000000000000000000000000000000000000..dc03f49115c3d70eac12d174f9e697557834dfd5 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,16 @@ + + + + + + OpenClaw Control + + + + + + + + + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000000000000000000000000000000000000..dbf223ffcea0f24c481bc3910a6f8b3130dbc393 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,23 @@ +{ + "name": "openclaw-control-ui", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", + "test": "vitest run --config vitest.config.ts" + }, + "dependencies": { + "@noble/ed25519": "3.0.0", + "dompurify": "^3.3.1", + "lit": "^3.3.2", + "marked": "^17.0.1", + "vite": "7.3.1" + }, + "devDependencies": { + "@vitest/browser-playwright": "4.0.18", + "playwright": "^1.58.1", + "vitest": "4.0.18" + } +} diff --git a/ui/public/apple-touch-icon.png b/ui/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..71781843f857e274449a0e7a2b151c06d92b5e4f Binary files /dev/null and b/ui/public/apple-touch-icon.png differ diff --git a/ui/public/favicon-32.png b/ui/public/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..563c79b0e6bfa77798f7b508ab6ed290c84b7363 Binary files /dev/null and b/ui/public/favicon-32.png differ diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ec5665f56e51d73d80a80b96dc2158eaf670f191 Binary files /dev/null and b/ui/public/favicon.ico differ diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..bcbc1e10cb40c29c98b8f867e0f312077a70d1ba --- /dev/null +++ b/ui/public/favicon.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000000000000000000000000000000000000..9374bb20ec43fa21d94be46dba5d48ea791029e9 --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,2 @@ +import "./styles.css"; +import "./ui/app.ts"; diff --git a/ui/src/styles.css b/ui/src/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..16b327f3a731e51a2c0bdbef25293dc8dbba9b47 --- /dev/null +++ b/ui/src/styles.css @@ -0,0 +1,5 @@ +@import "./styles/base.css"; +@import "./styles/layout.css"; +@import "./styles/layout.mobile.css"; +@import "./styles/components.css"; +@import "./styles/config.css"; diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css new file mode 100644 index 0000000000000000000000000000000000000000..b83afd32c50df738646296eb73fee31addc07a17 --- /dev/null +++ b/ui/src/styles/base.css @@ -0,0 +1,388 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"); + +:root { + /* Background - Warmer dark with depth */ + --bg: #12141a; + --bg-accent: #14161d; + --bg-elevated: #1a1d25; + --bg-hover: #262a35; + --bg-muted: #262a35; + + /* Card / Surface - More contrast between levels */ + --card: #181b22; + --card-foreground: #f4f4f5; + --card-highlight: rgba(255, 255, 255, 0.05); + --popover: #181b22; + --popover-foreground: #f4f4f5; + + /* Panel */ + --panel: #12141a; + --panel-strong: #1a1d25; + --panel-hover: #262a35; + --chrome: rgba(18, 20, 26, 0.95); + --chrome-strong: rgba(18, 20, 26, 0.98); + + /* Text - Slightly warmer */ + --text: #e4e4e7; + --text-strong: #fafafa; + --chat-text: #e4e4e7; + --muted: #71717a; + --muted-strong: #52525b; + --muted-foreground: #71717a; + + /* Border - Subtle but defined */ + --border: #27272a; + --border-strong: #3f3f46; + --border-hover: #52525b; + --input: #27272a; + --ring: #ff5c5c; + + /* Accent - Punchy signature red */ + --accent: #ff5c5c; + --accent-hover: #ff7070; + --accent-muted: #ff5c5c; + --accent-subtle: rgba(255, 92, 92, 0.15); + --accent-foreground: #fafafa; + --accent-glow: rgba(255, 92, 92, 0.25); + --primary: #ff5c5c; + --primary-foreground: #ffffff; + + /* Secondary - Teal accent for variety */ + --secondary: #1e2028; + --secondary-foreground: #f4f4f5; + --accent-2: #14b8a6; + --accent-2-muted: rgba(20, 184, 166, 0.7); + --accent-2-subtle: rgba(20, 184, 166, 0.15); + + /* Semantic - More saturated */ + --ok: #22c55e; + --ok-muted: rgba(34, 197, 94, 0.75); + --ok-subtle: rgba(34, 197, 94, 0.12); + --destructive: #ef4444; + --destructive-foreground: #fafafa; + --warn: #f59e0b; + --warn-muted: rgba(245, 158, 11, 0.75); + --warn-subtle: rgba(245, 158, 11, 0.12); + --danger: #ef4444; + --danger-muted: rgba(239, 68, 68, 0.75); + --danger-subtle: rgba(239, 68, 68, 0.12); + --info: #3b82f6; + + /* Focus - With glow */ + --focus: rgba(255, 92, 92, 0.25); + --focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring); + --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow); + + /* Grid */ + --grid-line: rgba(255, 255, 255, 0.04); + + /* Theme transition */ + --theme-switch-x: 50%; + --theme-switch-y: 50%; + + /* Typography - Space Grotesk for personality */ + --mono: + "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; + --font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-display: + "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Shadows - Richer with subtle color */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03); + --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03); + --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03); + --shadow-glow: 0 0 30px var(--accent-glow); + + /* Radii - Slightly larger for friendlier feel */ + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-full: 9999px; + --radius: 8px; + + /* Transitions - Snappy but smooth */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --duration-fast: 120ms; + --duration-normal: 200ms; + --duration-slow: 350ms; + + color-scheme: dark; +} + +/* Light theme - Clean with subtle warmth */ +:root[data-theme="light"] { + --bg: #fafafa; + --bg-accent: #f5f5f5; + --bg-elevated: #ffffff; + --bg-hover: #f0f0f0; + --bg-muted: #f0f0f0; + --bg-content: #f5f5f5; + + --card: #ffffff; + --card-foreground: #18181b; + --card-highlight: rgba(0, 0, 0, 0.03); + --popover: #ffffff; + --popover-foreground: #18181b; + + --panel: #fafafa; + --panel-strong: #f5f5f5; + --panel-hover: #ebebeb; + --chrome: rgba(250, 250, 250, 0.95); + --chrome-strong: rgba(250, 250, 250, 0.98); + + --text: #3f3f46; + --text-strong: #18181b; + --chat-text: #3f3f46; + --muted: #71717a; + --muted-strong: #52525b; + --muted-foreground: #71717a; + + --border: #e4e4e7; + --border-strong: #d4d4d8; + --border-hover: #a1a1aa; + --input: #e4e4e7; + + --accent: #dc2626; + --accent-hover: #ef4444; + --accent-muted: #dc2626; + --accent-subtle: rgba(220, 38, 38, 0.12); + --accent-foreground: #ffffff; + --accent-glow: rgba(220, 38, 38, 0.15); + --primary: #dc2626; + --primary-foreground: #ffffff; + + --secondary: #f4f4f5; + --secondary-foreground: #3f3f46; + --accent-2: #0d9488; + --accent-2-muted: rgba(13, 148, 136, 0.75); + --accent-2-subtle: rgba(13, 148, 136, 0.12); + + --ok: #16a34a; + --ok-muted: rgba(22, 163, 74, 0.75); + --ok-subtle: rgba(22, 163, 74, 0.1); + --destructive: #dc2626; + --destructive-foreground: #fafafa; + --warn: #d97706; + --warn-muted: rgba(217, 119, 6, 0.75); + --warn-subtle: rgba(217, 119, 6, 0.1); + --danger: #dc2626; + --danger-muted: rgba(220, 38, 38, 0.75); + --danger-subtle: rgba(220, 38, 38, 0.1); + --info: #2563eb; + + --focus: rgba(220, 38, 38, 0.2); + --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow); + + --grid-line: rgba(0, 0, 0, 0.05); + + /* Light shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04); + --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04); + --shadow-glow: 0 0 24px var(--accent-glow); + + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + font: 400 14px/1.55 var(--font-body); + letter-spacing: -0.02em; + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Theme transition */ +@keyframes theme-circle-transition { + 0% { + clip-path: circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%)); + } + 100% { + clip-path: circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%)); + } +} + +html.theme-transition { + view-transition-name: theme; +} + +html.theme-transition::view-transition-old(theme) { + mix-blend-mode: normal; + animation: none; + z-index: 1; +} + +html.theme-transition::view-transition-new(theme) { + mix-blend-mode: normal; + z-index: 2; + animation: theme-circle-transition 0.4s var(--ease-out) forwards; +} + +@media (prefers-reduced-motion: reduce) { + html.theme-transition::view-transition-old(theme), + html.theme-transition::view-transition-new(theme) { + animation: none !important; + } +} + +openclaw-app { + display: block; + position: relative; + z-index: 1; + min-height: 100vh; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +button, +input, +textarea, +select { + font: inherit; + color: inherit; +} + +::selection { + background: var(--accent-subtle); + color: var(--text-strong); +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: var(--radius-full); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border-strong); +} + +/* Animations - Polished with spring feel */ +@keyframes rise { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes scale-in { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes dashboard-enter { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +@keyframes pulse-subtle { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +@keyframes glow-pulse { + 0%, + 100% { + box-shadow: 0 0 0 rgba(255, 92, 92, 0); + } + 50% { + box-shadow: 0 0 20px var(--accent-glow); + } +} + +/* Stagger animation delays for grouped elements */ +.stagger-1 { + animation-delay: 0ms; +} +.stagger-2 { + animation-delay: 50ms; +} +.stagger-3 { + animation-delay: 100ms; +} +.stagger-4 { + animation-delay: 150ms; +} +.stagger-5 { + animation-delay: 200ms; +} +.stagger-6 { + animation-delay: 250ms; +} + +/* Focus visible styles */ +:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} diff --git a/ui/src/styles/chat.css b/ui/src/styles/chat.css new file mode 100644 index 0000000000000000000000000000000000000000..07d3b644a63f7e4af7eba2f5b064a6bb221f4422 --- /dev/null +++ b/ui/src/styles/chat.css @@ -0,0 +1,5 @@ +@import "./chat/layout.css"; +@import "./chat/text.css"; +@import "./chat/grouped.css"; +@import "./chat/tool-cards.css"; +@import "./chat/sidebar.css"; diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css new file mode 100644 index 0000000000000000000000000000000000000000..e91989dfc15d363d342250f16be311932d2cc3ee --- /dev/null +++ b/ui/src/styles/chat/grouped.css @@ -0,0 +1,273 @@ +/* ============================================= + GROUPED CHAT LAYOUT (Slack-style) + ============================================= */ + +/* Chat Group Layout - default (assistant/other on left) */ +.chat-group { + display: flex; + gap: 12px; + align-items: flex-start; + margin-bottom: 16px; + margin-left: 4px; + margin-right: 16px; +} + +/* User messages on right */ +.chat-group.user { + flex-direction: row-reverse; + justify-content: flex-start; +} + +.chat-group-messages { + display: flex; + flex-direction: column; + gap: 2px; + max-width: min(900px, calc(100% - 60px)); +} + +/* User messages align content right */ +.chat-group.user .chat-group-messages { + align-items: flex-end; +} + +.chat-group.user .chat-group-footer { + justify-content: flex-end; +} + +/* Footer at bottom of message group (role + time) */ +.chat-group-footer { + display: flex; + gap: 8px; + align-items: baseline; + margin-top: 6px; +} + +.chat-sender-name { + font-weight: 500; + font-size: 12px; + color: var(--muted); +} + +.chat-group-timestamp { + font-size: 11px; + color: var(--muted); + opacity: 0.7; +} + +/* Avatar Styles */ +.chat-avatar { + width: 40px; + height: 40px; + border-radius: 8px; + background: var(--panel-strong); + display: grid; + place-items: center; + font-weight: 600; + font-size: 14px; + flex-shrink: 0; + align-self: flex-end; /* Align with last message in group */ + margin-bottom: 4px; /* Optical alignment */ +} + +.chat-avatar.user { + background: var(--accent-subtle); + color: var(--accent); +} + +.chat-avatar.assistant { + background: var(--secondary); + color: var(--muted); +} + +.chat-avatar.other { + background: var(--secondary); + color: var(--muted); +} + +.chat-avatar.tool { + background: var(--secondary); + color: var(--muted); +} + +/* Image avatar support */ +img.chat-avatar { + display: block; + object-fit: cover; + object-position: center; +} + +/* Minimal Bubble Design - dynamic width based on content */ +.chat-bubble { + position: relative; + display: inline-block; + border: 1px solid transparent; + background: var(--card); + border-radius: var(--radius-lg); + padding: 10px 14px; + box-shadow: none; + transition: + background 150ms ease-out, + border-color 150ms ease-out; + max-width: 100%; + word-wrap: break-word; +} + +.chat-bubble.has-copy { + padding-right: 36px; +} + +.chat-copy-btn { + position: absolute; + top: 6px; + right: 8px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--muted); + border-radius: var(--radius-md); + padding: 4px 6px; + font-size: 14px; + line-height: 1; + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: + opacity 120ms ease-out, + background 120ms ease-out; +} + +.chat-copy-btn__icon { + display: inline-flex; + width: 14px; + height: 14px; + position: relative; +} + +.chat-copy-btn__icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-copy-btn__icon-copy, +.chat-copy-btn__icon-check { + position: absolute; + top: 0; + left: 0; + transition: opacity 150ms ease; +} + +.chat-copy-btn__icon-check { + opacity: 0; +} + +.chat-copy-btn[data-copied="1"] .chat-copy-btn__icon-copy { + opacity: 0; +} + +.chat-copy-btn[data-copied="1"] .chat-copy-btn__icon-check { + opacity: 1; +} + +.chat-bubble:hover .chat-copy-btn { + opacity: 1; + pointer-events: auto; +} + +.chat-copy-btn:hover { + background: var(--bg-hover); +} + +.chat-copy-btn[data-copying="1"] { + opacity: 0; + pointer-events: none; +} + +.chat-copy-btn[data-error="1"] { + opacity: 1; + pointer-events: auto; + border-color: var(--danger-subtle); + background: var(--danger-subtle); + color: var(--danger); +} + +.chat-copy-btn[data-copied="1"] { + opacity: 1; + pointer-events: auto; + border-color: var(--ok-subtle); + background: var(--ok-subtle); + color: var(--ok); +} + +.chat-copy-btn:focus-visible { + opacity: 1; + pointer-events: auto; + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +@media (hover: none) { + .chat-copy-btn { + opacity: 1; + pointer-events: auto; + } +} + +/* Light mode: restore borders */ +:root[data-theme="light"] .chat-bubble { + border-color: var(--border); + box-shadow: inset 0 1px 0 var(--card-highlight); +} + +.chat-bubble:hover { + background: var(--bg-hover); +} + +/* User bubbles have different styling */ +.chat-group.user .chat-bubble { + background: var(--accent-subtle); + border-color: transparent; +} + +:root[data-theme="light"] .chat-group.user .chat-bubble { + border-color: rgba(234, 88, 12, 0.2); + background: rgba(251, 146, 60, 0.12); +} + +.chat-group.user .chat-bubble:hover { + background: rgba(255, 77, 77, 0.15); +} + +/* Streaming animation */ +.chat-bubble.streaming { + animation: pulsing-border 1.5s ease-out infinite; +} + +@keyframes pulsing-border { + 0%, + 100% { + border-color: var(--border); + } + 50% { + border-color: var(--accent); + } +} + +/* Fade-in animation for new messages */ +.chat-bubble.fade-in { + animation: fade-in 200ms ease-out; +} + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css new file mode 100644 index 0000000000000000000000000000000000000000..62aede8ff39310695e8658c7bdfabdd53e687fb5 --- /dev/null +++ b/ui/src/styles/chat/layout.css @@ -0,0 +1,404 @@ +/* ============================================= + CHAT CARD LAYOUT - Flex container with sticky compose + ============================================= */ + +/* Main chat card - flex column layout, transparent background */ +.chat { + position: relative; + display: flex; + flex-direction: column; + flex: 1 1 0; + height: 100%; + min-height: 0; /* Allow flex shrinking */ + overflow: hidden; + background: transparent !important; + border: none !important; + box-shadow: none !important; +} + +/* Chat header - fixed at top, transparent */ +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: nowrap; + flex-shrink: 0; + padding-bottom: 12px; + margin-bottom: 12px; + background: transparent; +} + +.chat-header__left { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + min-width: 0; +} + +.chat-header__right { + display: flex; + align-items: center; + gap: 8px; +} + +.chat-session { + min-width: 180px; +} + +/* Chat thread - scrollable middle section, transparent */ +.chat-thread { + flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */ + overflow-y: auto; + overflow-x: hidden; + padding: 12px 4px; + margin: 0 -4px; + min-height: 0; /* Allow shrinking for flex scroll behavior */ + border-radius: 12px; + background: transparent; +} + +/* Focus mode exit button */ +.chat-focus-exit { + position: absolute; + top: 12px; + right: 12px; + z-index: 100; + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--panel); + color: var(--muted); + font-size: 20px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: + background 150ms ease-out, + color 150ms ease-out, + border-color 150ms ease-out; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.chat-focus-exit:hover { + background: var(--panel-strong); + color: var(--text); + border-color: var(--accent); +} + +.chat-focus-exit svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Chat compose - sticky at bottom */ +.chat-compose { + position: sticky; + bottom: 0; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 12px; + margin-top: auto; /* Push to bottom of flex container */ + padding: 12px 4px 4px; + background: linear-gradient(to bottom, transparent, var(--bg) 20%); + z-index: 10; +} + +/* Image attachments preview */ +.chat-attachments { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px; + background: var(--panel); + border-radius: 8px; + border: 1px solid var(--border); + width: fit-content; + max-width: 100%; + align-self: flex-start; /* Don't stretch in flex column parent */ +} + +.chat-attachment { + position: relative; + width: 80px; + height: 80px; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border); + background: var(--bg); +} + +.chat-attachment__img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.chat-attachment__remove { + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.7); + color: #fff; + font-size: 12px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 150ms ease-out; +} + +.chat-attachment:hover .chat-attachment__remove { + opacity: 1; +} + +.chat-attachment__remove:hover { + background: rgba(220, 38, 38, 0.9); +} + +.chat-attachment__remove svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 2px; +} + +/* Light theme attachment overrides */ +:root[data-theme="light"] .chat-attachments { + background: #f8fafc; + border-color: rgba(16, 24, 40, 0.1); +} + +:root[data-theme="light"] .chat-attachment { + border-color: rgba(16, 24, 40, 0.15); + background: #fff; +} + +:root[data-theme="light"] .chat-attachment__remove { + background: rgba(0, 0, 0, 0.6); +} + +/* Message images (sent images displayed in chat) */ +.chat-message-images { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} + +.chat-message-image { + max-width: 300px; + max-height: 200px; + border-radius: 8px; + object-fit: contain; + cursor: pointer; + transition: transform 150ms ease-out; +} + +.chat-message-image:hover { + transform: scale(1.02); +} + +/* User message images align right */ +.chat-group.user .chat-message-images { + justify-content: flex-end; +} + +/* Compose input row - horizontal layout */ +.chat-compose__row { + display: flex; + align-items: stretch; + gap: 12px; + flex: 1; +} + +:root[data-theme="light"] .chat-compose { + background: linear-gradient(to bottom, transparent, var(--bg-content) 20%); +} + +.chat-compose__field { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: stretch; +} + +/* Hide the "Message" label - keep textarea only */ +.chat-compose__field > span { + display: none; +} + +/* Override .field textarea min-height (180px) from components.css */ +.chat-compose .chat-compose__field textarea { + width: 100%; + height: 40px; + min-height: 40px; + max-height: 150px; + padding: 9px 12px; + border-radius: 8px; + overflow-y: auto; + resize: none; + white-space: pre-wrap; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; +} + +.chat-compose__field textarea:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.chat-compose__actions { + flex-shrink: 0; + display: flex; + align-items: stretch; + gap: 8px; +} + +.chat-compose .chat-compose__actions .btn { + padding: 0 16px; + font-size: 13px; + height: 40px; + min-height: 40px; + max-height: 40px; + line-height: 1; + white-space: nowrap; + box-sizing: border-box; +} + +/* Chat controls - moved to content-header area, left aligned */ +.chat-controls { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + flex-wrap: wrap; +} + +.chat-controls__session { + min-width: 140px; + max-width: 420px; +} + +.chat-controls__thinking { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +/* Icon button style */ +.btn--icon { + padding: 8px !important; + min-width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.06); +} + +/* Controls separator */ +.chat-controls__separator { + color: rgba(255, 255, 255, 0.4); + font-size: 18px; + margin: 0 8px; + font-weight: 300; +} + +:root[data-theme="light"] .chat-controls__separator { + color: rgba(16, 24, 40, 0.3); +} + +.btn--icon:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); +} + +/* Light theme icon button overrides */ +:root[data-theme="light"] .btn--icon { + background: #ffffff; + border-color: var(--border); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); + color: var(--muted); +} + +:root[data-theme="light"] .btn--icon:hover { + background: #ffffff; + border-color: var(--border-strong); + color: var(--text); +} + +.btn--icon svg { + display: block; + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-controls__session select { + padding: 6px 10px; + font-size: 13px; + max-width: 420px; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-controls__thinking { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + padding: 4px 10px; + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; + border: 1px solid var(--border); +} + +/* Light theme thinking indicator override */ +:root[data-theme="light"] .chat-controls__thinking { + background: rgba(255, 255, 255, 0.9); + border-color: rgba(16, 24, 40, 0.15); +} + +@media (max-width: 640px) { + .chat-session { + min-width: 140px; + } + + .chat-compose { + grid-template-columns: 1fr; + } + + .chat-controls { + flex-wrap: wrap; + gap: 8px; + } + + .chat-controls__session { + min-width: 120px; + } +} diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css new file mode 100644 index 0000000000000000000000000000000000000000..934e285d95b1e5ef89cef5bad8d6400f3002d954 --- /dev/null +++ b/ui/src/styles/chat/sidebar.css @@ -0,0 +1,117 @@ +/* Split View Layout */ +.chat-split-container { + display: flex; + gap: 0; + flex: 1; + min-height: 0; + height: 100%; +} + +.chat-main { + min-width: 400px; + display: flex; + flex-direction: column; + overflow: hidden; + /* Smooth transition when sidebar opens/closes */ + transition: flex 250ms ease-out; +} + +.chat-sidebar { + flex: 1; + min-width: 300px; + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; + animation: slide-in 200ms ease-out; +} + +@keyframes slide-in { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Sidebar Panel */ +.sidebar-panel { + display: flex; + flex-direction: column; + height: 100%; + background: var(--panel); +} + +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 10; + background: var(--panel); +} + +/* Smaller close button for sidebar */ +.sidebar-header .btn { + padding: 4px 8px; + font-size: 14px; + min-width: auto; + line-height: 1; +} + +.sidebar-title { + font-weight: 600; + font-size: 14px; +} + +.sidebar-content { + flex: 1; + overflow: auto; + padding: 16px; +} + +.sidebar-markdown { + font-size: 14px; + line-height: 1.5; +} + +.sidebar-markdown pre { + background: rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 12px; + overflow-x: auto; +} + +.sidebar-markdown code { + font-family: var(--mono); + font-size: 13px; +} + +/* Mobile: Full-screen modal */ +@media (max-width: 768px) { + .chat-split-container--open { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + } + + .chat-split-container--open .chat-main { + display: none; /* Hide chat on mobile when sidebar open */ + } + + .chat-split-container--open .chat-sidebar { + width: 100%; + min-width: 0; + border-left: none; + } +} diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css new file mode 100644 index 0000000000000000000000000000000000000000..13e245de2518b6d1915f725d1815b43ab333518f --- /dev/null +++ b/ui/src/styles/chat/text.css @@ -0,0 +1,124 @@ +/* ============================================= + CHAT TEXT STYLING + ============================================= */ + +.chat-thinking { + margin-bottom: 10px; + padding: 10px 12px; + border-radius: 10px; + border: 1px dashed rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.04); + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +:root[data-theme="light"] .chat-thinking { + border-color: rgba(16, 24, 40, 0.25); + background: rgba(16, 24, 40, 0.04); +} + +.chat-text { + font-size: 14px; + line-height: 1.5; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.chat-text :where(p, ul, ol, pre, blockquote, table) { + margin: 0; +} + +.chat-text :where(p + p, p + ul, p + ol, p + pre, p + blockquote) { + margin-top: 0.75em; +} + +.chat-text :where(ul, ol) { + padding-left: 1.5em; +} + +.chat-text :where(li + li) { + margin-top: 0.25em; +} + +.chat-text :where(a) { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.chat-text :where(a:hover) { + opacity: 0.8; +} + +.chat-text :where(code) { + font-family: var(--mono); + font-size: 0.9em; +} + +.chat-text :where(:not(pre) > code) { + background: rgba(0, 0, 0, 0.15); + padding: 0.15em 0.4em; + border-radius: 4px; +} + +.chat-text :where(pre) { + background: rgba(0, 0, 0, 0.15); + border-radius: 6px; + padding: 10px 12px; + overflow-x: auto; +} + +.chat-text :where(pre code) { + background: none; + padding: 0; +} + +.chat-text :where(blockquote) { + border-left: 3px solid var(--border-strong); + padding-left: 12px; + margin-left: 0; + color: var(--muted); + background: rgba(255, 255, 255, 0.02); + padding: 8px 12px; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} + +.chat-text :where(blockquote blockquote) { + margin-top: 8px; + border-left-color: var(--border-hover); + background: rgba(255, 255, 255, 0.03); +} + +.chat-text :where(blockquote blockquote blockquote) { + border-left-color: var(--muted-strong); + background: rgba(255, 255, 255, 0.04); +} + +:root[data-theme="light"] .chat-text :where(blockquote) { + background: rgba(0, 0, 0, 0.03); +} + +:root[data-theme="light"] .chat-text :where(blockquote blockquote) { + background: rgba(0, 0, 0, 0.05); +} + +:root[data-theme="light"] .chat-text :where(blockquote blockquote blockquote) { + background: rgba(0, 0, 0, 0.04); +} + +:root[data-theme="light"] .chat-text :where(:not(pre) > code) { + background: rgba(0, 0, 0, 0.08); + border: 1px solid rgba(0, 0, 0, 0.1); +} + +:root[data-theme="light"] .chat-text :where(pre) { + background: rgba(0, 0, 0, 0.05); + border: 1px solid rgba(0, 0, 0, 0.1); +} + +.chat-text :where(hr) { + border: none; + border-top: 1px solid var(--border); + margin: 1em 0; +} diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css new file mode 100644 index 0000000000000000000000000000000000000000..6384db115f02dab9f1481e67589e977e858a0e58 --- /dev/null +++ b/ui/src/styles/chat/tool-cards.css @@ -0,0 +1,202 @@ +/* Tool Card Styles */ +.chat-tool-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + margin-top: 8px; + background: var(--card); + box-shadow: inset 0 1px 0 var(--card-highlight); + transition: + border-color 150ms ease-out, + background 150ms ease-out; + /* Fixed max-height to ensure cards don't expand too much */ + max-height: 120px; + overflow: hidden; +} + +.chat-tool-card:hover { + border-color: var(--border-strong); + background: var(--bg-hover); +} + +/* First tool card in a group - no top margin */ +.chat-tool-card:first-child { + margin-top: 0; +} + +.chat-tool-card--clickable { + cursor: pointer; +} + +.chat-tool-card--clickable:focus { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Header with title and chevron */ +.chat-tool-card__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.chat-tool-card__title { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 600; + font-size: 13px; + line-height: 1.2; +} + +.chat-tool-card__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.chat-tool-card__icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* "View >" action link */ +.chat-tool-card__action { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--accent); + opacity: 0.8; + transition: opacity 150ms ease-out; +} + +.chat-tool-card__action svg { + width: 12px; + height: 12px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-tool-card--clickable:hover .chat-tool-card__action { + opacity: 1; +} + +/* Status indicator for completed/empty results */ +.chat-tool-card__status { + display: inline-flex; + align-items: center; + color: var(--ok); +} + +.chat-tool-card__status svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-tool-card__status-text { + font-size: 11px; + margin-top: 4px; +} + +.chat-tool-card__detail { + font-size: 12px; + color: var(--muted); + margin-top: 4px; +} + +/* Collapsed preview - fixed height with truncation */ +.chat-tool-card__preview { + font-size: 11px; + color: var(--muted); + margin-top: 8px; + padding: 8px 10px; + background: var(--secondary); + border-radius: var(--radius-md); + white-space: pre-wrap; + overflow: hidden; + max-height: 44px; + line-height: 1.4; + border: 1px solid var(--border); +} + +.chat-tool-card--clickable:hover .chat-tool-card__preview { + background: var(--bg-hover); + border-color: var(--border-strong); +} + +/* Short inline output */ +.chat-tool-card__inline { + font-size: 11px; + color: var(--text); + margin-top: 6px; + padding: 6px 8px; + background: var(--secondary); + border-radius: var(--radius-sm); + white-space: pre-wrap; + word-break: break-word; +} + +/* Reading Indicator */ +.chat-reading-indicator { + background: transparent; + border: 1px solid var(--border); + padding: 12px; + display: inline-flex; +} + +.chat-reading-indicator__dots { + display: flex; + gap: 6px; + align-items: center; +} + +.chat-reading-indicator__dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--muted); + animation: reading-pulse 1.4s ease-in-out infinite; +} + +.chat-reading-indicator__dots span:nth-child(1) { + animation-delay: 0s; +} + +.chat-reading-indicator__dots span:nth-child(2) { + animation-delay: 0.2s; +} + +.chat-reading-indicator__dots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes reading-pulse { + 0%, + 60%, + 100% { + opacity: 0.3; + transform: scale(0.8); + } + 30% { + opacity: 1; + transform: scale(1); + } +} diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css new file mode 100644 index 0000000000000000000000000000000000000000..848ffc365eae60ee8348a017fccda4b2c85192c6 --- /dev/null +++ b/ui/src/styles/components.css @@ -0,0 +1,1498 @@ +@import "./chat.css"; + +/* =========================================== + Cards - Refined with depth + =========================================== */ + +.card { + border: 1px solid var(--border); + background: var(--card); + border-radius: var(--radius-lg); + padding: 20px; + animation: rise 0.35s var(--ease-out) backwards; + transition: + border-color var(--duration-normal) var(--ease-out), + box-shadow var(--duration-normal) var(--ease-out), + transform var(--duration-normal) var(--ease-out); + box-shadow: + var(--shadow-sm), + inset 0 1px 0 var(--card-highlight); +} + +.card:hover { + border-color: var(--border-strong); + box-shadow: + var(--shadow-md), + inset 0 1px 0 var(--card-highlight); +} + +.card-title { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--text-strong); +} + +.card-sub { + color: var(--muted); + font-size: 13px; + margin-top: 6px; + line-height: 1.5; +} + +/* =========================================== + Stats - Bold values, subtle labels + =========================================== */ + +.stat { + background: var(--card); + border-radius: var(--radius-md); + padding: 14px 16px; + border: 1px solid var(--border); + transition: + border-color var(--duration-normal) var(--ease-out), + box-shadow var(--duration-normal) var(--ease-out); + box-shadow: inset 0 1px 0 var(--card-highlight); +} + +.stat:hover { + border-color: var(--border-strong); + box-shadow: + var(--shadow-sm), + inset 0 1px 0 var(--card-highlight); +} + +.stat-label { + color: var(--muted); + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.stat-value { + font-size: 24px; + font-weight: 700; + margin-top: 6px; + letter-spacing: -0.03em; + line-height: 1.1; +} + +.stat-value.ok { + color: var(--ok); +} + +.stat-value.warn { + color: var(--warn); +} + +.stat-card { + display: grid; + gap: 6px; +} + +.note-title { + font-weight: 600; + letter-spacing: -0.01em; +} + +/* =========================================== + Status List + =========================================== */ + +.status-list { + display: grid; + gap: 8px; +} + +.status-list div { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} + +.status-list div:last-child { + border-bottom: none; +} + +.account-count { + margin-top: 10px; + font-size: 12px; + font-weight: 500; + color: var(--muted); +} + +.account-card-list { + margin-top: 16px; + display: grid; + gap: 12px; +} + +.account-card { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px; + background: var(--bg-elevated); + transition: border-color var(--duration-fast) ease; +} + +.account-card:hover { + border-color: var(--border-strong); +} + +.account-card-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} + +.account-card-title { + font-weight: 500; +} + +.account-card-id { + font-family: var(--mono); + font-size: 12px; + color: var(--muted); +} + +.account-card-status { + margin-top: 10px; + font-size: 13px; +} + +.account-card-status div { + padding: 4px 0; +} + +.account-card-error { + margin-top: 8px; + color: var(--danger); + font-size: 12px; +} + +/* =========================================== + Labels & Pills + =========================================== */ + +.label { + color: var(--muted); + font-size: 12px; + font-weight: 500; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border); + padding: 6px 12px; + border-radius: var(--radius-full); + background: var(--secondary); + font-size: 13px; + font-weight: 500; + transition: border-color var(--duration-fast) ease; +} + +.pill:hover { + border-color: var(--border-strong); +} + +.pill.danger { + border-color: var(--danger-subtle); + background: var(--danger-subtle); + color: var(--danger); +} + +/* =========================================== + Theme Toggle + =========================================== */ + +.theme-toggle { + --theme-item: 28px; + --theme-gap: 2px; + --theme-pad: 4px; + position: relative; +} + +.theme-toggle__track { + position: relative; + display: grid; + grid-template-columns: repeat(3, var(--theme-item)); + gap: var(--theme-gap); + padding: var(--theme-pad); + border-radius: var(--radius-full); + border: 1px solid var(--border); + background: var(--secondary); +} + +.theme-toggle__indicator { + position: absolute; + top: 50%; + left: var(--theme-pad); + width: var(--theme-item); + height: var(--theme-item); + border-radius: var(--radius-full); + transform: translateY(-50%) + translateX(calc(var(--theme-index, 0) * (var(--theme-item) + var(--theme-gap)))); + background: var(--accent); + transition: transform var(--duration-normal) var(--ease-out); + z-index: 0; +} + +.theme-toggle__button { + height: var(--theme-item); + width: var(--theme-item); + display: grid; + place-items: center; + border: 0; + border-radius: var(--radius-full); + background: transparent; + color: var(--muted); + cursor: pointer; + position: relative; + z-index: 1; + transition: color var(--duration-fast) ease; +} + +.theme-toggle__button:hover { + color: var(--text); +} + +.theme-toggle__button.active { + color: var(--accent-foreground); +} + +.theme-toggle__button.active .theme-icon { + stroke: var(--accent-foreground); +} + +.theme-icon { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* =========================================== + Status Dot - With glow for emphasis + =========================================== */ + +.statusDot { + width: 8px; + height: 8px; + border-radius: var(--radius-full); + background: var(--danger); + box-shadow: 0 0 8px rgba(239, 68, 68, 0.5); + animation: pulse-subtle 2s ease-in-out infinite; +} + +.statusDot.ok { + background: var(--ok); + box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); + animation: none; +} + +/* =========================================== + Buttons - Tactile with personality + =========================================== */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid var(--border); + background: var(--bg-elevated); + padding: 9px 16px; + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 500; + letter-spacing: -0.01em; + cursor: pointer; + transition: + border-color var(--duration-fast) var(--ease-out), + background var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); +} + +.btn:hover { + background: var(--bg-hover); + border-color: var(--border-strong); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.btn:active { + background: var(--secondary); + transform: translateY(0); + box-shadow: none; +} + +.btn svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; +} + +.btn.primary { + border-color: var(--accent); + background: var(--accent); + color: var(--primary-foreground); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.btn.primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + box-shadow: + var(--shadow-md), + 0 0 20px var(--accent-glow); +} + +/* Keyboard shortcut badge (shadcn style) */ +.btn-kbd { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 6px; + padding: 2px 5px; + font-family: var(--mono); + font-size: 11px; + font-weight: 500; + line-height: 1; + border-radius: 4px; + background: rgba(255, 255, 255, 0.15); + color: inherit; + opacity: 0.8; +} + +.btn.primary .btn-kbd { + background: rgba(255, 255, 255, 0.2); +} + +:root[data-theme="light"] .btn-kbd { + background: rgba(0, 0, 0, 0.08); +} + +:root[data-theme="light"] .btn.primary .btn-kbd { + background: rgba(255, 255, 255, 0.25); +} + +.btn.active { + border-color: var(--accent); + background: var(--accent-subtle); + color: var(--accent); +} + +.btn.danger { + border-color: transparent; + background: var(--danger-subtle); + color: var(--danger); +} + +.btn.danger:hover { + background: rgba(239, 68, 68, 0.15); +} + +.btn--sm { + padding: 6px 10px; + font-size: 12px; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* =========================================== + Form Fields + =========================================== */ + +.field { + display: grid; + gap: 6px; +} + +.field.full { + grid-column: 1 / -1; +} + +.field span { + color: var(--muted); + font-size: 13px; + font-weight: 500; +} + +.field input, +.field textarea, +.field select { + border: 1px solid var(--input); + background: var(--card); + border-radius: var(--radius-md); + padding: 8px 12px; + outline: none; + box-shadow: inset 0 1px 0 var(--card-highlight); + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; +} + +.field input:focus, +.field textarea:focus, +.field select:focus { + border-color: var(--ring); + box-shadow: var(--focus-ring); +} + +.field select { + appearance: none; + padding-right: 36px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23a1a1aa' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + cursor: pointer; +} + +.field textarea { + font-family: var(--mono); + min-height: 160px; + resize: vertical; + white-space: pre; + line-height: 1.5; +} + +.field.checkbox { + grid-template-columns: auto 1fr; + align-items: center; +} + +.config-form .field.checkbox { + grid-template-columns: 18px minmax(0, 1fr); + column-gap: 10px; +} + +.config-form .field.checkbox input[type="checkbox"] { + margin: 0; + width: 16px; + height: 16px; + accent-color: var(--accent); +} + +.form-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +:root[data-theme="light"] .field input, +:root[data-theme="light"] .field textarea, +:root[data-theme="light"] .field select { + background: var(--card); + border-color: var(--input); +} + +:root[data-theme="light"] .btn { + background: var(--bg); + border-color: var(--input); +} + +:root[data-theme="light"] .btn:hover { + background: var(--bg-hover); +} + +:root[data-theme="light"] .btn.primary { + background: var(--accent); + border-color: var(--accent); +} + +/* =========================================== + Utilities + =========================================== */ + +.muted { + color: var(--muted); +} + +.mono { + font-family: var(--mono); +} + +/* =========================================== + Callouts - Informative with subtle depth + =========================================== */ + +.callout { + padding: 14px 16px; + border-radius: var(--radius-md); + background: var(--secondary); + border: 1px solid var(--border); + font-size: 13px; + line-height: 1.5; + position: relative; +} + +.callout.danger { + border-color: rgba(239, 68, 68, 0.25); + background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(239, 68, 68, 0.04) 100%); + color: var(--danger); +} + +.callout.info { + border-color: rgba(59, 130, 246, 0.25); + background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.04) 100%); + color: var(--info); +} + +.callout.success { + border-color: rgba(34, 197, 94, 0.25); + background: linear-gradient(135deg, rgba(34, 197, 94, 0.08) 0%, rgba(34, 197, 94, 0.04) 100%); + color: var(--ok); +} + +/* Compaction indicator */ +.compaction-indicator { + font-size: 13px; + padding: 10px 12px; + margin-bottom: 8px; + animation: fade-in 0.2s var(--ease-out); +} + +.compaction-indicator--active { + animation: compaction-pulse 1.5s ease-in-out infinite; +} + +.compaction-indicator--complete { + animation: fade-in 0.2s var(--ease-out); +} + +@keyframes compaction-pulse { + 0%, + 100% { + opacity: 0.7; + } + 50% { + opacity: 1; + } +} + +/* =========================================== + Code Blocks + =========================================== */ + +.code-block { + font-family: var(--mono); + font-size: 13px; + line-height: 1.5; + background: var(--secondary); + padding: 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + max-height: 360px; + overflow: auto; + max-width: 100%; +} + +:root[data-theme="light"] .code-block, +:root[data-theme="light"] .list-item, +:root[data-theme="light"] .table-row, +:root[data-theme="light"] .chip { + background: var(--bg); +} + +/* =========================================== + Lists + =========================================== */ + +.list { + display: grid; + gap: 8px; + container-type: inline-size; +} + +.list-item { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(200px, 260px); + gap: 16px; + align-items: start; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px; + background: var(--card); + transition: border-color var(--duration-fast) ease; +} + +.list-item-clickable { + cursor: pointer; +} + +.list-item-clickable:hover { + border-color: var(--border-strong); +} + +.list-item-selected { + border-color: var(--accent); + box-shadow: var(--focus-ring); +} + +.list-main { + display: grid; + gap: 4px; + min-width: 0; +} + +.list-title { + font-weight: 500; +} + +.list-sub { + color: var(--muted); + font-size: 12px; +} + +.list-meta { + text-align: right; + color: var(--muted); + font-size: 12px; + display: grid; + gap: 4px; + min-width: 200px; +} + +.list-meta .btn { + padding: 6px 10px; +} + +.list-meta .field input, +.list-meta .field textarea, +.list-meta .field select { + width: 100%; +} + +@container (max-width: 560px) { + .list-item { + grid-template-columns: 1fr; + } + + .list-meta { + min-width: 0; + text-align: left; + } +} + +/* =========================================== + Chips - Compact and punchy + =========================================== */ + +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + font-size: 12px; + font-weight: 500; + border: 1px solid var(--border); + border-radius: var(--radius-full); + padding: 5px 12px; + color: var(--muted); + background: var(--secondary); + transition: + border-color var(--duration-fast) var(--ease-out), + background var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); +} + +.chip:hover { + border-color: var(--border-strong); + transform: translateY(-1px); +} + +.chip input { + margin-right: 6px; +} + +.chip-ok { + color: var(--ok); + border-color: rgba(34, 197, 94, 0.3); + background: var(--ok-subtle); +} + +.chip-warn { + color: var(--warn); + border-color: rgba(245, 158, 11, 0.3); + background: var(--warn-subtle); +} + +/* =========================================== + Tables + =========================================== */ + +.table { + display: grid; + gap: 6px; +} + +.table-head, +.table-row { + display: grid; + grid-template-columns: 1.4fr 1fr 0.8fr 0.7fr 0.8fr 0.8fr 0.8fr 0.8fr 0.6fr; + gap: 12px; + align-items: center; +} + +.table-head { + font-size: 12px; + font-weight: 500; + color: var(--muted); + padding: 0 12px; +} + +.table-row { + border: 1px solid var(--border); + padding: 10px 12px; + border-radius: var(--radius-md); + background: var(--card); + transition: border-color var(--duration-fast) ease; +} + +.table-row:hover { + border-color: var(--border-strong); +} + +.session-link { + text-decoration: none; + color: var(--accent); + font-weight: 500; +} + +.session-link:hover { + text-decoration: underline; +} + +/* =========================================== + Log Stream + =========================================== */ + +.log-stream { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); + max-height: 500px; + overflow: auto; + container-type: inline-size; +} + +.log-row { + display: grid; + grid-template-columns: 90px 70px minmax(140px, 200px) minmax(0, 1fr); + gap: 12px; + align-items: start; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + font-size: 12px; + transition: background var(--duration-fast) ease; +} + +.log-row:hover { + background: var(--bg-hover); +} + +.log-row:last-child { + border-bottom: none; +} + +.log-time { + color: var(--muted); + font-family: var(--mono); +} + +.log-level { + font-size: 11px; + font-weight: 500; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 2px 6px; + width: fit-content; +} + +.log-level.trace, +.log-level.debug { + color: var(--muted); +} + +.log-level.info { + color: var(--info); + border-color: rgba(59, 130, 246, 0.3); +} + +.log-level.warn { + color: var(--warn); + border-color: var(--warn-subtle); +} + +.log-level.error, +.log-level.fatal { + color: var(--danger); + border-color: var(--danger-subtle); +} + +.log-chip.trace, +.log-chip.debug { + color: var(--muted); +} + +.log-chip.info { + color: var(--info); + border-color: rgba(59, 130, 246, 0.3); +} + +.log-chip.warn { + color: var(--warn); + border-color: var(--warn-subtle); +} + +.log-chip.error, +.log-chip.fatal { + color: var(--danger); + border-color: var(--danger-subtle); +} + +.log-subsystem { + color: var(--muted); + font-family: var(--mono); +} + +.log-message { + white-space: pre-wrap; + word-break: break-word; + font-family: var(--mono); +} + +@container (max-width: 620px) { + .log-row { + grid-template-columns: 70px 60px minmax(0, 1fr); + } + + .log-subsystem { + display: none; + } +} + +/* =========================================== + Chat + =========================================== */ + +.chat { + display: flex; + flex-direction: column; + min-height: 0; +} + +.shell--chat .chat { + flex: 1; +} + +.chat-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 16px; + flex-wrap: wrap; +} + +.chat-header__left { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; + min-width: 0; +} + +.chat-header__right { + display: flex; + align-items: center; + gap: 8px; +} + +.chat-session { + min-width: 240px; +} + +.chat-thread { + margin-top: 16px; + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 16px 12px; + min-width: 0; + border-radius: 0; + border: none; + background: transparent; +} + +/* Chat queue */ +.chat-queue { + margin-top: 12px; + padding: 12px; + border-radius: var(--radius-lg); + border: 1px solid var(--border); + background: var(--card); + display: grid; + gap: 8px; +} + +.chat-queue__title { + font-family: var(--mono); + font-size: 12px; + font-weight: 500; + color: var(--muted); +} + +.chat-queue__list { + display: grid; + gap: 8px; +} + +.chat-queue__item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 12px; + padding: 10px 12px; + border-radius: var(--radius-md); + border: 1px dashed var(--border-strong); + background: var(--secondary); +} + +.chat-queue__text { + color: var(--chat-text); + font-size: 13px; + line-height: 1.45; + white-space: pre-wrap; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.chat-queue__remove { + align-self: start; + padding: 4px 10px; + font-size: 12px; + line-height: 1; +} + +/* Chat lines */ +.chat-line { + display: flex; +} + +.chat-line.user { + justify-content: flex-end; +} + +.chat-line.assistant, +.chat-line.other { + justify-content: flex-start; +} + +.chat-msg { + display: grid; + gap: 6px; + max-width: min(700px, 82%); +} + +.chat-line.user .chat-msg { + justify-items: end; +} + +/* Chat bubbles */ +.chat-bubble { + border: 1px solid transparent; + background: var(--card); + border-radius: var(--radius-lg); + padding: 10px 14px; + min-width: 0; +} + +:root[data-theme="light"] .chat-bubble { + border-color: var(--border); + background: var(--bg); +} + +.chat-line.user .chat-bubble { + border-color: transparent; + background: var(--accent-subtle); +} + +:root[data-theme="light"] .chat-line.user .chat-bubble { + border-color: rgba(234, 88, 12, 0.2); + background: rgba(251, 146, 60, 0.12); +} + +.chat-line.assistant .chat-bubble { + border-color: transparent; + background: var(--secondary); +} + +:root[data-theme="light"] .chat-line.assistant .chat-bubble { + border-color: var(--border); + background: var(--bg-muted); +} + +@keyframes chatStreamPulse { + 0%, + 100% { + border-color: var(--border); + } + 50% { + border-color: var(--accent); + } +} + +.chat-bubble.streaming { + animation: chatStreamPulse 1.5s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .chat-bubble.streaming { + animation: none; + border-color: var(--accent); + } +} + +/* Reading indicator */ +.chat-bubble.chat-reading-indicator { + width: fit-content; + padding: 10px 16px; +} + +.chat-reading-indicator__dots { + display: inline-flex; + align-items: center; + gap: 4px; + height: 12px; +} + +.chat-reading-indicator__dots > span { + display: inline-block; + width: 6px; + height: 6px; + border-radius: var(--radius-full); + background: var(--muted); + opacity: 0.6; + transform: translateY(0); + animation: chatReadingDot 1.2s ease-in-out infinite; + will-change: transform, opacity; +} + +.chat-reading-indicator__dots > span:nth-child(2) { + animation-delay: 0.15s; +} + +.chat-reading-indicator__dots > span:nth-child(3) { + animation-delay: 0.3s; +} + +@keyframes chatReadingDot { + 0%, + 80%, + 100% { + opacity: 0.4; + transform: translateY(0); + } + 40% { + opacity: 1; + transform: translateY(-3px); + } +} + +@media (prefers-reduced-motion: reduce) { + .chat-reading-indicator__dots > span { + animation: none; + opacity: 0.6; + } +} + +/* Chat text */ +.chat-text { + overflow-wrap: anywhere; + word-break: break-word; + color: var(--chat-text); + line-height: 1.5; +} + +.chat-text :where(p, ul, ol, pre, blockquote, table) { + margin: 0; +} + +.chat-text :where(p + p, p + ul, p + ol, p + pre, p + blockquote, p + table) { + margin-top: 0.75em; +} + +.chat-text :where(ul, ol) { + padding-left: 1.2em; +} + +.chat-text :where(li + li) { + margin-top: 0.25em; +} + +.chat-text :where(a) { + color: var(--accent); +} + +.chat-text :where(a:hover) { + text-decoration: underline; +} + +.chat-text :where(blockquote) { + border-left: 2px solid var(--border-strong); + padding-left: 12px; + color: var(--muted); +} + +.chat-text :where(hr) { + border: 0; + border-top: 1px solid var(--border); + margin: 1em 0; +} + +.chat-text :where(code) { + font-family: var(--mono); + font-size: 0.9em; +} + +.chat-text :where(:not(pre) > code) { + padding: 0.15em 0.35em; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--secondary); +} + +:root[data-theme="light"] .chat-text :where(:not(pre) > code) { + background: var(--bg-muted); +} + +.chat-text :where(pre) { + margin-top: 0.75em; + padding: 10px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--secondary); + overflow: auto; +} + +:root[data-theme="light"] .chat-text :where(pre) { + background: var(--bg-muted); +} + +.chat-text :where(pre code) { + font-size: 12px; + white-space: pre; +} + +.chat-text :where(table) { + margin-top: 0.75em; + border-collapse: collapse; + width: 100%; + font-size: 13px; +} + +.chat-text :where(th, td) { + border: 1px solid var(--border); + padding: 6px 10px; + vertical-align: top; +} + +.chat-text :where(th) { + font-family: var(--mono); + font-weight: 500; + color: var(--muted); + background: var(--secondary); +} + +/* Tool cards */ +.chat-tool-card { + margin-top: 8px; + padding: 10px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--secondary); + display: grid; + gap: 4px; +} + +:root[data-theme="light"] .chat-tool-card { + background: var(--bg-muted); +} + +.chat-tool-card__title { + font-family: var(--mono); + font-size: 12px; + font-weight: 500; + color: var(--text); +} + +.chat-tool-card__detail { + font-family: var(--mono); + font-size: 11px; + color: var(--muted); +} + +.chat-tool-card__details { + margin-top: 6px; +} + +.chat-tool-card__summary { + font-family: var(--mono); + font-size: 11px; + color: var(--muted); + cursor: pointer; + list-style: none; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.chat-tool-card__summary::-webkit-details-marker { + display: none; +} + +.chat-tool-card__summary-meta { + color: var(--muted); + opacity: 0.7; +} + +.chat-tool-card__details[open] .chat-tool-card__summary { + color: var(--text); +} + +.chat-tool-card__output { + margin-top: 8px; + font-family: var(--mono); + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + color: var(--chat-text); + padding: 8px 10px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--card); +} + +:root[data-theme="light"] .chat-tool-card__output { + background: var(--bg); +} + +.chat-stamp { + font-size: 11px; + color: var(--muted); +} + +.chat-line.user .chat-stamp { + text-align: right; +} + +/* Chat compose */ +.chat-compose { + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.shell--chat .chat-compose { + position: sticky; + bottom: 0; + z-index: 5; + margin-top: 0; + padding-top: 12px; + background: linear-gradient(180deg, transparent 0%, var(--bg) 40%); +} + +.shell--chat-focus .chat-compose { + bottom: calc(var(--shell-pad) + 8px); + padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); + border-bottom-left-radius: var(--radius-lg); + border-bottom-right-radius: var(--radius-lg); +} + +.chat-compose__field { + gap: 4px; +} + +.chat-compose__field textarea { + min-height: 72px; + padding: 10px 14px; + border-radius: var(--radius-lg); + resize: vertical; + white-space: pre-wrap; + font-family: var(--font-body); + line-height: 1.5; + border: 1px solid var(--input); + background: var(--card); + box-shadow: inset 0 1px 0 var(--card-highlight); + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; +} + +.chat-compose__field textarea:focus { + border-color: var(--ring); + box-shadow: var(--focus-ring); +} + +.chat-compose__field textarea:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.chat-compose__actions { + justify-content: flex-end; + align-self: end; +} + +@media (max-width: 900px) { + .chat-session { + min-width: 180px; + } + + .chat-compose { + grid-template-columns: 1fr; + } +} + +/* =========================================== + QR Code + =========================================== */ + +.qr-wrap { + margin-top: 16px; + border-radius: var(--radius-md); + background: var(--card); + border: 1px dashed var(--border-strong); + padding: 16px; + display: inline-flex; +} + +.qr-wrap img { + width: 160px; + height: 160px; + border-radius: var(--radius-sm); + image-rendering: pixelated; +} + +/* =========================================== + Exec Approval Modal + =========================================== */ + +.exec-approval-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 200; +} + +.exec-approval-card { + width: min(540px, 100%); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; + animation: scale-in 0.2s var(--ease-out); +} + +.exec-approval-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.exec-approval-title { + font-size: 14px; + font-weight: 600; +} + +.exec-approval-sub { + color: var(--muted); + font-size: 13px; + margin-top: 4px; +} + +.exec-approval-queue { + font-size: 11px; + font-weight: 500; + color: var(--muted); + border: 1px solid var(--border); + border-radius: var(--radius-full); + padding: 4px 10px; +} + +.exec-approval-command { + margin-top: 12px; + padding: 10px 12px; + background: var(--secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + word-break: break-word; + white-space: pre-wrap; + font-family: var(--mono); + font-size: 13px; +} + +.exec-approval-meta { + margin-top: 12px; + display: grid; + gap: 6px; + font-size: 13px; + color: var(--muted); +} + +.exec-approval-meta-row { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.exec-approval-meta-row span:last-child { + color: var(--text); + font-family: var(--mono); +} + +.exec-approval-error { + margin-top: 10px; + font-size: 13px; + color: var(--danger); +} + +.exec-approval-actions { + margin-top: 16px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css new file mode 100644 index 0000000000000000000000000000000000000000..7d96ac13f9699060d6ed48efcaccf620a0207cef --- /dev/null +++ b/ui/src/styles/config.css @@ -0,0 +1,1447 @@ +/* =========================================== + Config Page - Carbon Design System + =========================================== */ + +/* Layout Container */ +.config-layout { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: 0; + height: calc(100vh - 160px); + margin: -16px; + border-radius: var(--radius-xl); + overflow: hidden; + border: 1px solid var(--border); + background: var(--panel); +} + +/* =========================================== + Sidebar + =========================================== */ + +.config-sidebar { + display: flex; + flex-direction: column; + background: var(--bg-accent); + border-right: 1px solid var(--border); + min-height: 0; + overflow: hidden; +} + +:root[data-theme="light"] .config-sidebar { + background: var(--bg-hover); +} + +.config-sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 18px; + border-bottom: 1px solid var(--border); +} + +.config-sidebar__title { + font-weight: 600; + font-size: 14px; + letter-spacing: -0.01em; +} + +.config-sidebar__footer { + margin-top: auto; + padding: 14px; + border-top: 1px solid var(--border); +} + +/* Search */ +.config-search { + position: relative; + padding: 14px; + border-bottom: 1px solid var(--border); +} + +.config-search__icon { + position: absolute; + left: 28px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + color: var(--muted); + pointer-events: none; +} + +.config-search__input { + width: 100%; + padding: 11px 36px 11px 42px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + font-size: 13px; + outline: none; + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease, + background var(--duration-fast) ease; +} + +.config-search__input::placeholder { + color: var(--muted); +} + +.config-search__input:focus { + border-color: var(--accent); + box-shadow: var(--focus-ring); + background: var(--bg-hover); +} + +:root[data-theme="light"] .config-search__input { + background: white; +} + +:root[data-theme="light"] .config-search__input:focus { + background: white; +} + +.config-search__clear { + position: absolute; + right: 22px; + top: 50%; + transform: translateY(-50%); + width: 22px; + height: 22px; + border: none; + border-radius: var(--radius-full); + background: var(--bg-hover); + color: var(--muted); + font-size: 14px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease; +} + +.config-search__clear:hover { + background: var(--border-strong); + color: var(--text); +} + +/* Navigation */ +.config-nav { + flex: 1; + overflow-y: auto; + padding: 10px; +} + +.config-nav__item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 11px 14px; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--muted); + font-size: 13px; + font-weight: 500; + text-align: left; + cursor: pointer; + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease; +} + +.config-nav__item:hover { + background: var(--bg-hover); + color: var(--text); +} + +:root[data-theme="light"] .config-nav__item:hover { + background: rgba(0, 0, 0, 0.04); +} + +.config-nav__item.active { + background: var(--accent-subtle); + color: var(--accent); +} + +.config-nav__icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + opacity: 0.7; +} + +.config-nav__item:hover .config-nav__icon, +.config-nav__item.active .config-nav__icon { + opacity: 1; +} + +.config-nav__icon svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; +} + +.config-nav__label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Mode Toggle */ +.config-mode-toggle { + display: flex; + padding: 4px; + background: var(--bg-elevated); + border-radius: var(--radius-md); + border: 1px solid var(--border); +} + +:root[data-theme="light"] .config-mode-toggle { + background: white; +} + +.config-mode-toggle__btn { + flex: 1; + padding: 9px 14px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; +} + +.config-mode-toggle__btn:hover { + color: var(--text); +} + +.config-mode-toggle__btn.active { + background: var(--accent); + color: white; + box-shadow: var(--shadow-sm); +} + +/* =========================================== + Main Content + =========================================== */ + +.config-main { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + background: var(--panel); + overflow: hidden; +} + +/* Actions Bar */ +.config-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px 22px; + background: var(--bg-accent); + border-bottom: 1px solid var(--border); +} + +:root[data-theme="light"] .config-actions { + background: var(--bg-hover); +} + +.config-actions__left, +.config-actions__right { + display: flex; + align-items: center; + gap: 10px; +} + +.config-changes-badge { + padding: 6px 14px; + border-radius: var(--radius-full); + background: var(--accent-subtle); + border: 1px solid rgba(255, 77, 77, 0.3); + color: var(--accent); + font-size: 12px; + font-weight: 600; +} + +.config-status { + font-size: 13px; + color: var(--muted); +} + +/* Diff Panel */ +.config-diff { + margin: 18px 22px 0; + border: 1px solid rgba(255, 77, 77, 0.25); + border-radius: var(--radius-lg); + background: var(--accent-subtle); + overflow: hidden; +} + +.config-diff__summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: var(--accent); + list-style: none; +} + +.config-diff__summary::-webkit-details-marker { + display: none; +} + +.config-diff__chevron { + width: 16px; + height: 16px; + transition: transform var(--duration-normal) var(--ease-out); +} + +.config-diff__chevron svg { + width: 100%; + height: 100%; +} + +.config-diff[open] .config-diff__chevron { + transform: rotate(180deg); +} + +.config-diff__content { + padding: 0 18px 18px; + display: grid; + gap: 10px; +} + +.config-diff__item { + display: flex; + align-items: baseline; + gap: 14px; + padding: 10px 14px; + border-radius: var(--radius-md); + background: var(--bg-elevated); + font-size: 12px; + font-family: var(--mono); +} + +:root[data-theme="light"] .config-diff__item { + background: white; +} + +.config-diff__path { + font-weight: 600; + color: var(--text); + flex-shrink: 0; +} + +.config-diff__values { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; + flex-wrap: wrap; +} + +.config-diff__from { + color: var(--danger); + opacity: 0.85; +} + +.config-diff__arrow { + color: var(--muted); +} + +.config-diff__to { + color: var(--ok); +} + +/* Section Hero */ +.config-section-hero { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 22px; + border-bottom: 1px solid var(--border); + background: var(--bg-accent); +} + +:root[data-theme="light"] .config-section-hero { + background: var(--bg-hover); +} + +.config-section-hero__icon { + width: 30px; + height: 30px; + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; +} + +.config-section-hero__icon svg { + width: 100%; + height: 100%; + stroke: currentColor; + fill: none; +} + +.config-section-hero__text { + display: grid; + gap: 3px; + min-width: 0; +} + +.config-section-hero__title { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.config-section-hero__desc { + font-size: 13px; + color: var(--muted); +} + +/* Subsection Nav */ +.config-subnav { + display: flex; + gap: 8px; + padding: 12px 22px 14px; + border-bottom: 1px solid var(--border); + background: var(--bg-accent); + overflow-x: auto; +} + +:root[data-theme="light"] .config-subnav { + background: var(--bg-hover); +} + +.config-subnav__item { + border: 1px solid transparent; + border-radius: var(--radius-full); + padding: 7px 14px; + font-size: 12px; + font-weight: 600; + color: var(--muted); + background: var(--bg-elevated); + cursor: pointer; + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease, + border-color var(--duration-fast) ease; + white-space: nowrap; +} + +:root[data-theme="light"] .config-subnav__item { + background: white; +} + +.config-subnav__item:hover { + color: var(--text); + border-color: var(--border); +} + +.config-subnav__item.active { + color: var(--accent); + border-color: rgba(255, 77, 77, 0.4); + background: var(--accent-subtle); +} + +/* Content Area */ +.config-content { + flex: 1; + overflow-y: auto; + padding: 22px; +} + +.config-raw-field textarea { + min-height: 500px; + font-family: var(--mono); + font-size: 13px; + line-height: 1.55; +} + +/* Loading State */ +.config-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + padding: 80px 24px; + color: var(--muted); +} + +.config-loading__spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: var(--radius-full); + animation: spin 0.75s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Empty State */ +.config-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + padding: 80px 24px; + text-align: center; +} + +.config-empty__icon { + font-size: 56px; + opacity: 0.35; +} + +.config-empty__text { + color: var(--muted); + font-size: 15px; +} + +/* =========================================== + Section Cards + =========================================== */ + +.config-form--modern { + display: grid; + gap: 26px; +} + +.config-section-card { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-elevated); + overflow: hidden; + transition: border-color var(--duration-fast) ease; +} + +.config-section-card:hover { + border-color: var(--border-strong); +} + +:root[data-theme="light"] .config-section-card { + background: white; +} + +.config-section-card__header { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 20px 22px; + background: var(--bg-accent); + border-bottom: 1px solid var(--border); +} + +:root[data-theme="light"] .config-section-card__header { + background: var(--bg-hover); +} + +.config-section-card__icon { + width: 34px; + height: 34px; + color: var(--accent); + flex-shrink: 0; +} + +.config-section-card__icon svg { + width: 100%; + height: 100%; +} + +.config-section-card__titles { + flex: 1; + min-width: 0; +} + +.config-section-card__title { + margin: 0; + font-size: 17px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.config-section-card__desc { + margin: 5px 0 0; + font-size: 13px; + color: var(--muted); + line-height: 1.45; +} + +.config-section-card__content { + padding: 22px; +} + +/* =========================================== + Form Fields + =========================================== */ + +.cfg-fields { + display: grid; + gap: 22px; +} + +.cfg-field { + display: grid; + gap: 8px; +} + +.cfg-field--error { + padding: 14px; + border-radius: var(--radius-md); + background: var(--danger-subtle); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.cfg-field__label { + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.cfg-field__help { + font-size: 12px; + color: var(--muted); + line-height: 1.45; +} + +.cfg-field__error { + font-size: 12px; + color: var(--danger); +} + +/* Text Input */ +.cfg-input-wrap { + display: flex; + gap: 10px; +} + +.cfg-input { + flex: 1; + padding: 11px 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-accent); + font-size: 14px; + outline: none; + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease, + background var(--duration-fast) ease; +} + +.cfg-input::placeholder { + color: var(--muted); + opacity: 0.7; +} + +.cfg-input:focus { + border-color: var(--accent); + box-shadow: var(--focus-ring); + background: var(--bg-hover); +} + +:root[data-theme="light"] .cfg-input { + background: white; +} + +:root[data-theme="light"] .cfg-input:focus { + background: white; +} + +.cfg-input--sm { + padding: 9px 12px; + font-size: 13px; +} + +.cfg-input__reset { + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + color: var(--muted); + font-size: 14px; + cursor: pointer; + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease; +} + +.cfg-input__reset:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text); +} + +.cfg-input__reset:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Textarea */ +.cfg-textarea { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-accent); + font-family: var(--mono); + font-size: 13px; + line-height: 1.55; + resize: vertical; + outline: none; + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; +} + +.cfg-textarea:focus { + border-color: var(--accent); + box-shadow: var(--focus-ring); +} + +:root[data-theme="light"] .cfg-textarea { + background: white; +} + +.cfg-textarea--sm { + padding: 10px 12px; + font-size: 12px; +} + +/* Number Input */ +.cfg-number { + display: inline-flex; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--bg-accent); +} + +:root[data-theme="light"] .cfg-number { + background: white; +} + +.cfg-number__btn { + width: 44px; + border: none; + background: var(--bg-elevated); + color: var(--text); + font-size: 18px; + font-weight: 300; + cursor: pointer; + transition: background var(--duration-fast) ease; +} + +.cfg-number__btn:hover:not(:disabled) { + background: var(--bg-hover); +} + +.cfg-number__btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +:root[data-theme="light"] .cfg-number__btn { + background: var(--bg-hover); +} + +:root[data-theme="light"] .cfg-number__btn:hover:not(:disabled) { + background: var(--border); +} + +.cfg-number__input { + width: 85px; + padding: 11px; + border: none; + border-left: 1px solid var(--border); + border-right: 1px solid var(--border); + background: transparent; + font-size: 14px; + text-align: center; + outline: none; + -moz-appearance: textfield; +} + +.cfg-number__input::-webkit-outer-spin-button, +.cfg-number__input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Select */ +.cfg-select { + padding: 11px 40px 11px 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background-color: var(--bg-accent); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + font-size: 14px; + cursor: pointer; + outline: none; + appearance: none; + transition: + border-color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; +} + +.cfg-select:focus { + border-color: var(--accent); + box-shadow: var(--focus-ring); +} + +:root[data-theme="light"] .cfg-select { + background-color: white; +} + +/* Segmented Control */ +.cfg-segmented { + display: inline-flex; + padding: 4px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-accent); +} + +:root[data-theme="light"] .cfg-segmented { + background: var(--bg-hover); +} + +.cfg-segmented__btn { + padding: 9px 18px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease, + box-shadow var(--duration-fast) ease; +} + +.cfg-segmented__btn:hover:not(:disabled):not(.active) { + color: var(--text); +} + +.cfg-segmented__btn.active { + background: var(--accent); + color: white; + box-shadow: var(--shadow-sm); +} + +.cfg-segmented__btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Toggle Row */ +.cfg-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 16px 18px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-accent); + cursor: pointer; + transition: + background var(--duration-fast) ease, + border-color var(--duration-fast) ease; +} + +.cfg-toggle-row:hover:not(.disabled) { + background: var(--bg-hover); + border-color: var(--border-strong); +} + +.cfg-toggle-row.disabled { + opacity: 0.55; + cursor: not-allowed; +} + +:root[data-theme="light"] .cfg-toggle-row { + background: white; +} + +:root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) { + background: var(--bg-hover); +} + +.cfg-toggle-row__content { + flex: 1; + min-width: 0; +} + +.cfg-toggle-row__label { + display: block; + font-size: 14px; + font-weight: 500; + color: var(--text); +} + +.cfg-toggle-row__help { + display: block; + margin-top: 3px; + font-size: 12px; + color: var(--muted); + line-height: 1.45; +} + +/* Toggle Switch */ +.cfg-toggle { + position: relative; + flex-shrink: 0; +} + +.cfg-toggle input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.cfg-toggle__track { + display: block; + width: 50px; + height: 28px; + background: var(--bg-elevated); + border: 1px solid var(--border-strong); + border-radius: var(--radius-full); + position: relative; + transition: + background var(--duration-normal) ease, + border-color var(--duration-normal) ease; +} + +:root[data-theme="light"] .cfg-toggle__track { + background: var(--border); +} + +.cfg-toggle__track::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + background: var(--text); + border-radius: var(--radius-full); + box-shadow: var(--shadow-sm); + transition: + transform var(--duration-normal) var(--ease-out), + background var(--duration-normal) ease; +} + +.cfg-toggle input:checked + .cfg-toggle__track { + background: var(--ok-subtle); + border-color: rgba(34, 197, 94, 0.4); +} + +.cfg-toggle input:checked + .cfg-toggle__track::after { + transform: translateX(22px); + background: var(--ok); +} + +.cfg-toggle input:focus + .cfg-toggle__track { + box-shadow: var(--focus-ring); +} + +/* Object (collapsible) */ +.cfg-object { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-accent); + overflow: hidden; +} + +:root[data-theme="light"] .cfg-object { + background: white; +} + +.cfg-object__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + cursor: pointer; + list-style: none; + transition: background var(--duration-fast) ease; +} + +.cfg-object__header:hover { + background: var(--bg-hover); +} + +.cfg-object__header::-webkit-details-marker { + display: none; +} + +.cfg-object__title { + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.cfg-object__chevron { + width: 18px; + height: 18px; + color: var(--muted); + transition: transform var(--duration-normal) var(--ease-out); +} + +.cfg-object__chevron svg { + width: 100%; + height: 100%; +} + +.cfg-object[open] .cfg-object__chevron { + transform: rotate(180deg); +} + +.cfg-object__help { + padding: 0 18px 14px; + font-size: 12px; + color: var(--muted); + border-bottom: 1px solid var(--border); +} + +.cfg-object__content { + padding: 18px; + display: grid; + gap: 18px; +} + +/* Array */ +.cfg-array { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.cfg-array__header { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; + background: var(--bg-accent); + border-bottom: 1px solid var(--border); +} + +:root[data-theme="light"] .cfg-array__header { + background: var(--bg-hover); +} + +.cfg-array__label { + flex: 1; + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +.cfg-array__count { + font-size: 12px; + color: var(--muted); + padding: 4px 10px; + background: var(--bg-elevated); + border-radius: var(--radius-full); +} + +:root[data-theme="light"] .cfg-array__count { + background: white; +} + +.cfg-array__add { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + color: var(--text); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background var(--duration-fast) ease; +} + +.cfg-array__add:hover:not(:disabled) { + background: var(--bg-hover); +} + +.cfg-array__add:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cfg-array__add-icon { + width: 14px; + height: 14px; +} + +.cfg-array__add-icon svg { + width: 100%; + height: 100%; +} + +.cfg-array__help { + padding: 12px 18px; + font-size: 12px; + color: var(--muted); + border-bottom: 1px solid var(--border); +} + +.cfg-array__empty { + padding: 36px 18px; + text-align: center; + color: var(--muted); + font-size: 13px; +} + +.cfg-array__items { + display: grid; + gap: 1px; + background: var(--border); +} + +.cfg-array__item { + background: var(--panel); +} + +.cfg-array__item-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 18px; + background: var(--bg-accent); + border-bottom: 1px solid var(--border); +} + +:root[data-theme="light"] .cfg-array__item-header { + background: var(--bg-hover); +} + +.cfg-array__item-index { + font-size: 11px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.cfg-array__item-remove { + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--muted); + cursor: pointer; + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease; +} + +.cfg-array__item-remove svg { + width: 16px; + height: 16px; +} + +.cfg-array__item-remove:hover:not(:disabled) { + background: var(--danger-subtle); + color: var(--danger); +} + +.cfg-array__item-remove:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.cfg-array__item-content { + padding: 18px; +} + +/* Map (custom entries) */ +.cfg-map { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.cfg-map__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px 18px; + background: var(--bg-accent); + border-bottom: 1px solid var(--border); +} + +:root[data-theme="light"] .cfg-map__header { + background: var(--bg-hover); +} + +.cfg-map__label { + font-size: 13px; + font-weight: 600; + color: var(--muted); +} + +.cfg-map__add { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + color: var(--text); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background var(--duration-fast) ease; +} + +.cfg-map__add:hover:not(:disabled) { + background: var(--bg-hover); +} + +.cfg-map__add-icon { + width: 14px; + height: 14px; +} + +.cfg-map__add-icon svg { + width: 100%; + height: 100%; +} + +.cfg-map__empty { + padding: 28px 18px; + text-align: center; + color: var(--muted); + font-size: 13px; +} + +.cfg-map__items { + display: grid; + gap: 10px; + padding: 14px; +} + +.cfg-map__item { + display: grid; + grid-template-columns: 150px 1fr auto; + gap: 10px; + align-items: start; +} + +.cfg-map__item-key { + min-width: 0; +} + +.cfg-map__item-value { + min-width: 0; +} + +.cfg-map__item-remove { + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--muted); + cursor: pointer; + transition: + background var(--duration-fast) ease, + color var(--duration-fast) ease; +} + +.cfg-map__item-remove svg { + width: 16px; + height: 16px; +} + +.cfg-map__item-remove:hover:not(:disabled) { + background: var(--danger-subtle); + color: var(--danger); +} + +/* Pill variants */ +.pill--sm { + padding: 5px 12px; + font-size: 11px; +} + +.pill--ok { + border-color: rgba(34, 197, 94, 0.35); + color: var(--ok); +} + +.pill--danger { + border-color: rgba(239, 68, 68, 0.35); + color: var(--danger); +} + +/* =========================================== + Mobile Responsiveness + =========================================== */ + +@media (max-width: 768px) { + .config-layout { + grid-template-columns: 1fr; + } + + .config-sidebar { + border-right: none; + border-bottom: 1px solid var(--border); + } + + .config-sidebar__header { + padding: 14px 16px; + } + + .config-nav { + display: flex; + flex-wrap: nowrap; + gap: 6px; + padding: 10px 14px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .config-nav__item { + flex: 0 0 auto; + padding: 9px 14px; + white-space: nowrap; + } + + .config-nav__label { + display: inline; + } + + .config-sidebar__footer { + display: none; + } + + .config-actions { + flex-wrap: wrap; + padding: 14px 16px; + } + + .config-actions__left, + .config-actions__right { + width: 100%; + justify-content: center; + } + + .config-section-hero { + padding: 14px 16px; + } + + .config-subnav { + padding: 10px 16px 12px; + } + + .config-content { + padding: 18px; + } + + .config-section-card__header { + padding: 16px 18px; + } + + .config-section-card__content { + padding: 18px; + } + + .cfg-toggle-row { + padding: 14px 16px; + } + + .cfg-map__item { + grid-template-columns: 1fr; + gap: 10px; + } + + .cfg-map__item-remove { + justify-self: end; + } +} + +@media (max-width: 480px) { + .config-nav__icon { + width: 26px; + height: 26px; + font-size: 17px; + } + + .config-nav__label { + display: none; + } + + .config-section-card__icon { + width: 30px; + height: 30px; + } + + .config-section-card__title { + font-size: 16px; + } + + .cfg-segmented { + flex-wrap: wrap; + } + + .cfg-segmented__btn { + flex: 1 0 auto; + min-width: 70px; + } +} diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css new file mode 100644 index 0000000000000000000000000000000000000000..c2a5c6fe3f1c18a0eba4e2bd9afb2de986d6fb27 --- /dev/null +++ b/ui/src/styles/layout.css @@ -0,0 +1,609 @@ +/* =========================================== + Shell Layout + =========================================== */ + +.shell { + --shell-pad: 16px; + --shell-gap: 16px; + --shell-nav-width: 220px; + --shell-topbar-height: 56px; + --shell-focus-duration: 200ms; + --shell-focus-ease: var(--ease-out); + height: 100vh; + display: grid; + grid-template-columns: var(--shell-nav-width) minmax(0, 1fr); + grid-template-rows: var(--shell-topbar-height) 1fr; + grid-template-areas: + "topbar topbar" + "nav content"; + gap: 0; + animation: dashboard-enter 0.4s var(--ease-out); + transition: grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease); + overflow: hidden; +} + +@supports (height: 100dvh) { + .shell { + height: 100dvh; + } +} + +.shell--chat { + min-height: 100vh; + height: 100vh; + overflow: hidden; +} + +@supports (height: 100dvh) { + .shell--chat { + height: 100dvh; + } +} + +.shell--nav-collapsed { + grid-template-columns: 0px minmax(0, 1fr); +} + +.shell--chat-focus { + grid-template-columns: 0px minmax(0, 1fr); +} + +.shell--onboarding { + grid-template-rows: 0 1fr; +} + +.shell--onboarding .topbar { + display: none; +} + +.shell--onboarding .content { + padding-top: 0; +} + +.shell--chat-focus .content { + padding-top: 0; + gap: 0; +} + +/* =========================================== + Topbar + =========================================== */ + +.topbar { + grid-area: topbar; + position: sticky; + top: 0; + z-index: 40; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 0 20px; + height: var(--shell-topbar-height); + border-bottom: 1px solid var(--border); + background: var(--bg); +} + +.topbar-left { + display: flex; + align-items: center; + gap: 12px; +} + +.topbar .nav-collapse-toggle { + width: 36px; + height: 36px; + margin-bottom: 0; +} + +.topbar .nav-collapse-toggle__icon { + width: 20px; + height: 20px; +} + +.topbar .nav-collapse-toggle__icon svg { + width: 20px; + height: 20px; +} + +/* Brand */ +.brand { + display: flex; + align-items: center; + gap: 10px; +} + +.brand-logo { + width: 28px; + height: 28px; + flex-shrink: 0; +} + +.brand-logo img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.brand-text { + display: flex; + flex-direction: column; + gap: 1px; +} + +.brand-title { + font-size: 16px; + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.1; + color: var(--text-strong); +} + +.brand-sub { + font-size: 10px; + font-weight: 500; + color: var(--muted); + letter-spacing: 0.05em; + text-transform: uppercase; + line-height: 1; +} + +/* Topbar status */ +.topbar-status { + display: flex; + align-items: center; + gap: 8px; +} + +.topbar-status .pill { + padding: 6px 10px; + gap: 6px; + font-size: 12px; + font-weight: 500; + height: 32px; + box-sizing: border-box; +} + +.topbar-status .pill .mono { + display: flex; + align-items: center; + line-height: 1; + margin-top: 0px; +} + +.topbar-status .statusDot { + width: 6px; + height: 6px; +} + +.topbar-status .theme-toggle { + --theme-item: 24px; + --theme-gap: 2px; + --theme-pad: 3px; +} + +.topbar-status .theme-icon { + width: 12px; + height: 12px; +} + +/* =========================================== + Navigation Sidebar + =========================================== */ + +.nav { + grid-area: nav; + overflow-y: auto; + overflow-x: hidden; + padding: 16px 12px; + background: var(--bg); + scrollbar-width: none; /* Firefox */ + transition: + width var(--shell-focus-duration) var(--shell-focus-ease), + padding var(--shell-focus-duration) var(--shell-focus-ease), + opacity var(--shell-focus-duration) var(--shell-focus-ease); + min-height: 0; +} + +.nav::-webkit-scrollbar { + display: none; /* Chrome/Safari */ +} + +.shell--chat-focus .nav { + width: 0; + padding: 0; + border-width: 0; + overflow: hidden; + pointer-events: none; + opacity: 0; +} + +.nav--collapsed { + width: 0; + min-width: 0; + padding: 0; + overflow: hidden; + border: none; + opacity: 0; + pointer-events: none; +} + +/* Nav collapse toggle */ +.nav-collapse-toggle { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-md); + cursor: pointer; + transition: + background var(--duration-fast) ease, + border-color var(--duration-fast) ease; + margin-bottom: 16px; +} + +.nav-collapse-toggle:hover { + background: var(--bg-hover); + border-color: var(--border); +} + +.nav-collapse-toggle__icon { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + color: var(--muted); + transition: color var(--duration-fast) ease; +} + +.nav-collapse-toggle__icon svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nav-collapse-toggle:hover .nav-collapse-toggle__icon { + color: var(--text); +} + +/* Nav groups */ +.nav-group { + margin-bottom: 20px; + display: grid; + gap: 2px; +} + +.nav-group:last-child { + margin-bottom: 0; +} + +.nav-group__items { + display: grid; + gap: 1px; +} + +.nav-group--collapsed .nav-group__items { + display: none; +} + +/* Nav label */ +.nav-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 6px 10px; + font-size: 11px; + font-weight: 500; + color: var(--muted); + margin-bottom: 4px; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + border-radius: var(--radius-sm); + transition: + color var(--duration-fast) ease, + background var(--duration-fast) ease; +} + +.nav-label:hover { + color: var(--text); + background: var(--bg-hover); +} + +.nav-label--static { + cursor: default; +} + +.nav-label--static:hover { + color: var(--muted); + background: transparent; +} + +.nav-label__text { + flex: 1; +} + +.nav-label__chevron { + font-size: 10px; + opacity: 0.5; + transition: transform var(--duration-fast) ease; +} + +.nav-group--collapsed .nav-label__chevron { + transform: rotate(-90deg); +} + +/* Nav items */ +.nav-item { + position: relative; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 8px 10px; + border-radius: var(--radius-md); + border: 1px solid transparent; + background: transparent; + color: var(--muted); + cursor: pointer; + text-decoration: none; + transition: + border-color var(--duration-fast) ease, + background var(--duration-fast) ease, + color var(--duration-fast) ease; +} + +.nav-item__icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0.7; + transition: opacity var(--duration-fast) ease; +} + +.nav-item__icon svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.nav-item__text { + font-size: 13px; + font-weight: 500; + white-space: nowrap; +} + +.nav-item:hover { + color: var(--text); + background: var(--bg-hover); + text-decoration: none; +} + +.nav-item:hover .nav-item__icon { + opacity: 1; +} + +.nav-item.active { + color: var(--text-strong); + background: var(--accent-subtle); +} + +.nav-item.active .nav-item__icon { + opacity: 1; + color: var(--accent); +} + +/* =========================================== + Content Area + =========================================== */ + +.content { + grid-area: content; + padding: 12px 16px 32px; + display: flex; + flex-direction: column; + gap: 24px; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +:root[data-theme="light"] .content { + background: var(--bg-content); +} + +.content--chat { + overflow: hidden; + padding-bottom: 0; +} + +/* Content header */ +.content-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + padding: 4px 8px; + overflow: hidden; + transform-origin: top center; + transition: + opacity var(--shell-focus-duration) var(--shell-focus-ease), + transform var(--shell-focus-duration) var(--shell-focus-ease), + max-height var(--shell-focus-duration) var(--shell-focus-ease), + padding var(--shell-focus-duration) var(--shell-focus-ease); + max-height: 80px; +} + +.shell--chat-focus .content-header { + opacity: 0; + transform: translateY(-8px); + max-height: 0px; + padding: 0; + pointer-events: none; +} + +.page-title { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.035em; + line-height: 1.15; + color: var(--text-strong); +} + +.page-sub { + color: var(--muted); + font-size: 14px; + font-weight: 400; + margin-top: 6px; + letter-spacing: -0.01em; +} + +.page-meta { + display: flex; + gap: 8px; +} + +/* Chat view header adjustments */ +.content--chat .content-header { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.content--chat .content-header > div:first-child { + text-align: left; +} + +.content--chat .page-meta { + justify-content: flex-start; +} + +.content--chat .chat-controls { + flex-shrink: 0; +} + +/* =========================================== + Grid Utilities + =========================================== */ + +.grid { + display: grid; + gap: 20px; +} + +.grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.stat-grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} + +.note-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.row { + display: flex; + gap: 12px; + align-items: center; +} + +.stack { + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 1fr); +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +/* =========================================== + Responsive - Tablet + =========================================== */ + +@media (max-width: 1100px) { + .shell { + --shell-pad: 12px; + --shell-gap: 12px; + grid-template-columns: 1fr; + grid-template-rows: auto auto 1fr; + grid-template-areas: + "topbar" + "nav" + "content"; + } + + .nav { + position: static; + max-height: none; + display: flex; + gap: 6px; + overflow-x: auto; + border-right: none; + border-bottom: 1px solid var(--border); + padding: 10px 14px; + background: var(--bg); + } + + .nav-group { + grid-auto-flow: column; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + margin-bottom: 0; + } + + .grid-cols-2, + .grid-cols-3 { + grid-template-columns: 1fr; + } + + .topbar { + position: static; + padding: 12px 14px; + gap: 10px; + } + + .topbar-status { + flex-wrap: wrap; + } + + .table-head, + .table-row { + grid-template-columns: 1fr; + } + + .list-item { + grid-template-columns: 1fr; + } +} diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css new file mode 100644 index 0000000000000000000000000000000000000000..450a83608c6bd65e65e5bf3075d57c3d3dbbe789 --- /dev/null +++ b/ui/src/styles/layout.mobile.css @@ -0,0 +1,374 @@ +/* =========================================== + Mobile Layout + =========================================== */ + +/* Tablet: Horizontal nav */ +@media (max-width: 1100px) { + .nav { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: 4px; + padding: 10px 14px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .nav::-webkit-scrollbar { + display: none; + } + + .nav-group { + display: contents; + } + + .nav-group__items { + display: contents; + } + + .nav-label { + display: none; + } + + .nav-group--collapsed .nav-group__items { + display: contents; + } + + .nav-item { + padding: 8px 14px; + font-size: 13px; + border-radius: var(--radius-md); + white-space: nowrap; + flex-shrink: 0; + } +} + +/* Mobile-specific styles */ +@media (max-width: 600px) { + .shell { + --shell-pad: 8px; + --shell-gap: 8px; + } + + /* Topbar */ + .topbar { + padding: 10px 12px; + gap: 8px; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + } + + .brand { + flex: 1; + min-width: 0; + } + + .brand-title { + font-size: 14px; + } + + .brand-sub { + display: none; + } + + .topbar-status { + gap: 6px; + width: auto; + flex-wrap: nowrap; + } + + .topbar-status .pill { + padding: 4px 8px; + font-size: 11px; + gap: 4px; + } + + .topbar-status .pill .mono { + display: none; + } + + .topbar-status .pill span:nth-child(2) { + display: none; + } + + /* Nav */ + .nav { + padding: 8px 10px; + gap: 4px; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .nav::-webkit-scrollbar { + display: none; + } + + .nav-group { + display: contents; + } + + .nav-label { + display: none; + } + + .nav-item { + padding: 6px 10px; + font-size: 12px; + border-radius: var(--radius-md); + white-space: nowrap; + flex-shrink: 0; + } + + /* Content */ + .content-header { + display: none; + } + + .content { + padding: 4px 4px 16px; + gap: 12px; + } + + /* Cards */ + .card { + padding: 12px; + border-radius: var(--radius-md); + } + + .card-title { + font-size: 13px; + } + + /* Stats */ + .stat-grid { + gap: 8px; + grid-template-columns: repeat(2, 1fr); + } + + .stat { + padding: 10px; + border-radius: var(--radius-md); + } + + .stat-label { + font-size: 11px; + } + + .stat-value { + font-size: 18px; + } + + /* Notes */ + .note-grid { + grid-template-columns: 1fr; + gap: 8px; + } + + /* Forms */ + .form-grid { + grid-template-columns: 1fr; + gap: 10px; + } + + .field input, + .field textarea, + .field select { + padding: 8px 10px; + border-radius: var(--radius-md); + font-size: 14px; + } + + /* Buttons */ + .btn { + padding: 8px 12px; + font-size: 12px; + } + + /* Pills */ + .pill { + padding: 4px 10px; + font-size: 12px; + } + + /* Chat */ + .chat-header { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .chat-header__left { + flex-direction: column; + align-items: stretch; + } + + .chat-header__right { + justify-content: space-between; + } + + .chat-session { + min-width: unset; + width: 100%; + } + + .chat-thread { + margin-top: 8px; + padding: 12px 8px; + } + + .chat-msg { + max-width: 90%; + } + + .chat-bubble { + padding: 8px 12px; + border-radius: var(--radius-md); + } + + .chat-compose { + gap: 8px; + } + + .chat-compose__field textarea { + min-height: 60px; + padding: 8px 10px; + border-radius: var(--radius-md); + font-size: 14px; + } + + /* Log stream */ + .log-stream { + border-radius: var(--radius-md); + max-height: 380px; + } + + .log-row { + grid-template-columns: 1fr; + gap: 4px; + padding: 8px; + } + + .log-time { + font-size: 10px; + } + + .log-level { + font-size: 9px; + } + + .log-subsystem { + font-size: 11px; + } + + .log-message { + font-size: 12px; + } + + /* Lists */ + .list-item { + padding: 10px; + border-radius: var(--radius-md); + } + + .list-title { + font-size: 13px; + } + + .list-sub { + font-size: 11px; + } + + /* Code blocks */ + .code-block { + padding: 8px; + border-radius: var(--radius-md); + font-size: 11px; + } + + /* Theme toggle */ + .theme-toggle { + --theme-item: 24px; + --theme-gap: 2px; + --theme-pad: 3px; + } + + .theme-icon { + width: 12px; + height: 12px; + } +} + +/* Small mobile */ +@media (max-width: 400px) { + .shell { + --shell-pad: 4px; + } + + .topbar { + padding: 8px 10px; + } + + .brand-title { + font-size: 13px; + } + + .nav { + padding: 6px 8px; + } + + .nav-item { + padding: 6px 8px; + font-size: 11px; + } + + .content { + padding: 4px 4px 12px; + gap: 10px; + } + + .card { + padding: 10px; + } + + .stat { + padding: 8px; + } + + .stat-value { + font-size: 16px; + } + + .chat-bubble { + padding: 8px 10px; + } + + .chat-compose__field textarea { + min-height: 52px; + padding: 8px 10px; + font-size: 13px; + } + + .btn { + padding: 6px 10px; + font-size: 11px; + } + + .topbar-status .pill { + padding: 3px 6px; + font-size: 10px; + } + + .theme-toggle { + --theme-item: 22px; + --theme-gap: 2px; + --theme-pad: 2px; + } + + .theme-icon { + width: 11px; + height: 11px; + } +} diff --git a/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-flags-unsupported-unions-1.png b/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-flags-unsupported-unions-1.png new file mode 100644 index 0000000000000000000000000000000000000000..850d5b364ecb7abd932e8634bc40b802f9c6729f Binary files /dev/null and b/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-flags-unsupported-unions-1.png differ diff --git a/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-inputs-and-patches-values-1.png b/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-inputs-and-patches-values-1.png new file mode 100644 index 0000000000000000000000000000000000000000..850d5b364ecb7abd932e8634bc40b802f9c6729f Binary files /dev/null and b/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-inputs-and-patches-values-1.png differ diff --git a/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-union-literals-as-select-options-1.png b/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-union-literals-as-select-options-1.png new file mode 100644 index 0000000000000000000000000000000000000000..850d5b364ecb7abd932e8634bc40b802f9c6729f Binary files /dev/null and b/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-union-literals-as-select-options-1.png differ diff --git a/ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png b/ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png new file mode 100644 index 0000000000000000000000000000000000000000..eae372b60fa88da755a37848a546623402e47f2f Binary files /dev/null and b/ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png differ diff --git a/ui/src/ui/app-channels.ts b/ui/src/ui/app-channels.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f139ea5a8ad11b89738302c4990deabe918f288 --- /dev/null +++ b/ui/src/ui/app-channels.ts @@ -0,0 +1,240 @@ +import type { OpenClawApp } from "./app"; +import type { NostrProfile } from "./types"; +import { + loadChannels, + logoutWhatsApp, + startWhatsAppLogin, + waitWhatsAppLogin, +} from "./controllers/channels"; +import { loadConfig, saveConfig } from "./controllers/config"; +import { createNostrProfileFormState } from "./views/channels.nostr-profile-form"; + +export async function handleWhatsAppStart(host: OpenClawApp, force: boolean) { + await startWhatsAppLogin(host, force); + await loadChannels(host, true); +} + +export async function handleWhatsAppWait(host: OpenClawApp) { + await waitWhatsAppLogin(host); + await loadChannels(host, true); +} + +export async function handleWhatsAppLogout(host: OpenClawApp) { + await logoutWhatsApp(host); + await loadChannels(host, true); +} + +export async function handleChannelConfigSave(host: OpenClawApp) { + await saveConfig(host); + await loadConfig(host); + await loadChannels(host, true); +} + +export async function handleChannelConfigReload(host: OpenClawApp) { + await loadConfig(host); + await loadChannels(host, true); +} + +function parseValidationErrors(details: unknown): Record { + if (!Array.isArray(details)) return {}; + const errors: Record = {}; + for (const entry of details) { + if (typeof entry !== "string") continue; + const [rawField, ...rest] = entry.split(":"); + if (!rawField || rest.length === 0) continue; + const field = rawField.trim(); + const message = rest.join(":").trim(); + if (field && message) errors[field] = message; + } + return errors; +} + +function resolveNostrAccountId(host: OpenClawApp): string { + const accounts = host.channelsSnapshot?.channelAccounts?.nostr ?? []; + return accounts[0]?.accountId ?? host.nostrProfileAccountId ?? "default"; +} + +function buildNostrProfileUrl(accountId: string, suffix = ""): string { + return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`; +} + +export function handleNostrProfileEdit( + host: OpenClawApp, + accountId: string, + profile: NostrProfile | null, +) { + host.nostrProfileAccountId = accountId; + host.nostrProfileFormState = createNostrProfileFormState(profile ?? undefined); +} + +export function handleNostrProfileCancel(host: OpenClawApp) { + host.nostrProfileFormState = null; + host.nostrProfileAccountId = null; +} + +export function handleNostrProfileFieldChange( + host: OpenClawApp, + field: keyof NostrProfile, + value: string, +) { + const state = host.nostrProfileFormState; + if (!state) return; + host.nostrProfileFormState = { + ...state, + values: { + ...state.values, + [field]: value, + }, + fieldErrors: { + ...state.fieldErrors, + [field]: "", + }, + }; +} + +export function handleNostrProfileToggleAdvanced(host: OpenClawApp) { + const state = host.nostrProfileFormState; + if (!state) return; + host.nostrProfileFormState = { + ...state, + showAdvanced: !state.showAdvanced, + }; +} + +export async function handleNostrProfileSave(host: OpenClawApp) { + const state = host.nostrProfileFormState; + if (!state || state.saving) return; + const accountId = resolveNostrAccountId(host); + + host.nostrProfileFormState = { + ...state, + saving: true, + error: null, + success: null, + fieldErrors: {}, + }; + + try { + const response = await fetch(buildNostrProfileUrl(accountId), { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(state.values), + }); + const data = (await response.json().catch(() => null)) as { + ok?: boolean; + error?: string; + details?: unknown; + persisted?: boolean; + } | null; + + if (!response.ok || data?.ok === false || !data) { + const errorMessage = data?.error ?? `Profile update failed (${response.status})`; + host.nostrProfileFormState = { + ...state, + saving: false, + error: errorMessage, + success: null, + fieldErrors: parseValidationErrors(data?.details), + }; + return; + } + + if (!data.persisted) { + host.nostrProfileFormState = { + ...state, + saving: false, + error: "Profile publish failed on all relays.", + success: null, + }; + return; + } + + host.nostrProfileFormState = { + ...state, + saving: false, + error: null, + success: "Profile published to relays.", + fieldErrors: {}, + original: { ...state.values }, + }; + await loadChannels(host, true); + } catch (err) { + host.nostrProfileFormState = { + ...state, + saving: false, + error: `Profile update failed: ${String(err)}`, + success: null, + }; + } +} + +export async function handleNostrProfileImport(host: OpenClawApp) { + const state = host.nostrProfileFormState; + if (!state || state.importing) return; + const accountId = resolveNostrAccountId(host); + + host.nostrProfileFormState = { + ...state, + importing: true, + error: null, + success: null, + }; + + try { + const response = await fetch(buildNostrProfileUrl(accountId, "/import"), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ autoMerge: true }), + }); + const data = (await response.json().catch(() => null)) as { + ok?: boolean; + error?: string; + imported?: NostrProfile; + merged?: NostrProfile; + saved?: boolean; + } | null; + + if (!response.ok || data?.ok === false || !data) { + const errorMessage = data?.error ?? `Profile import failed (${response.status})`; + host.nostrProfileFormState = { + ...state, + importing: false, + error: errorMessage, + success: null, + }; + return; + } + + const merged = data.merged ?? data.imported ?? null; + const nextValues = merged ? { ...state.values, ...merged } : state.values; + const showAdvanced = Boolean( + nextValues.banner || nextValues.website || nextValues.nip05 || nextValues.lud16, + ); + + host.nostrProfileFormState = { + ...state, + importing: false, + values: nextValues, + error: null, + success: data.saved + ? "Profile imported from relays. Review and publish." + : "Profile imported. Review and publish.", + showAdvanced, + }; + + if (data.saved) { + await loadChannels(host, true); + } + } catch (err) { + host.nostrProfileFormState = { + ...state, + importing: false, + error: `Profile import failed: ${String(err)}`, + success: null, + }; + } +} diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts new file mode 100644 index 0000000000000000000000000000000000000000..030b714679961e7c15339a183e4b2c3714f340fe --- /dev/null +++ b/ui/src/ui/app-chat.ts @@ -0,0 +1,242 @@ +import type { OpenClawApp } from "./app"; +import type { GatewayHelloOk } from "./gateway"; +import type { ChatAttachment, ChatQueueItem } from "./ui-types"; +import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js"; +import { scheduleChatScroll } from "./app-scroll"; +import { setLastActiveSessionKey } from "./app-settings"; +import { resetToolStream } from "./app-tool-stream"; +import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat"; +import { loadSessions } from "./controllers/sessions"; +import { normalizeBasePath } from "./navigation"; +import { generateUUID } from "./uuid"; + +type ChatHost = { + connected: boolean; + chatMessage: string; + chatAttachments: ChatAttachment[]; + chatQueue: ChatQueueItem[]; + chatRunId: string | null; + chatSending: boolean; + sessionKey: string; + basePath: string; + hello: GatewayHelloOk | null; + chatAvatarUrl: string | null; + refreshSessionsAfterChat: Set; +}; + +export const CHAT_SESSIONS_ACTIVE_MINUTES = 120; + +export function isChatBusy(host: ChatHost) { + return host.chatSending || Boolean(host.chatRunId); +} + +export function isChatStopCommand(text: string) { + const trimmed = text.trim(); + if (!trimmed) return false; + const normalized = trimmed.toLowerCase(); + if (normalized === "/stop") return true; + return ( + normalized === "stop" || + normalized === "esc" || + normalized === "abort" || + normalized === "wait" || + normalized === "exit" + ); +} + +function isChatResetCommand(text: string) { + const trimmed = text.trim(); + if (!trimmed) return false; + const normalized = trimmed.toLowerCase(); + if (normalized === "/new" || normalized === "/reset") return true; + return normalized.startsWith("/new ") || normalized.startsWith("/reset "); +} + +export async function handleAbortChat(host: ChatHost) { + if (!host.connected) return; + host.chatMessage = ""; + await abortChatRun(host as unknown as OpenClawApp); +} + +function enqueueChatMessage( + host: ChatHost, + text: string, + attachments?: ChatAttachment[], + refreshSessions?: boolean, +) { + const trimmed = text.trim(); + const hasAttachments = Boolean(attachments && attachments.length > 0); + if (!trimmed && !hasAttachments) return; + host.chatQueue = [ + ...host.chatQueue, + { + id: generateUUID(), + text: trimmed, + createdAt: Date.now(), + attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined, + refreshSessions, + }, + ]; +} + +async function sendChatMessageNow( + host: ChatHost, + message: string, + opts?: { + previousDraft?: string; + restoreDraft?: boolean; + attachments?: ChatAttachment[]; + previousAttachments?: ChatAttachment[]; + restoreAttachments?: boolean; + refreshSessions?: boolean; + }, +) { + resetToolStream(host as unknown as Parameters[0]); + const runId = await sendChatMessage(host as unknown as OpenClawApp, message, opts?.attachments); + const ok = Boolean(runId); + if (!ok && opts?.previousDraft != null) { + host.chatMessage = opts.previousDraft; + } + if (!ok && opts?.previousAttachments) { + host.chatAttachments = opts.previousAttachments; + } + if (ok) { + setLastActiveSessionKey( + host as unknown as Parameters[0], + host.sessionKey, + ); + } + if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) { + host.chatMessage = opts.previousDraft; + } + if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) { + host.chatAttachments = opts.previousAttachments; + } + scheduleChatScroll(host as unknown as Parameters[0]); + if (ok && !host.chatRunId) { + void flushChatQueue(host); + } + if (ok && opts?.refreshSessions && runId) { + host.refreshSessionsAfterChat.add(runId); + } + return ok; +} + +async function flushChatQueue(host: ChatHost) { + if (!host.connected || isChatBusy(host)) return; + const [next, ...rest] = host.chatQueue; + if (!next) return; + host.chatQueue = rest; + const ok = await sendChatMessageNow(host, next.text, { + attachments: next.attachments, + refreshSessions: next.refreshSessions, + }); + if (!ok) { + host.chatQueue = [next, ...host.chatQueue]; + } +} + +export function removeQueuedMessage(host: ChatHost, id: string) { + host.chatQueue = host.chatQueue.filter((item) => item.id !== id); +} + +export async function handleSendChat( + host: ChatHost, + messageOverride?: string, + opts?: { restoreDraft?: boolean }, +) { + if (!host.connected) return; + const previousDraft = host.chatMessage; + const message = (messageOverride ?? host.chatMessage).trim(); + const attachments = host.chatAttachments ?? []; + const attachmentsToSend = messageOverride == null ? attachments : []; + const hasAttachments = attachmentsToSend.length > 0; + + // Allow sending with just attachments (no message text required) + if (!message && !hasAttachments) return; + + if (isChatStopCommand(message)) { + await handleAbortChat(host); + return; + } + + const refreshSessions = isChatResetCommand(message); + if (messageOverride == null) { + host.chatMessage = ""; + // Clear attachments when sending + host.chatAttachments = []; + } + + if (isChatBusy(host)) { + enqueueChatMessage(host, message, attachmentsToSend, refreshSessions); + return; + } + + await sendChatMessageNow(host, message, { + previousDraft: messageOverride == null ? previousDraft : undefined, + restoreDraft: Boolean(messageOverride && opts?.restoreDraft), + attachments: hasAttachments ? attachmentsToSend : undefined, + previousAttachments: messageOverride == null ? attachments : undefined, + restoreAttachments: Boolean(messageOverride && opts?.restoreDraft), + refreshSessions, + }); +} + +export async function refreshChat(host: ChatHost) { + await Promise.all([ + loadChatHistory(host as unknown as OpenClawApp), + loadSessions(host as unknown as OpenClawApp, { + activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, + }), + refreshChatAvatar(host), + ]); + scheduleChatScroll(host as unknown as Parameters[0], true); +} + +export const flushChatQueueForEvent = flushChatQueue; + +type SessionDefaultsSnapshot = { + defaultAgentId?: string; +}; + +function resolveAgentIdForSession(host: ChatHost): string | null { + const parsed = parseAgentSessionKey(host.sessionKey); + if (parsed?.agentId) return parsed.agentId; + const snapshot = host.hello?.snapshot as + | { sessionDefaults?: SessionDefaultsSnapshot } + | undefined; + const fallback = snapshot?.sessionDefaults?.defaultAgentId?.trim(); + return fallback || "main"; +} + +function buildAvatarMetaUrl(basePath: string, agentId: string): string { + const base = normalizeBasePath(basePath); + const encoded = encodeURIComponent(agentId); + return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`; +} + +export async function refreshChatAvatar(host: ChatHost) { + if (!host.connected) { + host.chatAvatarUrl = null; + return; + } + const agentId = resolveAgentIdForSession(host); + if (!agentId) { + host.chatAvatarUrl = null; + return; + } + host.chatAvatarUrl = null; + const url = buildAvatarMetaUrl(host.basePath, agentId); + try { + const res = await fetch(url, { method: "GET" }); + if (!res.ok) { + host.chatAvatarUrl = null; + return; + } + const data = (await res.json()) as { avatarUrl?: unknown }; + const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : ""; + host.chatAvatarUrl = avatarUrl || null; + } catch { + host.chatAvatarUrl = null; + } +} diff --git a/ui/src/ui/app-defaults.ts b/ui/src/ui/app-defaults.ts new file mode 100644 index 0000000000000000000000000000000000000000..d863fa86308b2c76938fce049a5af006415dc178 --- /dev/null +++ b/ui/src/ui/app-defaults.ts @@ -0,0 +1,33 @@ +import type { LogLevel } from "./types"; +import type { CronFormState } from "./ui-types"; + +export const DEFAULT_LOG_LEVEL_FILTERS: Record = { + trace: true, + debug: true, + info: true, + warn: true, + error: true, + fatal: true, +}; + +export const DEFAULT_CRON_FORM: CronFormState = { + name: "", + description: "", + agentId: "", + enabled: true, + scheduleKind: "every", + scheduleAt: "", + everyAmount: "30", + everyUnit: "minutes", + cronExpr: "0 7 * * *", + cronTz: "", + sessionTarget: "main", + wakeMode: "next-heartbeat", + payloadKind: "systemEvent", + payloadText: "", + deliver: false, + channel: "last", + to: "", + timeoutSeconds: "", + postToMainPrefix: "", +}; diff --git a/ui/src/ui/app-events.ts b/ui/src/ui/app-events.ts new file mode 100644 index 0000000000000000000000000000000000000000..eda3a8e1634edfa94755270c772a391d3ab80a09 --- /dev/null +++ b/ui/src/ui/app-events.ts @@ -0,0 +1,5 @@ +export type EventLogEntry = { + ts: number; + event: string; + payload?: unknown; +}; diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts new file mode 100644 index 0000000000000000000000000000000000000000..fab712475316d432f46a0f39f9a9ee1ad08c2b41 --- /dev/null +++ b/ui/src/ui/app-gateway.ts @@ -0,0 +1,261 @@ +import type { OpenClawApp } from "./app"; +import type { EventLogEntry } from "./app-events"; +import type { ExecApprovalRequest } from "./controllers/exec-approval"; +import type { GatewayEventFrame, GatewayHelloOk } from "./gateway"; +import type { Tab } from "./navigation"; +import type { UiSettings } from "./storage"; +import type { AgentsListResult, PresenceEntry, HealthSnapshot, StatusSummary } from "./types"; +import { CHAT_SESSIONS_ACTIVE_MINUTES, flushChatQueueForEvent } from "./app-chat"; +import { applySettings, loadCron, refreshActiveTab, setLastActiveSessionKey } from "./app-settings"; +import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream"; +import { loadAgents } from "./controllers/agents"; +import { loadAssistantIdentity } from "./controllers/assistant-identity"; +import { loadChatHistory } from "./controllers/chat"; +import { handleChatEvent, type ChatEventPayload } from "./controllers/chat"; +import { loadDevices } from "./controllers/devices"; +import { + addExecApproval, + parseExecApprovalRequested, + parseExecApprovalResolved, + removeExecApproval, +} from "./controllers/exec-approval"; +import { loadNodes } from "./controllers/nodes"; +import { loadSessions } from "./controllers/sessions"; +import { GatewayBrowserClient } from "./gateway"; + +type GatewayHost = { + settings: UiSettings; + password: string; + client: GatewayBrowserClient | null; + connected: boolean; + hello: GatewayHelloOk | null; + lastError: string | null; + onboarding?: boolean; + eventLogBuffer: EventLogEntry[]; + eventLog: EventLogEntry[]; + tab: Tab; + presenceEntries: PresenceEntry[]; + presenceError: string | null; + presenceStatus: StatusSummary | null; + agentsLoading: boolean; + agentsList: AgentsListResult | null; + agentsError: string | null; + debugHealth: HealthSnapshot | null; + assistantName: string; + assistantAvatar: string | null; + assistantAgentId: string | null; + sessionKey: string; + chatRunId: string | null; + refreshSessionsAfterChat: Set; + execApprovalQueue: ExecApprovalRequest[]; + execApprovalError: string | null; +}; + +type SessionDefaultsSnapshot = { + defaultAgentId?: string; + mainKey?: string; + mainSessionKey?: string; + scope?: string; +}; + +function normalizeSessionKeyForDefaults( + value: string | undefined, + defaults: SessionDefaultsSnapshot, +): string { + const raw = (value ?? "").trim(); + const mainSessionKey = defaults.mainSessionKey?.trim(); + if (!mainSessionKey) return raw; + if (!raw) return mainSessionKey; + const mainKey = defaults.mainKey?.trim() || "main"; + const defaultAgentId = defaults.defaultAgentId?.trim(); + const isAlias = + raw === "main" || + raw === mainKey || + (defaultAgentId && + (raw === `agent:${defaultAgentId}:main` || raw === `agent:${defaultAgentId}:${mainKey}`)); + return isAlias ? mainSessionKey : raw; +} + +function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnapshot) { + if (!defaults?.mainSessionKey) return; + const resolvedSessionKey = normalizeSessionKeyForDefaults(host.sessionKey, defaults); + const resolvedSettingsSessionKey = normalizeSessionKeyForDefaults( + host.settings.sessionKey, + defaults, + ); + const resolvedLastActiveSessionKey = normalizeSessionKeyForDefaults( + host.settings.lastActiveSessionKey, + defaults, + ); + const nextSessionKey = resolvedSessionKey || resolvedSettingsSessionKey || host.sessionKey; + const nextSettings = { + ...host.settings, + sessionKey: resolvedSettingsSessionKey || nextSessionKey, + lastActiveSessionKey: resolvedLastActiveSessionKey || nextSessionKey, + }; + const shouldUpdateSettings = + nextSettings.sessionKey !== host.settings.sessionKey || + nextSettings.lastActiveSessionKey !== host.settings.lastActiveSessionKey; + if (nextSessionKey !== host.sessionKey) { + host.sessionKey = nextSessionKey; + } + if (shouldUpdateSettings) { + applySettings(host as unknown as Parameters[0], nextSettings); + } +} + +export function connectGateway(host: GatewayHost) { + host.lastError = null; + host.hello = null; + host.connected = false; + host.execApprovalQueue = []; + host.execApprovalError = null; + + host.client?.stop(); + host.client = new GatewayBrowserClient({ + url: host.settings.gatewayUrl, + token: host.settings.token.trim() ? host.settings.token : undefined, + password: host.password.trim() ? host.password : undefined, + clientName: "openclaw-control-ui", + mode: "webchat", + onHello: (hello) => { + host.connected = true; + host.lastError = null; + host.hello = hello; + applySnapshot(host, hello); + // Reset orphaned chat run state from before disconnect. + // Any in-flight run's final event was lost during the disconnect window. + host.chatRunId = null; + (host as unknown as { chatStream: string | null }).chatStream = null; + (host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null; + resetToolStream(host as unknown as Parameters[0]); + void loadAssistantIdentity(host as unknown as OpenClawApp); + void loadAgents(host as unknown as OpenClawApp); + void loadNodes(host as unknown as OpenClawApp, { quiet: true }); + void loadDevices(host as unknown as OpenClawApp, { quiet: true }); + void refreshActiveTab(host as unknown as Parameters[0]); + }, + onClose: ({ code, reason }) => { + host.connected = false; + // Code 1012 = Service Restart (expected during config saves, don't show as error) + if (code !== 1012) { + host.lastError = `disconnected (${code}): ${reason || "no reason"}`; + } + }, + onEvent: (evt) => handleGatewayEvent(host, evt), + onGap: ({ expected, received }) => { + host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`; + }, + }); + host.client.start(); +} + +export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) { + try { + handleGatewayEventUnsafe(host, evt); + } catch (err) { + console.error("[gateway] handleGatewayEvent error:", evt.event, err); + } +} + +function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { + host.eventLogBuffer = [ + { ts: Date.now(), event: evt.event, payload: evt.payload }, + ...host.eventLogBuffer, + ].slice(0, 250); + if (host.tab === "debug") { + host.eventLog = host.eventLogBuffer; + } + + if (evt.event === "agent") { + if (host.onboarding) return; + handleAgentEvent( + host as unknown as Parameters[0], + evt.payload as AgentEventPayload | undefined, + ); + return; + } + + if (evt.event === "chat") { + const payload = evt.payload as ChatEventPayload | undefined; + if (payload?.sessionKey) { + setLastActiveSessionKey( + host as unknown as Parameters[0], + payload.sessionKey, + ); + } + const state = handleChatEvent(host as unknown as OpenClawApp, payload); + if (state === "final" || state === "error" || state === "aborted") { + resetToolStream(host as unknown as Parameters[0]); + void flushChatQueueForEvent(host as unknown as Parameters[0]); + const runId = payload?.runId; + if (runId && host.refreshSessionsAfterChat.has(runId)) { + host.refreshSessionsAfterChat.delete(runId); + if (state === "final") { + void loadSessions(host as unknown as OpenClawApp, { + activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, + }); + } + } + } + if (state === "final") void loadChatHistory(host as unknown as OpenClawApp); + return; + } + + if (evt.event === "presence") { + const payload = evt.payload as { presence?: PresenceEntry[] } | undefined; + if (payload?.presence && Array.isArray(payload.presence)) { + host.presenceEntries = payload.presence; + host.presenceError = null; + host.presenceStatus = null; + } + return; + } + + if (evt.event === "cron" && host.tab === "cron") { + void loadCron(host as unknown as Parameters[0]); + } + + if (evt.event === "device.pair.requested" || evt.event === "device.pair.resolved") { + void loadDevices(host as unknown as OpenClawApp, { quiet: true }); + } + + if (evt.event === "exec.approval.requested") { + const entry = parseExecApprovalRequested(evt.payload); + if (entry) { + host.execApprovalQueue = addExecApproval(host.execApprovalQueue, entry); + host.execApprovalError = null; + const delay = Math.max(0, entry.expiresAtMs - Date.now() + 500); + window.setTimeout(() => { + host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, entry.id); + }, delay); + } + return; + } + + if (evt.event === "exec.approval.resolved") { + const resolved = parseExecApprovalResolved(evt.payload); + if (resolved) { + host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, resolved.id); + } + } +} + +export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { + const snapshot = hello.snapshot as + | { + presence?: PresenceEntry[]; + health?: HealthSnapshot; + sessionDefaults?: SessionDefaultsSnapshot; + } + | undefined; + if (snapshot?.presence && Array.isArray(snapshot.presence)) { + host.presenceEntries = snapshot.presence; + } + if (snapshot?.health) { + host.debugHealth = snapshot.health; + } + if (snapshot?.sessionDefaults) { + applySessionDefaults(host, snapshot.sessionDefaults); + } +} diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts new file mode 100644 index 0000000000000000000000000000000000000000..de0253399042f52a3f90ba1667b736433d456368 --- /dev/null +++ b/ui/src/ui/app-lifecycle.ts @@ -0,0 +1,97 @@ +import type { Tab } from "./navigation"; +import { connectGateway } from "./app-gateway"; +import { + startLogsPolling, + startNodesPolling, + stopLogsPolling, + stopNodesPolling, + startDebugPolling, + stopDebugPolling, +} from "./app-polling"; +import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll"; +import { + applySettingsFromUrl, + attachThemeListener, + detachThemeListener, + inferBasePath, + syncTabWithLocation, + syncThemeWithSettings, +} from "./app-settings"; + +type LifecycleHost = { + basePath: string; + tab: Tab; + chatHasAutoScrolled: boolean; + chatLoading: boolean; + chatMessages: unknown[]; + chatToolMessages: unknown[]; + chatStream: string; + logsAutoFollow: boolean; + logsAtBottom: boolean; + logsEntries: unknown[]; + popStateHandler: () => void; + topbarObserver: ResizeObserver | null; +}; + +export function handleConnected(host: LifecycleHost) { + host.basePath = inferBasePath(); + applySettingsFromUrl(host as unknown as Parameters[0]); + syncTabWithLocation(host as unknown as Parameters[0], true); + syncThemeWithSettings(host as unknown as Parameters[0]); + attachThemeListener(host as unknown as Parameters[0]); + window.addEventListener("popstate", host.popStateHandler); + connectGateway(host as unknown as Parameters[0]); + startNodesPolling(host as unknown as Parameters[0]); + if (host.tab === "logs") { + startLogsPolling(host as unknown as Parameters[0]); + } + if (host.tab === "debug") { + startDebugPolling(host as unknown as Parameters[0]); + } +} + +export function handleFirstUpdated(host: LifecycleHost) { + observeTopbar(host as unknown as Parameters[0]); +} + +export function handleDisconnected(host: LifecycleHost) { + window.removeEventListener("popstate", host.popStateHandler); + stopNodesPolling(host as unknown as Parameters[0]); + stopLogsPolling(host as unknown as Parameters[0]); + stopDebugPolling(host as unknown as Parameters[0]); + detachThemeListener(host as unknown as Parameters[0]); + host.topbarObserver?.disconnect(); + host.topbarObserver = null; +} + +export function handleUpdated(host: LifecycleHost, changed: Map) { + if ( + host.tab === "chat" && + (changed.has("chatMessages") || + changed.has("chatToolMessages") || + changed.has("chatStream") || + changed.has("chatLoading") || + changed.has("tab")) + ) { + const forcedByTab = changed.has("tab"); + const forcedByLoad = + changed.has("chatLoading") && + changed.get("chatLoading") === true && + host.chatLoading === false; + scheduleChatScroll( + host as unknown as Parameters[0], + forcedByTab || forcedByLoad || !host.chatHasAutoScrolled, + ); + } + if ( + host.tab === "logs" && + (changed.has("logsEntries") || changed.has("logsAutoFollow") || changed.has("tab")) + ) { + if (host.logsAutoFollow && host.logsAtBottom) { + scheduleLogsScroll( + host as unknown as Parameters[0], + changed.has("tab") || changed.has("logsAutoFollow"), + ); + } + } +} diff --git a/ui/src/ui/app-polling.ts b/ui/src/ui/app-polling.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0aa7c9d1d0dbd19df9d4d809edcbf1fa97845cc --- /dev/null +++ b/ui/src/ui/app-polling.ts @@ -0,0 +1,53 @@ +import type { OpenClawApp } from "./app"; +import { loadDebug } from "./controllers/debug"; +import { loadLogs } from "./controllers/logs"; +import { loadNodes } from "./controllers/nodes"; + +type PollingHost = { + nodesPollInterval: number | null; + logsPollInterval: number | null; + debugPollInterval: number | null; + tab: string; +}; + +export function startNodesPolling(host: PollingHost) { + if (host.nodesPollInterval != null) return; + host.nodesPollInterval = window.setInterval( + () => void loadNodes(host as unknown as OpenClawApp, { quiet: true }), + 5000, + ); +} + +export function stopNodesPolling(host: PollingHost) { + if (host.nodesPollInterval == null) return; + clearInterval(host.nodesPollInterval); + host.nodesPollInterval = null; +} + +export function startLogsPolling(host: PollingHost) { + if (host.logsPollInterval != null) return; + host.logsPollInterval = window.setInterval(() => { + if (host.tab !== "logs") return; + void loadLogs(host as unknown as OpenClawApp, { quiet: true }); + }, 2000); +} + +export function stopLogsPolling(host: PollingHost) { + if (host.logsPollInterval == null) return; + clearInterval(host.logsPollInterval); + host.logsPollInterval = null; +} + +export function startDebugPolling(host: PollingHost) { + if (host.debugPollInterval != null) return; + host.debugPollInterval = window.setInterval(() => { + if (host.tab !== "debug") return; + void loadDebug(host as unknown as OpenClawApp); + }, 3000); +} + +export function stopDebugPolling(host: PollingHost) { + if (host.debugPollInterval == null) return; + clearInterval(host.debugPollInterval); + host.debugPollInterval = null; +} diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2ff179616fa2eb7253ca31c65a428912daa53ca --- /dev/null +++ b/ui/src/ui/app-render.helpers.ts @@ -0,0 +1,330 @@ +import { html } from "lit"; +import { repeat } from "lit/directives/repeat.js"; +import type { AppViewState } from "./app-view-state"; +import type { ThemeMode } from "./theme"; +import type { ThemeTransitionContext } from "./theme-transition"; +import type { SessionsListResult } from "./types"; +import { refreshChat } from "./app-chat"; +import { syncUrlWithSessionKey } from "./app-settings"; +import { loadChatHistory } from "./controllers/chat"; +import { icons } from "./icons"; +import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation"; + +export function renderTab(state: AppViewState, tab: Tab) { + const href = pathForTab(tab, state.basePath); + return html` + { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + event.preventDefault(); + state.setTab(tab); + }} + title=${titleForTab(tab)} + > + + ${titleForTab(tab)} + + `; +} + +export function renderChatControls(state: AppViewState) { + const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult); + const sessionOptions = resolveSessionOptions( + state.sessionKey, + state.sessionsResult, + mainSessionKey, + ); + const disableThinkingToggle = state.onboarding; + const disableFocusToggle = state.onboarding; + const showThinking = state.onboarding ? false : state.settings.chatShowThinking; + const focusActive = state.onboarding ? true : state.settings.chatFocusMode; + // Refresh icon + const refreshIcon = html` + + + + + `; + const focusIcon = html` + + + + + + + + `; + return html` +
      + + + | + + +
      + `; +} + +type SessionDefaultsSnapshot = { + mainSessionKey?: string; + mainKey?: string; +}; + +function resolveMainSessionKey( + hello: AppViewState["hello"], + sessions: SessionsListResult | null, +): string | null { + const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined; + const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim(); + if (mainSessionKey) return mainSessionKey; + const mainKey = snapshot?.sessionDefaults?.mainKey?.trim(); + if (mainKey) return mainKey; + if (sessions?.sessions?.some((row) => row.key === "main")) return "main"; + return null; +} + +function resolveSessionDisplayName(key: string, row?: SessionsListResult["sessions"][number]) { + const label = row?.label?.trim(); + if (label) return `${label} (${key})`; + const displayName = row?.displayName?.trim(); + if (displayName) return displayName; + return key; +} + +function resolveSessionOptions( + sessionKey: string, + sessions: SessionsListResult | null, + mainSessionKey?: string | null, +) { + const seen = new Set(); + const options: Array<{ key: string; displayName?: string }> = []; + + const resolvedMain = mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey); + const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey); + + // Add main session key first + if (mainSessionKey) { + seen.add(mainSessionKey); + options.push({ + key: mainSessionKey, + displayName: resolveSessionDisplayName(mainSessionKey, resolvedMain), + }); + } + + // Add current session key next + if (!seen.has(sessionKey)) { + seen.add(sessionKey); + options.push({ + key: sessionKey, + displayName: resolveSessionDisplayName(sessionKey, resolvedCurrent), + }); + } + + // Add sessions from the result + if (sessions?.sessions) { + for (const s of sessions.sessions) { + if (!seen.has(s.key)) { + seen.add(s.key); + options.push({ + key: s.key, + displayName: resolveSessionDisplayName(s.key, s), + }); + } + } + } + + return options; +} + +const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"]; + +export function renderThemeToggle(state: AppViewState) { + const index = Math.max(0, THEME_ORDER.indexOf(state.theme)); + const applyTheme = (next: ThemeMode) => (event: MouseEvent) => { + const element = event.currentTarget as HTMLElement; + const context: ThemeTransitionContext = { element }; + if (event.clientX || event.clientY) { + context.pointerClientX = event.clientX; + context.pointerClientY = event.clientY; + } + state.setTheme(next, context); + }; + + return html` +
      +
      + + + + +
      +
      + `; +} + +function renderSunIcon() { + return html` + + `; +} + +function renderMoonIcon() { + return html` + + `; +} + +function renderMonitorIcon() { + return html` + + `; +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts new file mode 100644 index 0000000000000000000000000000000000000000..31abb5881c87090bf5fbd770d62900d79ab57bf3 --- /dev/null +++ b/ui/src/ui/app-render.ts @@ -0,0 +1,606 @@ +import { html, nothing } from "lit"; +import type { AppViewState } from "./app-view-state"; +import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; +import type { UiSettings } from "./storage"; +import type { ThemeMode } from "./theme"; +import type { ThemeTransitionContext } from "./theme-transition"; +import type { + ConfigSnapshot, + CronJob, + CronRunLogEntry, + CronStatus, + HealthSnapshot, + LogEntry, + LogLevel, + PresenceEntry, + ChannelsStatusSnapshot, + SessionsListResult, + SkillStatusReport, + StatusSummary, +} from "./types"; +import type { ChatQueueItem, CronFormState } from "./ui-types"; +import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; +import { refreshChatAvatar } from "./app-chat"; +import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers"; +import { loadChannels } from "./controllers/channels"; +import { loadChatHistory } from "./controllers/chat"; +import { + applyConfig, + loadConfig, + runUpdate, + saveConfig, + updateConfigFormValue, + removeConfigFormValue, +} from "./controllers/config"; +import { + loadCronRuns, + toggleCronJob, + runCronJob, + removeCronJob, + addCronJob, +} from "./controllers/cron"; +import { loadDebug, callDebugMethod } from "./controllers/debug"; +import { + approveDevicePairing, + loadDevices, + rejectDevicePairing, + revokeDeviceToken, + rotateDeviceToken, +} from "./controllers/devices"; +import { + loadExecApprovals, + removeExecApprovalsFormValue, + saveExecApprovals, + updateExecApprovalsFormValue, +} from "./controllers/exec-approvals"; +import { loadLogs } from "./controllers/logs"; +import { loadNodes } from "./controllers/nodes"; +import { loadPresence } from "./controllers/presence"; +import { deleteSession, loadSessions, patchSession } from "./controllers/sessions"; +import { + installSkill, + loadSkills, + saveSkillApiKey, + updateSkillEdit, + updateSkillEnabled, + type SkillMessage, +} from "./controllers/skills"; +import { icons } from "./icons"; +import { + TAB_GROUPS, + iconForTab, + pathForTab, + subtitleForTab, + titleForTab, + type Tab, +} from "./navigation"; +import { renderChannels } from "./views/channels"; +import { renderChat } from "./views/chat"; +import { renderConfig } from "./views/config"; +import { renderCron } from "./views/cron"; +import { renderDebug } from "./views/debug"; +import { renderExecApprovalPrompt } from "./views/exec-approval"; +import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation"; +import { renderInstances } from "./views/instances"; +import { renderLogs } from "./views/logs"; +import { renderNodes } from "./views/nodes"; +import { renderOverview } from "./views/overview"; +import { renderSessions } from "./views/sessions"; +import { renderSkills } from "./views/skills"; + +const AVATAR_DATA_RE = /^data:/i; +const AVATAR_HTTP_RE = /^https?:\/\//i; + +function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { + const list = state.agentsList?.agents ?? []; + const parsed = parseAgentSessionKey(state.sessionKey); + const agentId = parsed?.agentId ?? state.agentsList?.defaultId ?? "main"; + const agent = list.find((entry) => entry.id === agentId); + const identity = agent?.identity; + const candidate = identity?.avatarUrl ?? identity?.avatar; + if (!candidate) return undefined; + if (AVATAR_DATA_RE.test(candidate) || AVATAR_HTTP_RE.test(candidate)) return candidate; + return identity?.avatarUrl; +} + +export function renderApp(state: AppViewState) { + const presenceCount = state.presenceEntries.length; + const sessionsCount = state.sessionsResult?.count ?? null; + const cronNext = state.cronStatus?.nextWakeAtMs ?? null; + const chatDisabledReason = state.connected ? null : "Disconnected from gateway."; + const isChat = state.tab === "chat"; + const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding); + const showThinking = state.onboarding ? false : state.settings.chatShowThinking; + const assistantAvatarUrl = resolveAssistantAvatarUrl(state); + const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null; + + return html` +
      +
      +
      + +
      + +
      +
      OPENCLAW
      +
      Gateway Dashboard
      +
      +
      +
      +
      +
      + + Health + ${state.connected ? "OK" : "Offline"} +
      + ${renderThemeToggle(state)} +
      +
      + +
      +
      +
      +
      ${titleForTab(state.tab)}
      +
      ${subtitleForTab(state.tab)}
      +
      +
      + ${state.lastError ? html`
      ${state.lastError}
      ` : nothing} + ${isChat ? renderChatControls(state) : nothing} +
      +
      + + ${ + state.tab === "overview" + ? renderOverview({ + connected: state.connected, + hello: state.hello, + settings: state.settings, + password: state.password, + lastError: state.lastError, + presenceCount, + sessionsCount, + cronEnabled: state.cronStatus?.enabled ?? null, + cronNext, + lastChannelsRefresh: state.channelsLastSuccess, + onSettingsChange: (next) => state.applySettings(next), + onPasswordChange: (next) => (state.password = next), + onSessionKeyChange: (next) => { + state.sessionKey = next; + state.chatMessage = ""; + state.resetToolStream(); + state.applySettings({ + ...state.settings, + sessionKey: next, + lastActiveSessionKey: next, + }); + void state.loadAssistantIdentity(); + }, + onConnect: () => state.connect(), + onRefresh: () => state.loadOverview(), + }) + : nothing + } + + ${ + state.tab === "channels" + ? renderChannels({ + connected: state.connected, + loading: state.channelsLoading, + snapshot: state.channelsSnapshot, + lastError: state.channelsError, + lastSuccessAt: state.channelsLastSuccess, + whatsappMessage: state.whatsappLoginMessage, + whatsappQrDataUrl: state.whatsappLoginQrDataUrl, + whatsappConnected: state.whatsappLoginConnected, + whatsappBusy: state.whatsappBusy, + configSchema: state.configSchema, + configSchemaLoading: state.configSchemaLoading, + configForm: state.configForm, + configUiHints: state.configUiHints, + configSaving: state.configSaving, + configFormDirty: state.configFormDirty, + nostrProfileFormState: state.nostrProfileFormState, + nostrProfileAccountId: state.nostrProfileAccountId, + onRefresh: (probe) => loadChannels(state, probe), + onWhatsAppStart: (force) => state.handleWhatsAppStart(force), + onWhatsAppWait: () => state.handleWhatsAppWait(), + onWhatsAppLogout: () => state.handleWhatsAppLogout(), + onConfigPatch: (path, value) => updateConfigFormValue(state, path, value), + onConfigSave: () => state.handleChannelConfigSave(), + onConfigReload: () => state.handleChannelConfigReload(), + onNostrProfileEdit: (accountId, profile) => + state.handleNostrProfileEdit(accountId, profile), + onNostrProfileCancel: () => state.handleNostrProfileCancel(), + onNostrProfileFieldChange: (field, value) => + state.handleNostrProfileFieldChange(field, value), + onNostrProfileSave: () => state.handleNostrProfileSave(), + onNostrProfileImport: () => state.handleNostrProfileImport(), + onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(), + }) + : nothing + } + + ${ + state.tab === "instances" + ? renderInstances({ + loading: state.presenceLoading, + entries: state.presenceEntries, + lastError: state.presenceError, + statusMessage: state.presenceStatus, + onRefresh: () => loadPresence(state), + }) + : nothing + } + + ${ + state.tab === "sessions" + ? renderSessions({ + loading: state.sessionsLoading, + result: state.sessionsResult, + error: state.sessionsError, + activeMinutes: state.sessionsFilterActive, + limit: state.sessionsFilterLimit, + includeGlobal: state.sessionsIncludeGlobal, + includeUnknown: state.sessionsIncludeUnknown, + basePath: state.basePath, + onFiltersChange: (next) => { + state.sessionsFilterActive = next.activeMinutes; + state.sessionsFilterLimit = next.limit; + state.sessionsIncludeGlobal = next.includeGlobal; + state.sessionsIncludeUnknown = next.includeUnknown; + }, + onRefresh: () => loadSessions(state), + onPatch: (key, patch) => patchSession(state, key, patch), + onDelete: (key) => deleteSession(state, key), + }) + : nothing + } + + ${ + state.tab === "cron" + ? renderCron({ + loading: state.cronLoading, + status: state.cronStatus, + jobs: state.cronJobs, + error: state.cronError, + busy: state.cronBusy, + form: state.cronForm, + channels: state.channelsSnapshot?.channelMeta?.length + ? state.channelsSnapshot.channelMeta.map((entry) => entry.id) + : (state.channelsSnapshot?.channelOrder ?? []), + channelLabels: state.channelsSnapshot?.channelLabels ?? {}, + channelMeta: state.channelsSnapshot?.channelMeta ?? [], + runsJobId: state.cronRunsJobId, + runs: state.cronRuns, + onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }), + onRefresh: () => state.loadCron(), + onAdd: () => addCronJob(state), + onToggle: (job, enabled) => toggleCronJob(state, job, enabled), + onRun: (job) => runCronJob(state, job), + onRemove: (job) => removeCronJob(state, job), + onLoadRuns: (jobId) => loadCronRuns(state, jobId), + }) + : nothing + } + + ${ + state.tab === "skills" + ? renderSkills({ + loading: state.skillsLoading, + report: state.skillsReport, + error: state.skillsError, + filter: state.skillsFilter, + edits: state.skillEdits, + messages: state.skillMessages, + busyKey: state.skillsBusyKey, + onFilterChange: (next) => (state.skillsFilter = next), + onRefresh: () => loadSkills(state, { clearMessages: true }), + onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled), + onEdit: (key, value) => updateSkillEdit(state, key, value), + onSaveKey: (key) => saveSkillApiKey(state, key), + onInstall: (skillKey, name, installId) => + installSkill(state, skillKey, name, installId), + }) + : nothing + } + + ${ + state.tab === "nodes" + ? renderNodes({ + loading: state.nodesLoading, + nodes: state.nodes, + devicesLoading: state.devicesLoading, + devicesError: state.devicesError, + devicesList: state.devicesList, + configForm: + state.configForm ?? + (state.configSnapshot?.config as Record | null), + configLoading: state.configLoading, + configSaving: state.configSaving, + configDirty: state.configFormDirty, + configFormMode: state.configFormMode, + execApprovalsLoading: state.execApprovalsLoading, + execApprovalsSaving: state.execApprovalsSaving, + execApprovalsDirty: state.execApprovalsDirty, + execApprovalsSnapshot: state.execApprovalsSnapshot, + execApprovalsForm: state.execApprovalsForm, + execApprovalsSelectedAgent: state.execApprovalsSelectedAgent, + execApprovalsTarget: state.execApprovalsTarget, + execApprovalsTargetNodeId: state.execApprovalsTargetNodeId, + onRefresh: () => loadNodes(state), + onDevicesRefresh: () => loadDevices(state), + onDeviceApprove: (requestId) => approveDevicePairing(state, requestId), + onDeviceReject: (requestId) => rejectDevicePairing(state, requestId), + onDeviceRotate: (deviceId, role, scopes) => + rotateDeviceToken(state, { deviceId, role, scopes }), + onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }), + onLoadConfig: () => loadConfig(state), + onLoadExecApprovals: () => { + const target = + state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId + ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } + : { kind: "gateway" as const }; + return loadExecApprovals(state, target); + }, + onBindDefault: (nodeId) => { + if (nodeId) { + updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); + } else { + removeConfigFormValue(state, ["tools", "exec", "node"]); + } + }, + onBindAgent: (agentIndex, nodeId) => { + const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"]; + if (nodeId) { + updateConfigFormValue(state, basePath, nodeId); + } else { + removeConfigFormValue(state, basePath); + } + }, + onSaveBindings: () => saveConfig(state), + onExecApprovalsTargetChange: (kind, nodeId) => { + state.execApprovalsTarget = kind; + state.execApprovalsTargetNodeId = nodeId; + state.execApprovalsSnapshot = null; + state.execApprovalsForm = null; + state.execApprovalsDirty = false; + state.execApprovalsSelectedAgent = null; + }, + onExecApprovalsSelectAgent: (agentId) => { + state.execApprovalsSelectedAgent = agentId; + }, + onExecApprovalsPatch: (path, value) => + updateExecApprovalsFormValue(state, path, value), + onExecApprovalsRemove: (path) => removeExecApprovalsFormValue(state, path), + onSaveExecApprovals: () => { + const target = + state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId + ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } + : { kind: "gateway" as const }; + return saveExecApprovals(state, target); + }, + }) + : nothing + } + + ${ + state.tab === "chat" + ? renderChat({ + sessionKey: state.sessionKey, + onSessionKeyChange: (next) => { + state.sessionKey = next; + state.chatMessage = ""; + state.chatAttachments = []; + state.chatStream = null; + state.chatStreamStartedAt = null; + state.chatRunId = null; + state.chatQueue = []; + state.resetToolStream(); + state.resetChatScroll(); + state.applySettings({ + ...state.settings, + sessionKey: next, + lastActiveSessionKey: next, + }); + void state.loadAssistantIdentity(); + void loadChatHistory(state); + void refreshChatAvatar(state); + }, + thinkingLevel: state.chatThinkingLevel, + showThinking, + loading: state.chatLoading, + sending: state.chatSending, + compactionStatus: state.compactionStatus, + assistantAvatarUrl: chatAvatarUrl, + messages: state.chatMessages, + toolMessages: state.chatToolMessages, + stream: state.chatStream, + streamStartedAt: state.chatStreamStartedAt, + draft: state.chatMessage, + queue: state.chatQueue, + connected: state.connected, + canSend: state.connected, + disabledReason: chatDisabledReason, + error: state.lastError, + sessions: state.sessionsResult, + focusMode: chatFocus, + onRefresh: () => { + state.resetToolStream(); + return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); + }, + onToggleFocusMode: () => { + if (state.onboarding) return; + state.applySettings({ + ...state.settings, + chatFocusMode: !state.settings.chatFocusMode, + }); + }, + onChatScroll: (event) => state.handleChatScroll(event), + onDraftChange: (next) => (state.chatMessage = next), + attachments: state.chatAttachments, + onAttachmentsChange: (next) => (state.chatAttachments = next), + onSend: () => state.handleSendChat(), + canAbort: Boolean(state.chatRunId), + onAbort: () => void state.handleAbortChat(), + onQueueRemove: (id) => state.removeQueuedMessage(id), + onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }), + // Sidebar props for tool output viewing + sidebarOpen: state.sidebarOpen, + sidebarContent: state.sidebarContent, + sidebarError: state.sidebarError, + splitRatio: state.splitRatio, + onOpenSidebar: (content: string) => state.handleOpenSidebar(content), + onCloseSidebar: () => state.handleCloseSidebar(), + onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), + assistantName: state.assistantName, + assistantAvatar: state.assistantAvatar, + }) + : nothing + } + + ${ + state.tab === "config" + ? renderConfig({ + raw: state.configRaw, + originalRaw: state.configRawOriginal, + valid: state.configValid, + issues: state.configIssues, + loading: state.configLoading, + saving: state.configSaving, + applying: state.configApplying, + updating: state.updateRunning, + connected: state.connected, + schema: state.configSchema, + schemaLoading: state.configSchemaLoading, + uiHints: state.configUiHints, + formMode: state.configFormMode, + formValue: state.configForm, + originalValue: state.configFormOriginal, + searchQuery: state.configSearchQuery, + activeSection: state.configActiveSection, + activeSubsection: state.configActiveSubsection, + onRawChange: (next) => { + state.configRaw = next; + }, + onFormModeChange: (mode) => (state.configFormMode = mode), + onFormPatch: (path, value) => updateConfigFormValue(state, path, value), + onSearchChange: (query) => (state.configSearchQuery = query), + onSectionChange: (section) => { + state.configActiveSection = section; + state.configActiveSubsection = null; + }, + onSubsectionChange: (section) => (state.configActiveSubsection = section), + onReload: () => loadConfig(state), + onSave: () => saveConfig(state), + onApply: () => applyConfig(state), + onUpdate: () => runUpdate(state), + }) + : nothing + } + + ${ + state.tab === "debug" + ? renderDebug({ + loading: state.debugLoading, + status: state.debugStatus, + health: state.debugHealth, + models: state.debugModels, + heartbeat: state.debugHeartbeat, + eventLog: state.eventLog, + callMethod: state.debugCallMethod, + callParams: state.debugCallParams, + callResult: state.debugCallResult, + callError: state.debugCallError, + onCallMethodChange: (next) => (state.debugCallMethod = next), + onCallParamsChange: (next) => (state.debugCallParams = next), + onRefresh: () => loadDebug(state), + onCall: () => callDebugMethod(state), + }) + : nothing + } + + ${ + state.tab === "logs" + ? renderLogs({ + loading: state.logsLoading, + error: state.logsError, + file: state.logsFile, + entries: state.logsEntries, + filterText: state.logsFilterText, + levelFilters: state.logsLevelFilters, + autoFollow: state.logsAutoFollow, + truncated: state.logsTruncated, + onFilterTextChange: (next) => (state.logsFilterText = next), + onLevelToggle: (level, enabled) => { + state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled }; + }, + onToggleAutoFollow: (next) => (state.logsAutoFollow = next), + onRefresh: () => loadLogs(state, { reset: true }), + onExport: (lines, label) => state.exportLogs(lines, label), + onScroll: (event) => state.handleLogsScroll(event), + }) + : nothing + } +
      + ${renderExecApprovalPrompt(state)} + ${renderGatewayUrlConfirmation(state)} +
      + `; +} diff --git a/ui/src/ui/app-scroll.ts b/ui/src/ui/app-scroll.ts new file mode 100644 index 0000000000000000000000000000000000000000..36977047d4e4b80c1b385cf604aa7dba3fdbf527 --- /dev/null +++ b/ui/src/ui/app-scroll.ts @@ -0,0 +1,118 @@ +type ScrollHost = { + updateComplete: Promise; + querySelector: (selectors: string) => Element | null; + style: CSSStyleDeclaration; + chatScrollFrame: number | null; + chatScrollTimeout: number | null; + chatHasAutoScrolled: boolean; + chatUserNearBottom: boolean; + logsScrollFrame: number | null; + logsAtBottom: boolean; + topbarObserver: ResizeObserver | null; +}; + +export function scheduleChatScroll(host: ScrollHost, force = false) { + if (host.chatScrollFrame) cancelAnimationFrame(host.chatScrollFrame); + if (host.chatScrollTimeout != null) { + clearTimeout(host.chatScrollTimeout); + host.chatScrollTimeout = null; + } + const pickScrollTarget = () => { + const container = host.querySelector(".chat-thread") as HTMLElement | null; + if (container) { + const overflowY = getComputedStyle(container).overflowY; + const canScroll = + overflowY === "auto" || + overflowY === "scroll" || + container.scrollHeight - container.clientHeight > 1; + if (canScroll) return container; + } + return (document.scrollingElement ?? document.documentElement) as HTMLElement | null; + }; + // Wait for Lit render to complete, then scroll + void host.updateComplete.then(() => { + host.chatScrollFrame = requestAnimationFrame(() => { + host.chatScrollFrame = null; + const target = pickScrollTarget(); + if (!target) return; + const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight; + const shouldStick = force || host.chatUserNearBottom || distanceFromBottom < 200; + if (!shouldStick) return; + if (force) host.chatHasAutoScrolled = true; + target.scrollTop = target.scrollHeight; + host.chatUserNearBottom = true; + const retryDelay = force ? 150 : 120; + host.chatScrollTimeout = window.setTimeout(() => { + host.chatScrollTimeout = null; + const latest = pickScrollTarget(); + if (!latest) return; + const latestDistanceFromBottom = + latest.scrollHeight - latest.scrollTop - latest.clientHeight; + const shouldStickRetry = force || host.chatUserNearBottom || latestDistanceFromBottom < 200; + if (!shouldStickRetry) return; + latest.scrollTop = latest.scrollHeight; + host.chatUserNearBottom = true; + }, retryDelay); + }); + }); +} + +export function scheduleLogsScroll(host: ScrollHost, force = false) { + if (host.logsScrollFrame) cancelAnimationFrame(host.logsScrollFrame); + void host.updateComplete.then(() => { + host.logsScrollFrame = requestAnimationFrame(() => { + host.logsScrollFrame = null; + const container = host.querySelector(".log-stream") as HTMLElement | null; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + const shouldStick = force || distanceFromBottom < 80; + if (!shouldStick) return; + container.scrollTop = container.scrollHeight; + }); + }); +} + +export function handleChatScroll(host: ScrollHost, event: Event) { + const container = event.currentTarget as HTMLElement | null; + if (!container) return; + const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; + host.chatUserNearBottom = distanceFromBottom < 200; +} + +export function handleLogsScroll(host: ScrollHost, event: Event) { + const container = event.currentTarget as HTMLElement | null; + if (!container) return; + const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; + host.logsAtBottom = distanceFromBottom < 80; +} + +export function resetChatScroll(host: ScrollHost) { + host.chatHasAutoScrolled = false; + host.chatUserNearBottom = true; +} + +export function exportLogs(lines: string[], label: string) { + if (lines.length === 0) return; + const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); + anchor.href = url; + anchor.download = `openclaw-logs-${label}-${stamp}.log`; + anchor.click(); + URL.revokeObjectURL(url); +} + +export function observeTopbar(host: ScrollHost) { + if (typeof ResizeObserver === "undefined") return; + const topbar = host.querySelector(".topbar"); + if (!topbar) return; + const update = () => { + const { height } = topbar.getBoundingClientRect(); + host.style.setProperty("--topbar-height", `${height}px`); + }; + update(); + host.topbarObserver = new ResizeObserver(() => update()); + host.topbarObserver.observe(topbar); +} diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..33c87cf37fb76d7646d5d4895f30b54ea6bab713 --- /dev/null +++ b/ui/src/ui/app-settings.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Tab } from "./navigation"; +import { setTabFromRoute } from "./app-settings"; + +type SettingsHost = Parameters[0] & { + logsPollInterval: number | null; + debugPollInterval: number | null; +}; + +const createHost = (tab: Tab): SettingsHost => ({ + settings: { + gatewayUrl: "", + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }, + theme: "system", + themeResolved: "dark", + applySessionKey: "main", + sessionKey: "main", + tab, + connected: false, + chatHasAutoScrolled: false, + logsAtBottom: false, + eventLog: [], + eventLogBuffer: [], + basePath: "", + themeMedia: null, + themeMediaHandler: null, + logsPollInterval: null, + debugPollInterval: null, +}); + +describe("setTabFromRoute", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts and stops log polling based on the tab", () => { + const host = createHost("chat"); + + setTabFromRoute(host, "logs"); + expect(host.logsPollInterval).not.toBeNull(); + expect(host.debugPollInterval).toBeNull(); + + setTabFromRoute(host, "chat"); + expect(host.logsPollInterval).toBeNull(); + }); + + it("starts and stops debug polling based on the tab", () => { + const host = createHost("chat"); + + setTabFromRoute(host, "debug"); + expect(host.debugPollInterval).not.toBeNull(); + expect(host.logsPollInterval).toBeNull(); + + setTabFromRoute(host, "chat"); + expect(host.debugPollInterval).toBeNull(); + }); +}); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..e821c6bfecc499177f67911525481c8cc5434a70 --- /dev/null +++ b/ui/src/ui/app-settings.ts @@ -0,0 +1,332 @@ +import type { OpenClawApp } from "./app"; +import { refreshChat } from "./app-chat"; +import { + startLogsPolling, + stopLogsPolling, + startDebugPolling, + stopDebugPolling, +} from "./app-polling"; +import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll"; +import { loadChannels } from "./controllers/channels"; +import { loadConfig, loadConfigSchema } from "./controllers/config"; +import { loadCronJobs, loadCronStatus } from "./controllers/cron"; +import { loadDebug } from "./controllers/debug"; +import { loadDevices } from "./controllers/devices"; +import { loadExecApprovals } from "./controllers/exec-approvals"; +import { loadLogs } from "./controllers/logs"; +import { loadNodes } from "./controllers/nodes"; +import { loadPresence } from "./controllers/presence"; +import { loadSessions } from "./controllers/sessions"; +import { loadSkills } from "./controllers/skills"; +import { + inferBasePathFromPathname, + normalizeBasePath, + normalizePath, + pathForTab, + tabFromPath, + type Tab, +} from "./navigation"; +import { saveSettings, type UiSettings } from "./storage"; +import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme"; +import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition"; + +type SettingsHost = { + settings: UiSettings; + theme: ThemeMode; + themeResolved: ResolvedTheme; + applySessionKey: string; + sessionKey: string; + tab: Tab; + connected: boolean; + chatHasAutoScrolled: boolean; + logsAtBottom: boolean; + eventLog: unknown[]; + eventLogBuffer: unknown[]; + basePath: string; + themeMedia: MediaQueryList | null; + themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; + pendingGatewayUrl?: string | null; +}; + +export function applySettings(host: SettingsHost, next: UiSettings) { + const normalized = { + ...next, + lastActiveSessionKey: next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main", + }; + host.settings = normalized; + saveSettings(normalized); + if (next.theme !== host.theme) { + host.theme = next.theme; + applyResolvedTheme(host, resolveTheme(next.theme)); + } + host.applySessionKey = host.settings.lastActiveSessionKey; +} + +export function setLastActiveSessionKey(host: SettingsHost, next: string) { + const trimmed = next.trim(); + if (!trimmed) return; + if (host.settings.lastActiveSessionKey === trimmed) return; + applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed }); +} + +export function applySettingsFromUrl(host: SettingsHost) { + if (!window.location.search) return; + const params = new URLSearchParams(window.location.search); + const tokenRaw = params.get("token"); + const passwordRaw = params.get("password"); + const sessionRaw = params.get("session"); + const gatewayUrlRaw = params.get("gatewayUrl"); + let shouldCleanUrl = false; + + if (tokenRaw != null) { + const token = tokenRaw.trim(); + if (token && token !== host.settings.token) { + applySettings(host, { ...host.settings, token }); + } + params.delete("token"); + shouldCleanUrl = true; + } + + if (passwordRaw != null) { + const password = passwordRaw.trim(); + if (password) { + (host as { password: string }).password = password; + } + params.delete("password"); + shouldCleanUrl = true; + } + + if (sessionRaw != null) { + const session = sessionRaw.trim(); + if (session) { + host.sessionKey = session; + applySettings(host, { + ...host.settings, + sessionKey: session, + lastActiveSessionKey: session, + }); + } + } + + if (gatewayUrlRaw != null) { + const gatewayUrl = gatewayUrlRaw.trim(); + if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) { + host.pendingGatewayUrl = gatewayUrl; + } + params.delete("gatewayUrl"); + shouldCleanUrl = true; + } + + if (!shouldCleanUrl) return; + const url = new URL(window.location.href); + url.search = params.toString(); + window.history.replaceState({}, "", url.toString()); +} + +export function setTab(host: SettingsHost, next: Tab) { + if (host.tab !== next) host.tab = next; + if (next === "chat") host.chatHasAutoScrolled = false; + if (next === "logs") startLogsPolling(host as unknown as Parameters[0]); + else stopLogsPolling(host as unknown as Parameters[0]); + if (next === "debug") + startDebugPolling(host as unknown as Parameters[0]); + else stopDebugPolling(host as unknown as Parameters[0]); + void refreshActiveTab(host); + syncUrlWithTab(host, next, false); +} + +export function setTheme(host: SettingsHost, next: ThemeMode, context?: ThemeTransitionContext) { + const applyTheme = () => { + host.theme = next; + applySettings(host, { ...host.settings, theme: next }); + applyResolvedTheme(host, resolveTheme(next)); + }; + startThemeTransition({ + nextTheme: next, + applyTheme, + context, + currentTheme: host.theme, + }); +} + +export async function refreshActiveTab(host: SettingsHost) { + if (host.tab === "overview") await loadOverview(host); + if (host.tab === "channels") await loadChannelsTab(host); + if (host.tab === "instances") await loadPresence(host as unknown as OpenClawApp); + if (host.tab === "sessions") await loadSessions(host as unknown as OpenClawApp); + if (host.tab === "cron") await loadCron(host); + if (host.tab === "skills") await loadSkills(host as unknown as OpenClawApp); + if (host.tab === "nodes") { + await loadNodes(host as unknown as OpenClawApp); + await loadDevices(host as unknown as OpenClawApp); + await loadConfig(host as unknown as OpenClawApp); + await loadExecApprovals(host as unknown as OpenClawApp); + } + if (host.tab === "chat") { + await refreshChat(host as unknown as Parameters[0]); + scheduleChatScroll( + host as unknown as Parameters[0], + !host.chatHasAutoScrolled, + ); + } + if (host.tab === "config") { + await loadConfigSchema(host as unknown as OpenClawApp); + await loadConfig(host as unknown as OpenClawApp); + } + if (host.tab === "debug") { + await loadDebug(host as unknown as OpenClawApp); + host.eventLog = host.eventLogBuffer; + } + if (host.tab === "logs") { + host.logsAtBottom = true; + await loadLogs(host as unknown as OpenClawApp, { reset: true }); + scheduleLogsScroll(host as unknown as Parameters[0], true); + } +} + +export function inferBasePath() { + if (typeof window === "undefined") return ""; + const configured = window.__OPENCLAW_CONTROL_UI_BASE_PATH__; + if (typeof configured === "string" && configured.trim()) { + return normalizeBasePath(configured); + } + return inferBasePathFromPathname(window.location.pathname); +} + +export function syncThemeWithSettings(host: SettingsHost) { + host.theme = host.settings.theme ?? "system"; + applyResolvedTheme(host, resolveTheme(host.theme)); +} + +export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) { + host.themeResolved = resolved; + if (typeof document === "undefined") return; + const root = document.documentElement; + root.dataset.theme = resolved; + root.style.colorScheme = resolved; +} + +export function attachThemeListener(host: SettingsHost) { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; + host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)"); + host.themeMediaHandler = (event) => { + if (host.theme !== "system") return; + applyResolvedTheme(host, event.matches ? "dark" : "light"); + }; + if (typeof host.themeMedia.addEventListener === "function") { + host.themeMedia.addEventListener("change", host.themeMediaHandler); + return; + } + const legacy = host.themeMedia as MediaQueryList & { + addListener: (cb: (event: MediaQueryListEvent) => void) => void; + }; + legacy.addListener(host.themeMediaHandler); +} + +export function detachThemeListener(host: SettingsHost) { + if (!host.themeMedia || !host.themeMediaHandler) return; + if (typeof host.themeMedia.removeEventListener === "function") { + host.themeMedia.removeEventListener("change", host.themeMediaHandler); + return; + } + const legacy = host.themeMedia as MediaQueryList & { + removeListener: (cb: (event: MediaQueryListEvent) => void) => void; + }; + legacy.removeListener(host.themeMediaHandler); + host.themeMedia = null; + host.themeMediaHandler = null; +} + +export function syncTabWithLocation(host: SettingsHost, replace: boolean) { + if (typeof window === "undefined") return; + const resolved = tabFromPath(window.location.pathname, host.basePath) ?? "chat"; + setTabFromRoute(host, resolved); + syncUrlWithTab(host, resolved, replace); +} + +export function onPopState(host: SettingsHost) { + if (typeof window === "undefined") return; + const resolved = tabFromPath(window.location.pathname, host.basePath); + if (!resolved) return; + + const url = new URL(window.location.href); + const session = url.searchParams.get("session")?.trim(); + if (session) { + host.sessionKey = session; + applySettings(host, { + ...host.settings, + sessionKey: session, + lastActiveSessionKey: session, + }); + } + + setTabFromRoute(host, resolved); +} + +export function setTabFromRoute(host: SettingsHost, next: Tab) { + if (host.tab !== next) host.tab = next; + if (next === "chat") host.chatHasAutoScrolled = false; + if (next === "logs") startLogsPolling(host as unknown as Parameters[0]); + else stopLogsPolling(host as unknown as Parameters[0]); + if (next === "debug") + startDebugPolling(host as unknown as Parameters[0]); + else stopDebugPolling(host as unknown as Parameters[0]); + if (host.connected) void refreshActiveTab(host); +} + +export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) { + if (typeof window === "undefined") return; + const targetPath = normalizePath(pathForTab(tab, host.basePath)); + const currentPath = normalizePath(window.location.pathname); + const url = new URL(window.location.href); + + if (tab === "chat" && host.sessionKey) { + url.searchParams.set("session", host.sessionKey); + } else { + url.searchParams.delete("session"); + } + + if (currentPath !== targetPath) { + url.pathname = targetPath; + } + + if (replace) { + window.history.replaceState({}, "", url.toString()); + } else { + window.history.pushState({}, "", url.toString()); + } +} + +export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, replace: boolean) { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + url.searchParams.set("session", sessionKey); + if (replace) window.history.replaceState({}, "", url.toString()); + else window.history.pushState({}, "", url.toString()); +} + +export async function loadOverview(host: SettingsHost) { + await Promise.all([ + loadChannels(host as unknown as OpenClawApp, false), + loadPresence(host as unknown as OpenClawApp), + loadSessions(host as unknown as OpenClawApp), + loadCronStatus(host as unknown as OpenClawApp), + loadDebug(host as unknown as OpenClawApp), + ]); +} + +export async function loadChannelsTab(host: SettingsHost) { + await Promise.all([ + loadChannels(host as unknown as OpenClawApp, true), + loadConfigSchema(host as unknown as OpenClawApp), + loadConfig(host as unknown as OpenClawApp), + ]); +} + +export async function loadCron(host: SettingsHost) { + await Promise.all([ + loadChannels(host as unknown as OpenClawApp, false), + loadCronStatus(host as unknown as OpenClawApp), + loadCronJobs(host as unknown as OpenClawApp), + ]); +} diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/ui/app-tool-stream.ts new file mode 100644 index 0000000000000000000000000000000000000000..f438149981bffbf12604d0994c8766466fc4e7cd --- /dev/null +++ b/ui/src/ui/app-tool-stream.ts @@ -0,0 +1,240 @@ +import { truncateText } from "./format"; + +const TOOL_STREAM_LIMIT = 50; +const TOOL_STREAM_THROTTLE_MS = 80; +const TOOL_OUTPUT_CHAR_LIMIT = 120_000; + +export type AgentEventPayload = { + runId: string; + seq: number; + stream: string; + ts: number; + sessionKey?: string; + data: Record; +}; + +export type ToolStreamEntry = { + toolCallId: string; + runId: string; + sessionKey?: string; + name: string; + args?: unknown; + output?: string; + startedAt: number; + updatedAt: number; + message: Record; +}; + +type ToolStreamHost = { + sessionKey: string; + chatRunId: string | null; + toolStreamById: Map; + toolStreamOrder: string[]; + chatToolMessages: Record[]; + toolStreamSyncTimer: number | null; +}; + +function extractToolOutputText(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + if (typeof record.text === "string") return record.text; + const content = record.content; + if (!Array.isArray(content)) return null; + const parts = content + .map((item) => { + if (!item || typeof item !== "object") return null; + const entry = item as Record; + if (entry.type === "text" && typeof entry.text === "string") return entry.text; + return null; + }) + .filter((part): part is string => Boolean(part)); + if (parts.length === 0) return null; + return parts.join("\n"); +} + +function formatToolOutput(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + const contentText = extractToolOutputText(value); + let text: string; + if (typeof value === "string") { + text = value; + } else if (contentText) { + text = contentText; + } else { + try { + text = JSON.stringify(value, null, 2); + } catch { + text = String(value); + } + } + const truncated = truncateText(text, TOOL_OUTPUT_CHAR_LIMIT); + if (!truncated.truncated) return truncated.text; + return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`; +} + +function buildToolStreamMessage(entry: ToolStreamEntry): Record { + const content: Array> = []; + content.push({ + type: "toolcall", + name: entry.name, + arguments: entry.args ?? {}, + }); + if (entry.output) { + content.push({ + type: "toolresult", + name: entry.name, + text: entry.output, + }); + } + return { + role: "assistant", + toolCallId: entry.toolCallId, + runId: entry.runId, + content, + timestamp: entry.startedAt, + }; +} + +function trimToolStream(host: ToolStreamHost) { + if (host.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return; + const overflow = host.toolStreamOrder.length - TOOL_STREAM_LIMIT; + const removed = host.toolStreamOrder.splice(0, overflow); + for (const id of removed) host.toolStreamById.delete(id); +} + +function syncToolStreamMessages(host: ToolStreamHost) { + host.chatToolMessages = host.toolStreamOrder + .map((id) => host.toolStreamById.get(id)?.message) + .filter((msg): msg is Record => Boolean(msg)); +} + +export function flushToolStreamSync(host: ToolStreamHost) { + if (host.toolStreamSyncTimer != null) { + clearTimeout(host.toolStreamSyncTimer); + host.toolStreamSyncTimer = null; + } + syncToolStreamMessages(host); +} + +export function scheduleToolStreamSync(host: ToolStreamHost, force = false) { + if (force) { + flushToolStreamSync(host); + return; + } + if (host.toolStreamSyncTimer != null) return; + host.toolStreamSyncTimer = window.setTimeout( + () => flushToolStreamSync(host), + TOOL_STREAM_THROTTLE_MS, + ); +} + +export function resetToolStream(host: ToolStreamHost) { + host.toolStreamById.clear(); + host.toolStreamOrder = []; + host.chatToolMessages = []; + flushToolStreamSync(host); +} + +export type CompactionStatus = { + active: boolean; + startedAt: number | null; + completedAt: number | null; +}; + +type CompactionHost = ToolStreamHost & { + compactionStatus?: CompactionStatus | null; + compactionClearTimer?: number | null; +}; + +const COMPACTION_TOAST_DURATION_MS = 5000; + +export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) { + const data = payload.data ?? {}; + const phase = typeof data.phase === "string" ? data.phase : ""; + + // Clear any existing timer + if (host.compactionClearTimer != null) { + window.clearTimeout(host.compactionClearTimer); + host.compactionClearTimer = null; + } + + if (phase === "start") { + host.compactionStatus = { + active: true, + startedAt: Date.now(), + completedAt: null, + }; + } else if (phase === "end") { + host.compactionStatus = { + active: false, + startedAt: host.compactionStatus?.startedAt ?? null, + completedAt: Date.now(), + }; + // Auto-clear the toast after duration + host.compactionClearTimer = window.setTimeout(() => { + host.compactionStatus = null; + host.compactionClearTimer = null; + }, COMPACTION_TOAST_DURATION_MS); + } +} + +export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) { + if (!payload) return; + + // Handle compaction events + if (payload.stream === "compaction") { + handleCompactionEvent(host as CompactionHost, payload); + return; + } + + if (payload.stream !== "tool") return; + const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined; + if (sessionKey && sessionKey !== host.sessionKey) return; + // Fallback: only accept session-less events for the active run. + if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) return; + if (host.chatRunId && payload.runId !== host.chatRunId) return; + if (!host.chatRunId) return; + + const data = payload.data ?? {}; + const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : ""; + if (!toolCallId) return; + const name = typeof data.name === "string" ? data.name : "tool"; + const phase = typeof data.phase === "string" ? data.phase : ""; + const args = phase === "start" ? data.args : undefined; + const output = + phase === "update" + ? formatToolOutput(data.partialResult) + : phase === "result" + ? formatToolOutput(data.result) + : undefined; + + const now = Date.now(); + let entry = host.toolStreamById.get(toolCallId); + if (!entry) { + entry = { + toolCallId, + runId: payload.runId, + sessionKey, + name, + args, + output, + startedAt: typeof payload.ts === "number" ? payload.ts : now, + updatedAt: now, + message: {}, + }; + host.toolStreamById.set(toolCallId, entry); + host.toolStreamOrder.push(toolCallId); + } else { + entry.name = name; + if (args !== undefined) entry.args = args; + if (output !== undefined) entry.output = output; + entry.updatedAt = now; + } + + entry.message = buildToolStreamMessage(entry); + trimToolStream(host); + scheduleToolStreamSync(host, phase === "result"); +} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ccbf59d41bf3d87057bd91ebb3c8e1775380ff7 --- /dev/null +++ b/ui/src/ui/app-view-state.ts @@ -0,0 +1,206 @@ +import type { EventLogEntry } from "./app-events"; +import type { DevicePairingList } from "./controllers/devices"; +import type { ExecApprovalRequest } from "./controllers/exec-approval"; +import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals"; +import type { SkillMessage } from "./controllers/skills"; +import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; +import type { Tab } from "./navigation"; +import type { UiSettings } from "./storage"; +import type { ThemeMode } from "./theme"; +import type { ThemeTransitionContext } from "./theme-transition"; +import type { + AgentsListResult, + ChannelsStatusSnapshot, + ConfigSnapshot, + CronJob, + CronRunLogEntry, + CronStatus, + HealthSnapshot, + LogEntry, + LogLevel, + NostrProfile, + PresenceEntry, + SessionsListResult, + SkillStatusReport, + StatusSummary, +} from "./types"; +import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types"; +import type { NostrProfileFormState } from "./views/channels.nostr-profile-form"; + +export type AppViewState = { + settings: UiSettings; + password: string; + tab: Tab; + onboarding: boolean; + basePath: string; + connected: boolean; + theme: ThemeMode; + themeResolved: "light" | "dark"; + hello: GatewayHelloOk | null; + lastError: string | null; + eventLog: EventLogEntry[]; + assistantName: string; + assistantAvatar: string | null; + assistantAgentId: string | null; + sessionKey: string; + chatLoading: boolean; + chatSending: boolean; + chatMessage: string; + chatAttachments: ChatAttachment[]; + chatMessages: unknown[]; + chatToolMessages: unknown[]; + chatStream: string | null; + chatRunId: string | null; + chatAvatarUrl: string | null; + chatThinkingLevel: string | null; + chatQueue: ChatQueueItem[]; + nodesLoading: boolean; + nodes: Array>; + devicesLoading: boolean; + devicesError: string | null; + devicesList: DevicePairingList | null; + execApprovalsLoading: boolean; + execApprovalsSaving: boolean; + execApprovalsDirty: boolean; + execApprovalsSnapshot: ExecApprovalsSnapshot | null; + execApprovalsForm: ExecApprovalsFile | null; + execApprovalsSelectedAgent: string | null; + execApprovalsTarget: "gateway" | "node"; + execApprovalsTargetNodeId: string | null; + execApprovalQueue: ExecApprovalRequest[]; + execApprovalBusy: boolean; + execApprovalError: string | null; + pendingGatewayUrl: string | null; + configLoading: boolean; + configRaw: string; + configRawOriginal: string; + configValid: boolean | null; + configIssues: unknown[]; + configSaving: boolean; + configApplying: boolean; + updateRunning: boolean; + configSnapshot: ConfigSnapshot | null; + configSchema: unknown | null; + configSchemaLoading: boolean; + configUiHints: Record; + configForm: Record | null; + configFormOriginal: Record | null; + configFormMode: "form" | "raw"; + channelsLoading: boolean; + channelsSnapshot: ChannelsStatusSnapshot | null; + channelsError: string | null; + channelsLastSuccess: number | null; + whatsappLoginMessage: string | null; + whatsappLoginQrDataUrl: string | null; + whatsappLoginConnected: boolean | null; + whatsappBusy: boolean; + nostrProfileFormState: NostrProfileFormState | null; + nostrProfileAccountId: string | null; + configFormDirty: boolean; + presenceLoading: boolean; + presenceEntries: PresenceEntry[]; + presenceError: string | null; + presenceStatus: string | null; + agentsLoading: boolean; + agentsList: AgentsListResult | null; + agentsError: string | null; + sessionsLoading: boolean; + sessionsResult: SessionsListResult | null; + sessionsError: string | null; + sessionsFilterActive: string; + sessionsFilterLimit: string; + sessionsIncludeGlobal: boolean; + sessionsIncludeUnknown: boolean; + cronLoading: boolean; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + cronError: string | null; + cronForm: CronFormState; + cronRunsJobId: string | null; + cronRuns: CronRunLogEntry[]; + cronBusy: boolean; + skillsLoading: boolean; + skillsReport: SkillStatusReport | null; + skillsError: string | null; + skillsFilter: string; + skillEdits: Record; + skillMessages: Record; + skillsBusyKey: string | null; + debugLoading: boolean; + debugStatus: StatusSummary | null; + debugHealth: HealthSnapshot | null; + debugModels: unknown[]; + debugHeartbeat: unknown | null; + debugCallMethod: string; + debugCallParams: string; + debugCallResult: string | null; + debugCallError: string | null; + logsLoading: boolean; + logsError: string | null; + logsFile: string | null; + logsEntries: LogEntry[]; + logsFilterText: string; + logsLevelFilters: Record; + logsAutoFollow: boolean; + logsTruncated: boolean; + client: GatewayBrowserClient | null; + connect: () => void; + setTab: (tab: Tab) => void; + setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void; + applySettings: (next: UiSettings) => void; + loadOverview: () => Promise; + loadAssistantIdentity: () => Promise; + loadCron: () => Promise; + handleWhatsAppStart: (force: boolean) => Promise; + handleWhatsAppWait: () => Promise; + handleWhatsAppLogout: () => Promise; + handleChannelConfigSave: () => Promise; + handleChannelConfigReload: () => Promise; + handleNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void; + handleNostrProfileCancel: () => void; + handleNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void; + handleNostrProfileSave: () => Promise; + handleNostrProfileImport: () => Promise; + handleNostrProfileToggleAdvanced: () => void; + handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise; + handleGatewayUrlConfirm: () => void; + handleGatewayUrlCancel: () => void; + handleConfigLoad: () => Promise; + handleConfigSave: () => Promise; + handleConfigApply: () => Promise; + handleConfigFormUpdate: (path: string, value: unknown) => void; + handleConfigFormModeChange: (mode: "form" | "raw") => void; + handleConfigRawChange: (raw: string) => void; + handleInstallSkill: (key: string) => Promise; + handleUpdateSkill: (key: string) => Promise; + handleToggleSkillEnabled: (key: string, enabled: boolean) => Promise; + handleUpdateSkillEdit: (key: string, value: string) => void; + handleSaveSkillApiKey: (key: string, apiKey: string) => Promise; + handleCronToggle: (jobId: string, enabled: boolean) => Promise; + handleCronRun: (jobId: string) => Promise; + handleCronRemove: (jobId: string) => Promise; + handleCronAdd: () => Promise; + handleCronRunsLoad: (jobId: string) => Promise; + handleCronFormUpdate: (path: string, value: unknown) => void; + handleSessionsLoad: () => Promise; + handleSessionsPatch: (key: string, patch: unknown) => Promise; + handleLoadNodes: () => Promise; + handleLoadPresence: () => Promise; + handleLoadSkills: () => Promise; + handleLoadDebug: () => Promise; + handleLoadLogs: () => Promise; + handleDebugCall: () => Promise; + handleRunUpdate: () => Promise; + setPassword: (next: string) => void; + setSessionKey: (next: string) => void; + setChatMessage: (next: string) => void; + handleChatSend: () => Promise; + handleChatAbort: () => Promise; + handleChatSelectQueueItem: (id: string) => void; + handleChatDropQueueItem: (id: string) => void; + handleChatClearQueue: () => void; + handleLogsFilterChange: (next: string) => void; + handleLogsLevelFilterToggle: (level: LogLevel) => void; + handleLogsAutoFollowToggle: (next: boolean) => void; + handleCallDebugMethod: (method: string, params: string) => Promise; +}; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts new file mode 100644 index 0000000000000000000000000000000000000000..54ba72498f268bccf6d1e5d897e705b1809a88df --- /dev/null +++ b/ui/src/ui/app.ts @@ -0,0 +1,474 @@ +import { LitElement, html, nothing } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import type { EventLogEntry } from "./app-events"; +import type { DevicePairingList } from "./controllers/devices"; +import type { ExecApprovalRequest } from "./controllers/exec-approval"; +import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals"; +import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; +import type { Tab } from "./navigation"; +import type { ResolvedTheme, ThemeMode } from "./theme"; +import type { + AgentsListResult, + ConfigSnapshot, + ConfigUiHints, + CronJob, + CronRunLogEntry, + CronStatus, + HealthSnapshot, + LogEntry, + LogLevel, + PresenceEntry, + ChannelsStatusSnapshot, + SessionsListResult, + SkillStatusReport, + StatusSummary, + NostrProfile, +} from "./types"; +import type { NostrProfileFormState } from "./views/channels.nostr-profile-form"; +import { + handleChannelConfigReload as handleChannelConfigReloadInternal, + handleChannelConfigSave as handleChannelConfigSaveInternal, + handleNostrProfileCancel as handleNostrProfileCancelInternal, + handleNostrProfileEdit as handleNostrProfileEditInternal, + handleNostrProfileFieldChange as handleNostrProfileFieldChangeInternal, + handleNostrProfileImport as handleNostrProfileImportInternal, + handleNostrProfileSave as handleNostrProfileSaveInternal, + handleNostrProfileToggleAdvanced as handleNostrProfileToggleAdvancedInternal, + handleWhatsAppLogout as handleWhatsAppLogoutInternal, + handleWhatsAppStart as handleWhatsAppStartInternal, + handleWhatsAppWait as handleWhatsAppWaitInternal, +} from "./app-channels"; +import { + handleAbortChat as handleAbortChatInternal, + handleSendChat as handleSendChatInternal, + removeQueuedMessage as removeQueuedMessageInternal, +} from "./app-chat"; +import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults"; +import { connectGateway as connectGatewayInternal } from "./app-gateway"; +import { + handleConnected, + handleDisconnected, + handleFirstUpdated, + handleUpdated, +} from "./app-lifecycle"; +import { renderApp } from "./app-render"; +import { + exportLogs as exportLogsInternal, + handleChatScroll as handleChatScrollInternal, + handleLogsScroll as handleLogsScrollInternal, + resetChatScroll as resetChatScrollInternal, +} from "./app-scroll"; +import { + applySettings as applySettingsInternal, + loadCron as loadCronInternal, + loadOverview as loadOverviewInternal, + setTab as setTabInternal, + setTheme as setThemeInternal, + onPopState as onPopStateInternal, +} from "./app-settings"; +import { + resetToolStream as resetToolStreamInternal, + type ToolStreamEntry, +} from "./app-tool-stream"; +import { resolveInjectedAssistantIdentity } from "./assistant-identity"; +import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity"; +import { loadSettings, type UiSettings } from "./storage"; +import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types"; + +declare global { + interface Window { + __OPENCLAW_CONTROL_UI_BASE_PATH__?: string; + } +} + +const injectedAssistantIdentity = resolveInjectedAssistantIdentity(); + +function resolveOnboardingMode(): boolean { + if (!window.location.search) return false; + const params = new URLSearchParams(window.location.search); + const raw = params.get("onboarding"); + if (!raw) return false; + const normalized = raw.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; +} + +@customElement("openclaw-app") +export class OpenClawApp extends LitElement { + @state() settings: UiSettings = loadSettings(); + @state() password = ""; + @state() tab: Tab = "chat"; + @state() onboarding = resolveOnboardingMode(); + @state() connected = false; + @state() theme: ThemeMode = this.settings.theme ?? "system"; + @state() themeResolved: ResolvedTheme = "dark"; + @state() hello: GatewayHelloOk | null = null; + @state() lastError: string | null = null; + @state() eventLog: EventLogEntry[] = []; + private eventLogBuffer: EventLogEntry[] = []; + private toolStreamSyncTimer: number | null = null; + private sidebarCloseTimer: number | null = null; + + @state() assistantName = injectedAssistantIdentity.name; + @state() assistantAvatar = injectedAssistantIdentity.avatar; + @state() assistantAgentId = injectedAssistantIdentity.agentId ?? null; + + @state() sessionKey = this.settings.sessionKey; + @state() chatLoading = false; + @state() chatSending = false; + @state() chatMessage = ""; + @state() chatMessages: unknown[] = []; + @state() chatToolMessages: unknown[] = []; + @state() chatStream: string | null = null; + @state() chatStreamStartedAt: number | null = null; + @state() chatRunId: string | null = null; + @state() compactionStatus: import("./app-tool-stream").CompactionStatus | null = null; + @state() chatAvatarUrl: string | null = null; + @state() chatThinkingLevel: string | null = null; + @state() chatQueue: ChatQueueItem[] = []; + @state() chatAttachments: ChatAttachment[] = []; + // Sidebar state for tool output viewing + @state() sidebarOpen = false; + @state() sidebarContent: string | null = null; + @state() sidebarError: string | null = null; + @state() splitRatio = this.settings.splitRatio; + + @state() nodesLoading = false; + @state() nodes: Array> = []; + @state() devicesLoading = false; + @state() devicesError: string | null = null; + @state() devicesList: DevicePairingList | null = null; + @state() execApprovalsLoading = false; + @state() execApprovalsSaving = false; + @state() execApprovalsDirty = false; + @state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null; + @state() execApprovalsForm: ExecApprovalsFile | null = null; + @state() execApprovalsSelectedAgent: string | null = null; + @state() execApprovalsTarget: "gateway" | "node" = "gateway"; + @state() execApprovalsTargetNodeId: string | null = null; + @state() execApprovalQueue: ExecApprovalRequest[] = []; + @state() execApprovalBusy = false; + @state() execApprovalError: string | null = null; + @state() pendingGatewayUrl: string | null = null; + + @state() configLoading = false; + @state() configRaw = "{\n}\n"; + @state() configRawOriginal = ""; + @state() configValid: boolean | null = null; + @state() configIssues: unknown[] = []; + @state() configSaving = false; + @state() configApplying = false; + @state() updateRunning = false; + @state() applySessionKey = this.settings.lastActiveSessionKey; + @state() configSnapshot: ConfigSnapshot | null = null; + @state() configSchema: unknown | null = null; + @state() configSchemaVersion: string | null = null; + @state() configSchemaLoading = false; + @state() configUiHints: ConfigUiHints = {}; + @state() configForm: Record | null = null; + @state() configFormOriginal: Record | null = null; + @state() configFormDirty = false; + @state() configFormMode: "form" | "raw" = "form"; + @state() configSearchQuery = ""; + @state() configActiveSection: string | null = null; + @state() configActiveSubsection: string | null = null; + + @state() channelsLoading = false; + @state() channelsSnapshot: ChannelsStatusSnapshot | null = null; + @state() channelsError: string | null = null; + @state() channelsLastSuccess: number | null = null; + @state() whatsappLoginMessage: string | null = null; + @state() whatsappLoginQrDataUrl: string | null = null; + @state() whatsappLoginConnected: boolean | null = null; + @state() whatsappBusy = false; + @state() nostrProfileFormState: NostrProfileFormState | null = null; + @state() nostrProfileAccountId: string | null = null; + + @state() presenceLoading = false; + @state() presenceEntries: PresenceEntry[] = []; + @state() presenceError: string | null = null; + @state() presenceStatus: string | null = null; + + @state() agentsLoading = false; + @state() agentsList: AgentsListResult | null = null; + @state() agentsError: string | null = null; + + @state() sessionsLoading = false; + @state() sessionsResult: SessionsListResult | null = null; + @state() sessionsError: string | null = null; + @state() sessionsFilterActive = ""; + @state() sessionsFilterLimit = "120"; + @state() sessionsIncludeGlobal = true; + @state() sessionsIncludeUnknown = false; + + @state() cronLoading = false; + @state() cronJobs: CronJob[] = []; + @state() cronStatus: CronStatus | null = null; + @state() cronError: string | null = null; + @state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM }; + @state() cronRunsJobId: string | null = null; + @state() cronRuns: CronRunLogEntry[] = []; + @state() cronBusy = false; + + @state() skillsLoading = false; + @state() skillsReport: SkillStatusReport | null = null; + @state() skillsError: string | null = null; + @state() skillsFilter = ""; + @state() skillEdits: Record = {}; + @state() skillsBusyKey: string | null = null; + @state() skillMessages: Record = {}; + + @state() debugLoading = false; + @state() debugStatus: StatusSummary | null = null; + @state() debugHealth: HealthSnapshot | null = null; + @state() debugModels: unknown[] = []; + @state() debugHeartbeat: unknown | null = null; + @state() debugCallMethod = ""; + @state() debugCallParams = "{}"; + @state() debugCallResult: string | null = null; + @state() debugCallError: string | null = null; + + @state() logsLoading = false; + @state() logsError: string | null = null; + @state() logsFile: string | null = null; + @state() logsEntries: LogEntry[] = []; + @state() logsFilterText = ""; + @state() logsLevelFilters: Record = { + ...DEFAULT_LOG_LEVEL_FILTERS, + }; + @state() logsAutoFollow = true; + @state() logsTruncated = false; + @state() logsCursor: number | null = null; + @state() logsLastFetchAt: number | null = null; + @state() logsLimit = 500; + @state() logsMaxBytes = 250_000; + @state() logsAtBottom = true; + + client: GatewayBrowserClient | null = null; + private chatScrollFrame: number | null = null; + private chatScrollTimeout: number | null = null; + private chatHasAutoScrolled = false; + private chatUserNearBottom = true; + private nodesPollInterval: number | null = null; + private logsPollInterval: number | null = null; + private debugPollInterval: number | null = null; + private logsScrollFrame: number | null = null; + private toolStreamById = new Map(); + private toolStreamOrder: string[] = []; + refreshSessionsAfterChat = new Set(); + basePath = ""; + private popStateHandler = () => + onPopStateInternal(this as unknown as Parameters[0]); + private themeMedia: MediaQueryList | null = null; + private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null; + private topbarObserver: ResizeObserver | null = null; + + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + handleConnected(this as unknown as Parameters[0]); + } + + protected firstUpdated() { + handleFirstUpdated(this as unknown as Parameters[0]); + } + + disconnectedCallback() { + handleDisconnected(this as unknown as Parameters[0]); + super.disconnectedCallback(); + } + + protected updated(changed: Map) { + handleUpdated(this as unknown as Parameters[0], changed); + } + + connect() { + connectGatewayInternal(this as unknown as Parameters[0]); + } + + handleChatScroll(event: Event) { + handleChatScrollInternal( + this as unknown as Parameters[0], + event, + ); + } + + handleLogsScroll(event: Event) { + handleLogsScrollInternal( + this as unknown as Parameters[0], + event, + ); + } + + exportLogs(lines: string[], label: string) { + exportLogsInternal(lines, label); + } + + resetToolStream() { + resetToolStreamInternal(this as unknown as Parameters[0]); + } + + resetChatScroll() { + resetChatScrollInternal(this as unknown as Parameters[0]); + } + + async loadAssistantIdentity() { + await loadAssistantIdentityInternal(this); + } + + applySettings(next: UiSettings) { + applySettingsInternal(this as unknown as Parameters[0], next); + } + + setTab(next: Tab) { + setTabInternal(this as unknown as Parameters[0], next); + } + + setTheme(next: ThemeMode, context?: Parameters[2]) { + setThemeInternal(this as unknown as Parameters[0], next, context); + } + + async loadOverview() { + await loadOverviewInternal(this as unknown as Parameters[0]); + } + + async loadCron() { + await loadCronInternal(this as unknown as Parameters[0]); + } + + async handleAbortChat() { + await handleAbortChatInternal(this as unknown as Parameters[0]); + } + + removeQueuedMessage(id: string) { + removeQueuedMessageInternal( + this as unknown as Parameters[0], + id, + ); + } + + async handleSendChat( + messageOverride?: string, + opts?: Parameters[2], + ) { + await handleSendChatInternal( + this as unknown as Parameters[0], + messageOverride, + opts, + ); + } + + async handleWhatsAppStart(force: boolean) { + await handleWhatsAppStartInternal(this, force); + } + + async handleWhatsAppWait() { + await handleWhatsAppWaitInternal(this); + } + + async handleWhatsAppLogout() { + await handleWhatsAppLogoutInternal(this); + } + + async handleChannelConfigSave() { + await handleChannelConfigSaveInternal(this); + } + + async handleChannelConfigReload() { + await handleChannelConfigReloadInternal(this); + } + + handleNostrProfileEdit(accountId: string, profile: NostrProfile | null) { + handleNostrProfileEditInternal(this, accountId, profile); + } + + handleNostrProfileCancel() { + handleNostrProfileCancelInternal(this); + } + + handleNostrProfileFieldChange(field: keyof NostrProfile, value: string) { + handleNostrProfileFieldChangeInternal(this, field, value); + } + + async handleNostrProfileSave() { + await handleNostrProfileSaveInternal(this); + } + + async handleNostrProfileImport() { + await handleNostrProfileImportInternal(this); + } + + handleNostrProfileToggleAdvanced() { + handleNostrProfileToggleAdvancedInternal(this); + } + + async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") { + const active = this.execApprovalQueue[0]; + if (!active || !this.client || this.execApprovalBusy) return; + this.execApprovalBusy = true; + this.execApprovalError = null; + try { + await this.client.request("exec.approval.resolve", { + id: active.id, + decision, + }); + this.execApprovalQueue = this.execApprovalQueue.filter((entry) => entry.id !== active.id); + } catch (err) { + this.execApprovalError = `Exec approval failed: ${String(err)}`; + } finally { + this.execApprovalBusy = false; + } + } + + handleGatewayUrlConfirm() { + const nextGatewayUrl = this.pendingGatewayUrl; + if (!nextGatewayUrl) return; + this.pendingGatewayUrl = null; + applySettingsInternal(this as unknown as Parameters[0], { + ...this.settings, + gatewayUrl: nextGatewayUrl, + }); + this.connect(); + } + + handleGatewayUrlCancel() { + this.pendingGatewayUrl = null; + } + + // Sidebar handlers for tool output viewing + handleOpenSidebar(content: string) { + if (this.sidebarCloseTimer != null) { + window.clearTimeout(this.sidebarCloseTimer); + this.sidebarCloseTimer = null; + } + this.sidebarContent = content; + this.sidebarError = null; + this.sidebarOpen = true; + } + + handleCloseSidebar() { + this.sidebarOpen = false; + // Clear content after transition + if (this.sidebarCloseTimer != null) { + window.clearTimeout(this.sidebarCloseTimer); + } + this.sidebarCloseTimer = window.setTimeout(() => { + if (this.sidebarOpen) return; + this.sidebarContent = null; + this.sidebarError = null; + this.sidebarCloseTimer = null; + }, 200); + } + + handleSplitRatioChange(ratio: number) { + const newRatio = Math.max(0.4, Math.min(0.7, ratio)); + this.splitRatio = newRatio; + this.applySettings({ ...this.settings, splitRatio: newRatio }); + } + + render() { + return renderApp(this); + } +} diff --git a/ui/src/ui/assistant-identity.ts b/ui/src/ui/assistant-identity.ts new file mode 100644 index 0000000000000000000000000000000000000000..6159cc36e2e3789c99e429d9d5aa214df3ae1d40 --- /dev/null +++ b/ui/src/ui/assistant-identity.ts @@ -0,0 +1,46 @@ +const MAX_ASSISTANT_NAME = 50; +const MAX_ASSISTANT_AVATAR = 200; + +export const DEFAULT_ASSISTANT_NAME = "Assistant"; +export const DEFAULT_ASSISTANT_AVATAR = "A"; + +export type AssistantIdentity = { + agentId?: string | null; + name: string; + avatar: string | null; +}; + +declare global { + interface Window { + __OPENCLAW_ASSISTANT_NAME__?: string; + __OPENCLAW_ASSISTANT_AVATAR__?: string; + } +} + +function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (trimmed.length <= maxLength) return trimmed; + return trimmed.slice(0, maxLength); +} + +export function normalizeAssistantIdentity( + input?: Partial | null, +): AssistantIdentity { + const name = coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME; + const avatar = coerceIdentityValue(input?.avatar ?? undefined, MAX_ASSISTANT_AVATAR) ?? null; + const agentId = + typeof input?.agentId === "string" && input.agentId.trim() ? input.agentId.trim() : null; + return { agentId, name, avatar }; +} + +export function resolveInjectedAssistantIdentity(): AssistantIdentity { + if (typeof window === "undefined") { + return normalizeAssistantIdentity({}); + } + return normalizeAssistantIdentity({ + name: window.__OPENCLAW_ASSISTANT_NAME__, + avatar: window.__OPENCLAW_ASSISTANT_AVATAR__, + }); +} diff --git a/ui/src/ui/chat-markdown.browser.test.ts b/ui/src/ui/chat-markdown.browser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..86057f3544c97f4f430e7c57d6d435d730263a9b --- /dev/null +++ b/ui/src/ui/chat-markdown.browser.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { OpenClawApp } from "./app"; + +const originalConnect = OpenClawApp.prototype.connect; + +function mountApp(pathname: string) { + window.history.replaceState({}, "", pathname); + const app = document.createElement("openclaw-app") as OpenClawApp; + document.body.append(app); + return app; +} + +beforeEach(() => { + OpenClawApp.prototype.connect = () => { + // no-op: avoid real gateway WS connections in browser tests + }; + window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); + document.body.innerHTML = ""; +}); + +afterEach(() => { + OpenClawApp.prototype.connect = originalConnect; + window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); + document.body.innerHTML = ""; +}); + +describe("chat markdown rendering", () => { + it("renders markdown inside tool output sidebar", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + const timestamp = Date.now(); + app.chatMessages = [ + { + role: "assistant", + content: [ + { type: "toolcall", name: "noop", arguments: {} }, + { type: "toolresult", name: "noop", text: "Hello **world**" }, + ], + timestamp, + }, + ]; + + await app.updateComplete; + + const toolCards = Array.from(app.querySelectorAll(".chat-tool-card")); + const toolCard = toolCards.find((card) => + card.querySelector(".chat-tool-card__preview, .chat-tool-card__inline"), + ); + expect(toolCard).not.toBeUndefined(); + toolCard?.click(); + + await app.updateComplete; + + const strong = app.querySelector(".sidebar-markdown strong"); + expect(strong?.textContent).toBe("world"); + }); +}); diff --git a/ui/src/ui/chat/constants.ts b/ui/src/ui/chat/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ab7acb11bd39f1d6c4af361a719658b7e3c59ea --- /dev/null +++ b/ui/src/ui/chat/constants.ts @@ -0,0 +1,12 @@ +/** + * Chat-related constants for the UI layer. + */ + +/** Character threshold for showing tool output inline vs collapsed */ +export const TOOL_INLINE_THRESHOLD = 80; + +/** Maximum lines to show in collapsed preview */ +export const PREVIEW_MAX_LINES = 2; + +/** Maximum characters to show in collapsed preview */ +export const PREVIEW_MAX_CHARS = 100; diff --git a/ui/src/ui/chat/copy-as-markdown.ts b/ui/src/ui/chat/copy-as-markdown.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d11eb32e7cd5889a68a617140f697cdd7b5ebd9 --- /dev/null +++ b/ui/src/ui/chat/copy-as-markdown.ts @@ -0,0 +1,88 @@ +import { html, type TemplateResult } from "lit"; +import { icons } from "../icons"; + +const COPIED_FOR_MS = 1500; +const ERROR_FOR_MS = 2000; +const COPY_LABEL = "Copy as markdown"; +const COPIED_LABEL = "Copied"; +const ERROR_LABEL = "Copy failed"; + +type CopyButtonOptions = { + text: () => string; + label?: string; +}; + +async function copyTextToClipboard(text: string): Promise { + if (!text) return false; + + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +function setButtonLabel(button: HTMLButtonElement, label: string) { + button.title = label; + button.setAttribute("aria-label", label); +} + +function createCopyButton(options: CopyButtonOptions): TemplateResult { + const idleLabel = options.label ?? COPY_LABEL; + return html` + + `; +} + +export function renderCopyAsMarkdownButton(markdown: string): TemplateResult { + return createCopyButton({ text: () => markdown, label: COPY_LABEL }); +} diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts new file mode 100644 index 0000000000000000000000000000000000000000..97ad421f696a110ba58d5ac32c9e231e78a5eede --- /dev/null +++ b/ui/src/ui/chat/grouped-render.ts @@ -0,0 +1,275 @@ +import { html, nothing } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import type { AssistantIdentity } from "../assistant-identity"; +import type { MessageGroup } from "../types/chat-types"; +import { toSanitizedMarkdownHtml } from "../markdown"; +import { renderCopyAsMarkdownButton } from "./copy-as-markdown"; +import { + extractTextCached, + extractThinkingCached, + formatReasoningMarkdown, +} from "./message-extract"; +import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer"; +import { extractToolCards, renderToolCardSidebar } from "./tool-cards"; + +type ImageBlock = { + url: string; + alt?: string; +}; + +function extractImages(message: unknown): ImageBlock[] { + const m = message as Record; + const content = m.content; + const images: ImageBlock[] = []; + + if (Array.isArray(content)) { + for (const block of content) { + if (typeof block !== "object" || block === null) continue; + const b = block as Record; + + if (b.type === "image") { + // Handle source object format (from sendChatMessage) + const source = b.source as Record | undefined; + if (source?.type === "base64" && typeof source.data === "string") { + const data = source.data as string; + const mediaType = (source.media_type as string) || "image/png"; + // If data is already a data URL, use it directly + const url = data.startsWith("data:") ? data : `data:${mediaType};base64,${data}`; + images.push({ url }); + } else if (typeof b.url === "string") { + images.push({ url: b.url }); + } + } else if (b.type === "image_url") { + // OpenAI format + const imageUrl = b.image_url as Record | undefined; + if (typeof imageUrl?.url === "string") { + images.push({ url: imageUrl.url }); + } + } + } + } + + return images; +} + +export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) { + return html` +
      + ${renderAvatar("assistant", assistant)} +
      + +
      +
      + `; +} + +export function renderStreamingGroup( + text: string, + startedAt: number, + onOpenSidebar?: (content: string) => void, + assistant?: AssistantIdentity, +) { + const timestamp = new Date(startedAt).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }); + const name = assistant?.name ?? "Assistant"; + + return html` +
      + ${renderAvatar("assistant", assistant)} +
      + ${renderGroupedMessage( + { + role: "assistant", + content: [{ type: "text", text }], + timestamp: startedAt, + }, + { isStreaming: true, showReasoning: false }, + onOpenSidebar, + )} + +
      +
      + `; +} + +export function renderMessageGroup( + group: MessageGroup, + opts: { + onOpenSidebar?: (content: string) => void; + showReasoning: boolean; + assistantName?: string; + assistantAvatar?: string | null; + }, +) { + const normalizedRole = normalizeRoleForGrouping(group.role); + const assistantName = opts.assistantName ?? "Assistant"; + const who = + normalizedRole === "user" + ? "You" + : normalizedRole === "assistant" + ? assistantName + : normalizedRole; + const roleClass = + normalizedRole === "user" ? "user" : normalizedRole === "assistant" ? "assistant" : "other"; + const timestamp = new Date(group.timestamp).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }); + + return html` +
      + ${renderAvatar(group.role, { + name: assistantName, + avatar: opts.assistantAvatar ?? null, + })} +
      + ${group.messages.map((item, index) => + renderGroupedMessage( + item.message, + { + isStreaming: group.isStreaming && index === group.messages.length - 1, + showReasoning: opts.showReasoning, + }, + opts.onOpenSidebar, + ), + )} + +
      +
      + `; +} + +function renderAvatar(role: string, assistant?: Pick) { + const normalized = normalizeRoleForGrouping(role); + const assistantName = assistant?.name?.trim() || "Assistant"; + const assistantAvatar = assistant?.avatar?.trim() || ""; + const initial = + normalized === "user" + ? "U" + : normalized === "assistant" + ? assistantName.charAt(0).toUpperCase() || "A" + : normalized === "tool" + ? "⚙" + : "?"; + const className = + normalized === "user" + ? "user" + : normalized === "assistant" + ? "assistant" + : normalized === "tool" + ? "tool" + : "other"; + + if (assistantAvatar && normalized === "assistant") { + if (isAvatarUrl(assistantAvatar)) { + return html`${assistantName}`; + } + return html`
      ${assistantAvatar}
      `; + } + + return html`
      ${initial}
      `; +} + +function isAvatarUrl(value: string): boolean { + return ( + /^https?:\/\//i.test(value) || /^data:image\//i.test(value) || /^\//.test(value) // Relative paths from avatar endpoint + ); +} + +function renderMessageImages(images: ImageBlock[]) { + if (images.length === 0) return nothing; + + return html` +
      + ${images.map( + (img) => html` + ${img.alt window.open(img.url, "_blank")} + /> + `, + )} +
      + `; +} + +function renderGroupedMessage( + message: unknown, + opts: { isStreaming: boolean; showReasoning: boolean }, + onOpenSidebar?: (content: string) => void, +) { + const m = message as Record; + const role = typeof m.role === "string" ? m.role : "unknown"; + const isToolResult = + isToolResultMessage(message) || + role.toLowerCase() === "toolresult" || + role.toLowerCase() === "tool_result" || + typeof m.toolCallId === "string" || + typeof m.tool_call_id === "string"; + + const toolCards = extractToolCards(message); + const hasToolCards = toolCards.length > 0; + const images = extractImages(message); + const hasImages = images.length > 0; + + const extractedText = extractTextCached(message); + const extractedThinking = + opts.showReasoning && role === "assistant" ? extractThinkingCached(message) : null; + const markdownBase = extractedText?.trim() ? extractedText : null; + const reasoningMarkdown = extractedThinking ? formatReasoningMarkdown(extractedThinking) : null; + const markdown = markdownBase; + const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim()); + + const bubbleClasses = [ + "chat-bubble", + canCopyMarkdown ? "has-copy" : "", + opts.isStreaming ? "streaming" : "", + "fade-in", + ] + .filter(Boolean) + .join(" "); + + if (!markdown && hasToolCards && isToolResult) { + return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`; + } + + if (!markdown && !hasToolCards && !hasImages) return nothing; + + return html` +
      + ${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing} + ${renderMessageImages(images)} + ${ + reasoningMarkdown + ? html`
      ${unsafeHTML( + toSanitizedMarkdownHtml(reasoningMarkdown), + )}
      ` + : nothing + } + ${ + markdown + ? html`
      ${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
      ` + : nothing + } + ${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))} +
      + `; +} diff --git a/ui/src/ui/chat/message-extract.test.ts b/ui/src/ui/chat/message-extract.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b557daa1c71b6e04c28dad656525241e3c161d7 --- /dev/null +++ b/ui/src/ui/chat/message-extract.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + extractText, + extractTextCached, + extractThinking, + extractThinkingCached, +} from "./message-extract"; + +describe("extractTextCached", () => { + it("matches extractText output", () => { + const message = { + role: "assistant", + content: [{ type: "text", text: "Hello there" }], + }; + expect(extractTextCached(message)).toBe(extractText(message)); + }); + + it("returns consistent output for repeated calls", () => { + const message = { + role: "user", + content: "plain text", + }; + expect(extractTextCached(message)).toBe("plain text"); + expect(extractTextCached(message)).toBe("plain text"); + }); +}); + +describe("extractThinkingCached", () => { + it("matches extractThinking output", () => { + const message = { + role: "assistant", + content: [{ type: "thinking", thinking: "Plan A" }], + }; + expect(extractThinkingCached(message)).toBe(extractThinking(message)); + }); + + it("returns consistent output for repeated calls", () => { + const message = { + role: "assistant", + content: [{ type: "thinking", thinking: "Plan A" }], + }; + expect(extractThinkingCached(message)).toBe("Plan A"); + expect(extractThinkingCached(message)).toBe("Plan A"); + }); +}); diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/ui/chat/message-extract.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a63a073fdee64ad9ea9101efd05c72ebb8298db --- /dev/null +++ b/ui/src/ui/chat/message-extract.ts @@ -0,0 +1,135 @@ +import { stripThinkingTags } from "../format"; + +const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/; +const ENVELOPE_CHANNELS = [ + "WebChat", + "WhatsApp", + "Telegram", + "Signal", + "Slack", + "Discord", + "iMessage", + "Teams", + "Matrix", + "Zalo", + "Zalo Personal", + "BlueBubbles", +]; + +const textCache = new WeakMap(); +const thinkingCache = new WeakMap(); + +function looksLikeEnvelopeHeader(header: string): boolean { + if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true; + if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true; + return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `)); +} + +export function stripEnvelope(text: string): string { + const match = text.match(ENVELOPE_PREFIX); + if (!match) return text; + const header = match[1] ?? ""; + if (!looksLikeEnvelopeHeader(header)) return text; + return text.slice(match[0].length); +} + +export function extractText(message: unknown): string | null { + const m = message as Record; + const role = typeof m.role === "string" ? m.role : ""; + const content = m.content; + if (typeof content === "string") { + const processed = role === "assistant" ? stripThinkingTags(content) : stripEnvelope(content); + return processed; + } + if (Array.isArray(content)) { + const parts = content + .map((p) => { + const item = p as Record; + if (item.type === "text" && typeof item.text === "string") return item.text; + return null; + }) + .filter((v): v is string => typeof v === "string"); + if (parts.length > 0) { + const joined = parts.join("\n"); + const processed = role === "assistant" ? stripThinkingTags(joined) : stripEnvelope(joined); + return processed; + } + } + if (typeof m.text === "string") { + const processed = role === "assistant" ? stripThinkingTags(m.text) : stripEnvelope(m.text); + return processed; + } + return null; +} + +export function extractTextCached(message: unknown): string | null { + if (!message || typeof message !== "object") return extractText(message); + const obj = message as object; + if (textCache.has(obj)) return textCache.get(obj) ?? null; + const value = extractText(message); + textCache.set(obj, value); + return value; +} + +export function extractThinking(message: unknown): string | null { + const m = message as Record; + const content = m.content; + const parts: string[] = []; + if (Array.isArray(content)) { + for (const p of content) { + const item = p as Record; + if (item.type === "thinking" && typeof item.thinking === "string") { + const cleaned = item.thinking.trim(); + if (cleaned) parts.push(cleaned); + } + } + } + if (parts.length > 0) return parts.join("\n"); + + // Back-compat: older logs may still have tags inside text blocks. + const rawText = extractRawText(message); + if (!rawText) return null; + const matches = [ + ...rawText.matchAll(/<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi), + ]; + const extracted = matches.map((m) => (m[1] ?? "").trim()).filter(Boolean); + return extracted.length > 0 ? extracted.join("\n") : null; +} + +export function extractThinkingCached(message: unknown): string | null { + if (!message || typeof message !== "object") return extractThinking(message); + const obj = message as object; + if (thinkingCache.has(obj)) return thinkingCache.get(obj) ?? null; + const value = extractThinking(message); + thinkingCache.set(obj, value); + return value; +} + +export function extractRawText(message: unknown): string | null { + const m = message as Record; + const content = m.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts = content + .map((p) => { + const item = p as Record; + if (item.type === "text" && typeof item.text === "string") return item.text; + return null; + }) + .filter((v): v is string => typeof v === "string"); + if (parts.length > 0) return parts.join("\n"); + } + if (typeof m.text === "string") return m.text; + return null; +} + +export function formatReasoningMarkdown(text: string): string { + const trimmed = text.trim(); + if (!trimmed) return ""; + const lines = trimmed + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => `_${line}_`); + return lines.length ? ["_Reasoning:_", ...lines].join("\n") : ""; +} diff --git a/ui/src/ui/chat/message-normalizer.test.ts b/ui/src/ui/chat/message-normalizer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c9c241b07a0305d26c3b3c50969d58e75137a59a --- /dev/null +++ b/ui/src/ui/chat/message-normalizer.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + normalizeMessage, + normalizeRoleForGrouping, + isToolResultMessage, +} from "./message-normalizer"; + +describe("message-normalizer", () => { + describe("normalizeMessage", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("normalizes message with string content", () => { + const result = normalizeMessage({ + role: "user", + content: "Hello world", + timestamp: 1000, + id: "msg-1", + }); + + expect(result).toEqual({ + role: "user", + content: [{ type: "text", text: "Hello world" }], + timestamp: 1000, + id: "msg-1", + }); + }); + + it("normalizes message with array content", () => { + const result = normalizeMessage({ + role: "assistant", + content: [ + { type: "text", text: "Here is the result" }, + { type: "tool_use", name: "bash", args: { command: "ls" } }, + ], + timestamp: 2000, + }); + + expect(result.role).toBe("assistant"); + expect(result.content).toHaveLength(2); + expect(result.content[0]).toEqual({ + type: "text", + text: "Here is the result", + name: undefined, + args: undefined, + }); + expect(result.content[1]).toEqual({ + type: "tool_use", + text: undefined, + name: "bash", + args: { command: "ls" }, + }); + }); + + it("normalizes message with text field (alternative format)", () => { + const result = normalizeMessage({ + role: "user", + text: "Alternative format", + }); + + expect(result.content).toEqual([{ type: "text", text: "Alternative format" }]); + }); + + it("detects tool result by toolCallId", () => { + const result = normalizeMessage({ + role: "assistant", + toolCallId: "call-123", + content: "Tool output", + }); + + expect(result.role).toBe("toolResult"); + }); + + it("detects tool result by tool_call_id (snake_case)", () => { + const result = normalizeMessage({ + role: "assistant", + tool_call_id: "call-456", + content: "Tool output", + }); + + expect(result.role).toBe("toolResult"); + }); + + it("handles missing role", () => { + const result = normalizeMessage({ content: "No role" }); + expect(result.role).toBe("unknown"); + }); + + it("handles missing content", () => { + const result = normalizeMessage({ role: "user" }); + expect(result.content).toEqual([]); + }); + + it("uses current timestamp when not provided", () => { + const result = normalizeMessage({ role: "user", content: "Test" }); + expect(result.timestamp).toBe(Date.now()); + }); + + it("handles arguments field (alternative to args)", () => { + const result = normalizeMessage({ + role: "assistant", + content: [{ type: "tool_use", name: "test", arguments: { foo: "bar" } }], + }); + + expect(result.content[0].args).toEqual({ foo: "bar" }); + }); + }); + + describe("normalizeRoleForGrouping", () => { + it("returns tool for toolresult", () => { + expect(normalizeRoleForGrouping("toolresult")).toBe("tool"); + expect(normalizeRoleForGrouping("toolResult")).toBe("tool"); + expect(normalizeRoleForGrouping("TOOLRESULT")).toBe("tool"); + }); + + it("returns tool for tool_result", () => { + expect(normalizeRoleForGrouping("tool_result")).toBe("tool"); + expect(normalizeRoleForGrouping("TOOL_RESULT")).toBe("tool"); + }); + + it("returns tool for tool", () => { + expect(normalizeRoleForGrouping("tool")).toBe("tool"); + expect(normalizeRoleForGrouping("Tool")).toBe("tool"); + }); + + it("returns tool for function", () => { + expect(normalizeRoleForGrouping("function")).toBe("tool"); + expect(normalizeRoleForGrouping("Function")).toBe("tool"); + }); + + it("preserves user role", () => { + expect(normalizeRoleForGrouping("user")).toBe("user"); + expect(normalizeRoleForGrouping("User")).toBe("User"); + }); + + it("preserves assistant role", () => { + expect(normalizeRoleForGrouping("assistant")).toBe("assistant"); + }); + + it("preserves system role", () => { + expect(normalizeRoleForGrouping("system")).toBe("system"); + }); + }); + + describe("isToolResultMessage", () => { + it("returns true for toolresult role", () => { + expect(isToolResultMessage({ role: "toolresult" })).toBe(true); + expect(isToolResultMessage({ role: "toolResult" })).toBe(true); + expect(isToolResultMessage({ role: "TOOLRESULT" })).toBe(true); + }); + + it("returns true for tool_result role", () => { + expect(isToolResultMessage({ role: "tool_result" })).toBe(true); + expect(isToolResultMessage({ role: "TOOL_RESULT" })).toBe(true); + }); + + it("returns false for other roles", () => { + expect(isToolResultMessage({ role: "user" })).toBe(false); + expect(isToolResultMessage({ role: "assistant" })).toBe(false); + expect(isToolResultMessage({ role: "tool" })).toBe(false); + }); + + it("returns false for missing role", () => { + expect(isToolResultMessage({})).toBe(false); + expect(isToolResultMessage({ content: "test" })).toBe(false); + }); + + it("returns false for non-string role", () => { + expect(isToolResultMessage({ role: 123 })).toBe(false); + expect(isToolResultMessage({ role: null })).toBe(false); + }); + }); +}); diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts new file mode 100644 index 0000000000000000000000000000000000000000..388939b9f971ce676d72190c29c6cadc67dd0b79 --- /dev/null +++ b/ui/src/ui/chat/message-normalizer.ts @@ -0,0 +1,86 @@ +/** + * Message normalization utilities for chat rendering. + */ + +import type { NormalizedMessage, MessageContentItem } from "../types/chat-types"; + +/** + * Normalize a raw message object into a consistent structure. + */ +export function normalizeMessage(message: unknown): NormalizedMessage { + const m = message as Record; + let role = typeof m.role === "string" ? m.role : "unknown"; + + // Detect tool messages by common gateway shapes. + // Some tool events come through as assistant role with tool_* items in the content array. + const hasToolId = typeof m.toolCallId === "string" || typeof m.tool_call_id === "string"; + + const contentRaw = m.content; + const contentItems = Array.isArray(contentRaw) ? contentRaw : null; + const hasToolContent = + Array.isArray(contentItems) && + contentItems.some((item) => { + const x = item as Record; + const t = String(x.type ?? "").toLowerCase(); + return t === "toolresult" || t === "tool_result"; + }); + + const hasToolName = + typeof (m as Record).toolName === "string" || + typeof (m as Record).tool_name === "string"; + + if (hasToolId || hasToolContent || hasToolName) { + role = "toolResult"; + } + + // Extract content + let content: MessageContentItem[] = []; + + if (typeof m.content === "string") { + content = [{ type: "text", text: m.content }]; + } else if (Array.isArray(m.content)) { + content = m.content.map((item: Record) => ({ + type: (item.type as MessageContentItem["type"]) || "text", + text: item.text as string | undefined, + name: item.name as string | undefined, + args: item.args || item.arguments, + })); + } else if (typeof m.text === "string") { + content = [{ type: "text", text: m.text }]; + } + + const timestamp = typeof m.timestamp === "number" ? m.timestamp : Date.now(); + const id = typeof m.id === "string" ? m.id : undefined; + + return { role, content, timestamp, id }; +} + +/** + * Normalize role for grouping purposes. + */ +export function normalizeRoleForGrouping(role: string): string { + const lower = role.toLowerCase(); + // Preserve original casing when it's already a core role. + if (role === "user" || role === "User") return role; + if (role === "assistant") return "assistant"; + if (role === "system") return "system"; + // Keep tool-related roles distinct so the UI can style/toggle them. + if ( + lower === "toolresult" || + lower === "tool_result" || + lower === "tool" || + lower === "function" + ) { + return "tool"; + } + return role; +} + +/** + * Check if a message is a tool result message based on its role. + */ +export function isToolResultMessage(message: unknown): boolean { + const m = message as Record; + const role = typeof m.role === "string" ? m.role.toLowerCase() : ""; + return role === "toolresult" || role === "tool_result"; +} diff --git a/ui/src/ui/chat/tool-cards.ts b/ui/src/ui/chat/tool-cards.ts new file mode 100644 index 0000000000000000000000000000000000000000..19e8cf82eb8893abd409dbfa9f5d7ee61cd7a606 --- /dev/null +++ b/ui/src/ui/chat/tool-cards.ts @@ -0,0 +1,140 @@ +import { html, nothing } from "lit"; +import type { ToolCard } from "../types/chat-types"; +import { icons } from "../icons"; +import { formatToolDetail, resolveToolDisplay } from "../tool-display"; +import { TOOL_INLINE_THRESHOLD } from "./constants"; +import { extractTextCached } from "./message-extract"; +import { isToolResultMessage } from "./message-normalizer"; +import { formatToolOutputForSidebar, getTruncatedPreview } from "./tool-helpers"; + +export function extractToolCards(message: unknown): ToolCard[] { + const m = message as Record; + const content = normalizeContent(m.content); + const cards: ToolCard[] = []; + + for (const item of content) { + const kind = String(item.type ?? "").toLowerCase(); + const isToolCall = + ["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) || + (typeof item.name === "string" && item.arguments != null); + if (isToolCall) { + cards.push({ + kind: "call", + name: (item.name as string) ?? "tool", + args: coerceArgs(item.arguments ?? item.args), + }); + } + } + + for (const item of content) { + const kind = String(item.type ?? "").toLowerCase(); + if (kind !== "toolresult" && kind !== "tool_result") continue; + const text = extractToolText(item); + const name = typeof item.name === "string" ? item.name : "tool"; + cards.push({ kind: "result", name, text }); + } + + if (isToolResultMessage(message) && !cards.some((card) => card.kind === "result")) { + const name = + (typeof m.toolName === "string" && m.toolName) || + (typeof m.tool_name === "string" && m.tool_name) || + "tool"; + const text = extractTextCached(message) ?? undefined; + cards.push({ kind: "result", name, text }); + } + + return cards; +} + +export function renderToolCardSidebar(card: ToolCard, onOpenSidebar?: (content: string) => void) { + const display = resolveToolDisplay({ name: card.name, args: card.args }); + const detail = formatToolDetail(display); + const hasText = Boolean(card.text?.trim()); + + const canClick = Boolean(onOpenSidebar); + const handleClick = canClick + ? () => { + if (hasText) { + onOpenSidebar!(formatToolOutputForSidebar(card.text!)); + return; + } + const info = `## ${display.label}\n\n${ + detail ? `**Command:** \`${detail}\`\n\n` : "" + }*No output — tool completed successfully.*`; + onOpenSidebar!(info); + } + : undefined; + + const isShort = hasText && (card.text?.length ?? 0) <= TOOL_INLINE_THRESHOLD; + const showCollapsed = hasText && !isShort; + const showInline = hasText && isShort; + const isEmpty = !hasText; + + return html` +
      { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + handleClick?.(); + } + : nothing + } + > +
      +
      + ${icons[display.icon]} + ${display.label} +
      + ${ + canClick + ? html`${hasText ? "View" : ""} ${icons.check}` + : nothing + } + ${isEmpty && !canClick ? html`${icons.check}` : nothing} +
      + ${detail ? html`
      ${detail}
      ` : nothing} + ${ + isEmpty + ? html` +
      Completed
      + ` + : nothing + } + ${ + showCollapsed + ? html`
      ${getTruncatedPreview(card.text!)}
      ` + : nothing + } + ${showInline ? html`
      ${card.text}
      ` : nothing} +
      + `; +} + +function normalizeContent(content: unknown): Array> { + if (!Array.isArray(content)) return []; + return content.filter(Boolean) as Array>; +} + +function coerceArgs(value: unknown): unknown { + if (typeof value !== "string") return value; + const trimmed = value.trim(); + if (!trimmed) return value; + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value; + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +function extractToolText(item: Record): string | undefined { + if (typeof item.text === "string") return item.text; + if (typeof item.content === "string") return item.content; + return undefined; +} diff --git a/ui/src/ui/chat/tool-helpers.test.ts b/ui/src/ui/chat/tool-helpers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1e1662330a36bbe23dca10b6f3d651ff1d5c8ef --- /dev/null +++ b/ui/src/ui/chat/tool-helpers.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { formatToolOutputForSidebar, getTruncatedPreview } from "./tool-helpers"; + +describe("tool-helpers", () => { + describe("formatToolOutputForSidebar", () => { + it("formats valid JSON object as code block", () => { + const input = '{"name":"test","value":123}'; + const result = formatToolOutputForSidebar(input); + + expect(result).toBe(`\`\`\`json +{ + "name": "test", + "value": 123 +} +\`\`\``); + }); + + it("formats valid JSON array as code block", () => { + const input = "[1, 2, 3]"; + const result = formatToolOutputForSidebar(input); + + expect(result).toBe(`\`\`\`json +[ + 1, + 2, + 3 +] +\`\`\``); + }); + + it("handles nested JSON objects", () => { + const input = '{"outer":{"inner":"value"}}'; + const result = formatToolOutputForSidebar(input); + + expect(result).toContain("```json"); + expect(result).toContain('"outer"'); + expect(result).toContain('"inner"'); + }); + + it("returns plain text for non-JSON content", () => { + const input = "This is plain text output"; + const result = formatToolOutputForSidebar(input); + + expect(result).toBe("This is plain text output"); + }); + + it("returns as-is for invalid JSON starting with {", () => { + const input = "{not valid json"; + const result = formatToolOutputForSidebar(input); + + expect(result).toBe("{not valid json"); + }); + + it("returns as-is for invalid JSON starting with [", () => { + const input = "[not valid json"; + const result = formatToolOutputForSidebar(input); + + expect(result).toBe("[not valid json"); + }); + + it("trims whitespace before detecting JSON", () => { + const input = ' {"trimmed": true} '; + const result = formatToolOutputForSidebar(input); + + expect(result).toContain("```json"); + expect(result).toContain('"trimmed"'); + }); + + it("handles empty string", () => { + const result = formatToolOutputForSidebar(""); + expect(result).toBe(""); + }); + + it("handles whitespace-only string", () => { + const result = formatToolOutputForSidebar(" "); + expect(result).toBe(" "); + }); + }); + + describe("getTruncatedPreview", () => { + it("returns short text unchanged", () => { + const input = "Short text"; + const result = getTruncatedPreview(input); + + expect(result).toBe("Short text"); + }); + + it("truncates text longer than max chars", () => { + const input = "a".repeat(150); + const result = getTruncatedPreview(input); + + expect(result.length).toBe(101); // 100 chars + ellipsis + expect(result.endsWith("…")).toBe(true); + }); + + it("truncates to max lines", () => { + const input = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"; + const result = getTruncatedPreview(input); + + // Should only show first 2 lines (PREVIEW_MAX_LINES = 2) + expect(result).toBe("Line 1\nLine 2…"); + }); + + it("adds ellipsis when lines are truncated", () => { + const input = "Line 1\nLine 2\nLine 3"; + const result = getTruncatedPreview(input); + + expect(result.endsWith("…")).toBe(true); + }); + + it("does not add ellipsis when all lines fit", () => { + const input = "Line 1\nLine 2"; + const result = getTruncatedPreview(input); + + expect(result).toBe("Line 1\nLine 2"); + expect(result.endsWith("…")).toBe(false); + }); + + it("handles single line within limits", () => { + const input = "Single line"; + const result = getTruncatedPreview(input); + + expect(result).toBe("Single line"); + }); + + it("handles empty string", () => { + const result = getTruncatedPreview(""); + expect(result).toBe(""); + }); + + it("truncates by chars even within line limit", () => { + // Two lines but very long content + const longLine = "x".repeat(80); + const input = `${longLine}\n${longLine}`; + const result = getTruncatedPreview(input); + + expect(result.length).toBe(101); // 100 + ellipsis + expect(result.endsWith("…")).toBe(true); + }); + }); +}); diff --git a/ui/src/ui/chat/tool-helpers.ts b/ui/src/ui/chat/tool-helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..f541fce90a3b1ce792a7a470b39cf13fc3dc601c --- /dev/null +++ b/ui/src/ui/chat/tool-helpers.ts @@ -0,0 +1,37 @@ +/** + * Helper functions for tool card rendering. + */ + +import { PREVIEW_MAX_CHARS, PREVIEW_MAX_LINES } from "./constants"; + +/** + * Format tool output content for display in the sidebar. + * Detects JSON and wraps it in a code block with formatting. + */ +export function formatToolOutputForSidebar(text: string): string { + const trimmed = text.trim(); + // Try to detect and format JSON + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed); + return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```"; + } catch { + // Not valid JSON, return as-is + } + } + return text; +} + +/** + * Get a truncated preview of tool output text. + * Truncates to first N lines or first N characters, whichever is shorter. + */ +export function getTruncatedPreview(text: string): string { + const allLines = text.split("\n"); + const lines = allLines.slice(0, PREVIEW_MAX_LINES); + const preview = lines.join("\n"); + if (preview.length > PREVIEW_MAX_CHARS) { + return preview.slice(0, PREVIEW_MAX_CHARS) + "…"; + } + return lines.length < allLines.length ? preview + "…" : preview; +} diff --git a/ui/src/ui/components/resizable-divider.ts b/ui/src/ui/components/resizable-divider.ts new file mode 100644 index 0000000000000000000000000000000000000000..98aba4bc6d8f052c1c0388c7cf3340ec2cd4d29c --- /dev/null +++ b/ui/src/ui/components/resizable-divider.ts @@ -0,0 +1,111 @@ +import { LitElement, html, css } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +/** + * A draggable divider for resizable split views. + * Dispatches 'resize' events with { splitRatio: number } detail. + */ +@customElement("resizable-divider") +export class ResizableDivider extends LitElement { + @property({ type: Number }) splitRatio = 0.6; + @property({ type: Number }) minRatio = 0.4; + @property({ type: Number }) maxRatio = 0.7; + + private isDragging = false; + private startX = 0; + private startRatio = 0; + + static styles = css` + :host { + width: 4px; + cursor: col-resize; + background: var(--border, #333); + transition: background 150ms ease-out; + flex-shrink: 0; + position: relative; + } + + :host::before { + content: ""; + position: absolute; + top: 0; + left: -4px; + right: -4px; + bottom: 0; + } + + :host(:hover) { + background: var(--accent, #007bff); + } + + :host(.dragging) { + background: var(--accent, #007bff); + } + `; + + render() { + return html` + + `; + } + + connectedCallback() { + super.connectedCallback(); + this.addEventListener("mousedown", this.handleMouseDown); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener("mousedown", this.handleMouseDown); + document.removeEventListener("mousemove", this.handleMouseMove); + document.removeEventListener("mouseup", this.handleMouseUp); + } + + private handleMouseDown = (e: MouseEvent) => { + this.isDragging = true; + this.startX = e.clientX; + this.startRatio = this.splitRatio; + this.classList.add("dragging"); + + document.addEventListener("mousemove", this.handleMouseMove); + document.addEventListener("mouseup", this.handleMouseUp); + + e.preventDefault(); + }; + + private handleMouseMove = (e: MouseEvent) => { + if (!this.isDragging) return; + + const container = this.parentElement; + if (!container) return; + + const containerWidth = container.getBoundingClientRect().width; + const deltaX = e.clientX - this.startX; + const deltaRatio = deltaX / containerWidth; + + let newRatio = this.startRatio + deltaRatio; + newRatio = Math.max(this.minRatio, Math.min(this.maxRatio, newRatio)); + + this.dispatchEvent( + new CustomEvent("resize", { + detail: { splitRatio: newRatio }, + bubbles: true, + composed: true, + }), + ); + }; + + private handleMouseUp = () => { + this.isDragging = false; + this.classList.remove("dragging"); + + document.removeEventListener("mousemove", this.handleMouseMove); + document.removeEventListener("mouseup", this.handleMouseUp); + }; +} + +declare global { + interface HTMLElementTagNameMap { + "resizable-divider": ResizableDivider; + } +} diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f64ee7f2001341c22cb441b429be470c9160a8e --- /dev/null +++ b/ui/src/ui/config-form.browser.test.ts @@ -0,0 +1,259 @@ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { analyzeConfigSchema, renderConfigForm } from "./views/config-form"; + +const rootSchema = { + type: "object", + properties: { + gateway: { + type: "object", + properties: { + auth: { + type: "object", + properties: { + token: { type: "string" }, + }, + }, + }, + }, + allowFrom: { + type: "array", + items: { type: "string" }, + }, + mode: { + type: "string", + enum: ["off", "token"], + }, + enabled: { + type: "boolean", + }, + bind: { + anyOf: [{ const: "auto" }, { const: "lan" }, { const: "tailnet" }, { const: "loopback" }], + }, + }, +}; + +describe("config form renderer", () => { + it("renders inputs and patches values", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const analysis = analyzeConfigSchema(rootSchema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: { + "gateway.auth.token": { label: "Gateway Token", sensitive: true }, + }, + unsupportedPaths: analysis.unsupportedPaths, + value: {}, + onPatch, + }), + container, + ); + + const tokenInput = container.querySelector("input[type='password']") as HTMLInputElement | null; + expect(tokenInput).not.toBeNull(); + if (!tokenInput) return; + tokenInput.value = "abc123"; + tokenInput.dispatchEvent(new Event("input", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["gateway", "auth", "token"], "abc123"); + + const tokenButton = Array.from( + container.querySelectorAll(".cfg-segmented__btn"), + ).find((btn) => btn.textContent?.trim() === "token"); + expect(tokenButton).not.toBeUndefined(); + tokenButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["mode"], "token"); + + const checkbox = container.querySelector("input[type='checkbox']") as HTMLInputElement | null; + expect(checkbox).not.toBeNull(); + if (!checkbox) return; + checkbox.checked = true; + checkbox.dispatchEvent(new Event("change", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["enabled"], true); + }); + + it("adds and removes array entries", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const analysis = analyzeConfigSchema(rootSchema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { allowFrom: ["+1"] }, + onPatch, + }), + container, + ); + + const addButton = container.querySelector(".cfg-array__add") as HTMLButtonElement | null; + expect(addButton).not.toBeUndefined(); + addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]); + + const removeButton = container.querySelector( + ".cfg-array__item-remove", + ) as HTMLButtonElement | null; + expect(removeButton).not.toBeUndefined(); + removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []); + }); + + it("renders union literals as select options", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const analysis = analyzeConfigSchema(rootSchema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { bind: "auto" }, + onPatch, + }), + container, + ); + + const tailnetButton = Array.from( + container.querySelectorAll(".cfg-segmented__btn"), + ).find((btn) => btn.textContent?.trim() === "tailnet"); + expect(tailnetButton).not.toBeUndefined(); + tailnetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet"); + }); + + it("renders map fields from additionalProperties", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + slack: { + type: "object", + additionalProperties: { + type: "string", + }, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { slack: { channelA: "ok" } }, + onPatch, + }), + container, + ); + + const removeButton = container.querySelector( + ".cfg-map__item-remove", + ) as HTMLButtonElement | null; + expect(removeButton).not.toBeUndefined(); + removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["slack"], {}); + }); + + it("supports wildcard uiHints for map entries", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + plugins: { + type: "object", + properties: { + entries: { + type: "object", + additionalProperties: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + }, + }, + }, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: { + "plugins.entries.*.enabled": { label: "Plugin Enabled" }, + }, + unsupportedPaths: analysis.unsupportedPaths, + value: { plugins: { entries: { "voice-call": { enabled: true } } } }, + onPatch, + }), + container, + ); + + expect(container.textContent).toContain("Plugin Enabled"); + }); + + it("flags unsupported unions", () => { + const schema = { + type: "object", + properties: { + mixed: { + anyOf: [{ type: "string" }, { type: "object", properties: {} }], + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + expect(analysis.unsupportedPaths).toContain("mixed"); + }); + + it("supports nullable types", () => { + const schema = { + type: "object", + properties: { + note: { type: ["string", "null"] }, + }, + }; + const analysis = analyzeConfigSchema(schema); + expect(analysis.unsupportedPaths).not.toContain("note"); + }); + + it("ignores untyped additionalProperties schemas", () => { + const schema = { + type: "object", + properties: { + channels: { + type: "object", + properties: { + whatsapp: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + }, + }, + additionalProperties: {}, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + expect(analysis.unsupportedPaths).not.toContain("channels"); + }); + + it("flags additionalProperties true", () => { + const schema = { + type: "object", + properties: { + extra: { + type: "object", + additionalProperties: true, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + expect(analysis.unsupportedPaths).toContain("extra"); + }); +}); diff --git a/ui/src/ui/controllers/agents.ts b/ui/src/ui/controllers/agents.ts new file mode 100644 index 0000000000000000000000000000000000000000..deb79ef6b579584df729b0c6a4d2498f57084c3d --- /dev/null +++ b/ui/src/ui/controllers/agents.ts @@ -0,0 +1,25 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { AgentsListResult } from "../types"; + +export type AgentsState = { + client: GatewayBrowserClient | null; + connected: boolean; + agentsLoading: boolean; + agentsError: string | null; + agentsList: AgentsListResult | null; +}; + +export async function loadAgents(state: AgentsState) { + if (!state.client || !state.connected) return; + if (state.agentsLoading) return; + state.agentsLoading = true; + state.agentsError = null; + try { + const res = (await state.client.request("agents.list", {})) as AgentsListResult | undefined; + if (res) state.agentsList = res; + } catch (err) { + state.agentsError = String(err); + } finally { + state.agentsLoading = false; + } +} diff --git a/ui/src/ui/controllers/assistant-identity.ts b/ui/src/ui/controllers/assistant-identity.ts new file mode 100644 index 0000000000000000000000000000000000000000..98eb090874fa096cdd875c75e87a1269eb04d6b9 --- /dev/null +++ b/ui/src/ui/controllers/assistant-identity.ts @@ -0,0 +1,32 @@ +import type { GatewayBrowserClient } from "../gateway"; +import { normalizeAssistantIdentity, type AssistantIdentity } from "../assistant-identity"; + +export type AssistantIdentityState = { + client: GatewayBrowserClient | null; + connected: boolean; + sessionKey: string; + assistantName: string; + assistantAvatar: string | null; + assistantAgentId: string | null; +}; + +export async function loadAssistantIdentity( + state: AssistantIdentityState, + opts?: { sessionKey?: string }, +) { + if (!state.client || !state.connected) return; + const sessionKey = opts?.sessionKey?.trim() || state.sessionKey.trim(); + const params = sessionKey ? { sessionKey } : {}; + try { + const res = (await state.client.request("agent.identity.get", params)) as + | Partial + | undefined; + if (!res) return; + const normalized = normalizeAssistantIdentity(res); + state.assistantName = normalized.name; + state.assistantAvatar = normalized.avatar; + state.assistantAgentId = normalized.agentId ?? null; + } catch { + // Ignore errors; keep last known identity. + } +} diff --git a/ui/src/ui/controllers/channels.ts b/ui/src/ui/controllers/channels.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e9e6ee1d91a844d9e8ca12cc821d0cfb8bd4a96 --- /dev/null +++ b/ui/src/ui/controllers/channels.ts @@ -0,0 +1,76 @@ +import type { ChannelsStatusSnapshot } from "../types"; +import type { ChannelsState } from "./channels.types"; + +export type { ChannelsState }; + +export async function loadChannels(state: ChannelsState, probe: boolean) { + if (!state.client || !state.connected) return; + if (state.channelsLoading) return; + state.channelsLoading = true; + state.channelsError = null; + try { + const res = (await state.client.request("channels.status", { + probe, + timeoutMs: 8000, + })) as ChannelsStatusSnapshot; + state.channelsSnapshot = res; + state.channelsLastSuccess = Date.now(); + } catch (err) { + state.channelsError = String(err); + } finally { + state.channelsLoading = false; + } +} + +export async function startWhatsAppLogin(state: ChannelsState, force: boolean) { + if (!state.client || !state.connected || state.whatsappBusy) return; + state.whatsappBusy = true; + try { + const res = (await state.client.request("web.login.start", { + force, + timeoutMs: 30000, + })) as { message?: string; qrDataUrl?: string }; + state.whatsappLoginMessage = res.message ?? null; + state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null; + state.whatsappLoginConnected = null; + } catch (err) { + state.whatsappLoginMessage = String(err); + state.whatsappLoginQrDataUrl = null; + state.whatsappLoginConnected = null; + } finally { + state.whatsappBusy = false; + } +} + +export async function waitWhatsAppLogin(state: ChannelsState) { + if (!state.client || !state.connected || state.whatsappBusy) return; + state.whatsappBusy = true; + try { + const res = (await state.client.request("web.login.wait", { + timeoutMs: 120000, + })) as { connected?: boolean; message?: string }; + state.whatsappLoginMessage = res.message ?? null; + state.whatsappLoginConnected = res.connected ?? null; + if (res.connected) state.whatsappLoginQrDataUrl = null; + } catch (err) { + state.whatsappLoginMessage = String(err); + state.whatsappLoginConnected = null; + } finally { + state.whatsappBusy = false; + } +} + +export async function logoutWhatsApp(state: ChannelsState) { + if (!state.client || !state.connected || state.whatsappBusy) return; + state.whatsappBusy = true; + try { + await state.client.request("channels.logout", { channel: "whatsapp" }); + state.whatsappLoginMessage = "Logged out."; + state.whatsappLoginQrDataUrl = null; + state.whatsappLoginConnected = null; + } catch (err) { + state.whatsappLoginMessage = String(err); + } finally { + state.whatsappBusy = false; + } +} diff --git a/ui/src/ui/controllers/channels.types.ts b/ui/src/ui/controllers/channels.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..15d6d08c95153736a889def59b10face499ab4a5 --- /dev/null +++ b/ui/src/ui/controllers/channels.types.ts @@ -0,0 +1,15 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { ChannelsStatusSnapshot } from "../types"; + +export type ChannelsState = { + client: GatewayBrowserClient | null; + connected: boolean; + channelsLoading: boolean; + channelsSnapshot: ChannelsStatusSnapshot | null; + channelsError: string | null; + channelsLastSuccess: number | null; + whatsappLoginMessage: string | null; + whatsappLoginQrDataUrl: string | null; + whatsappLoginConnected: boolean | null; + whatsappBusy: boolean; +}; diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bd3aeb7f758601d90e16dbd068f2bac3ba4cdcf --- /dev/null +++ b/ui/src/ui/controllers/chat.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { handleChatEvent, type ChatEventPayload, type ChatState } from "./chat"; + +function createState(overrides: Partial = {}): ChatState { + return { + client: null, + connected: true, + sessionKey: "main", + chatLoading: false, + chatMessages: [], + chatThinkingLevel: null, + chatSending: false, + chatMessage: "", + chatRunId: null, + chatStream: null, + chatStreamStartedAt: null, + lastError: null, + ...overrides, + }; +} + +describe("handleChatEvent", () => { + it("returns null when payload is missing", () => { + const state = createState(); + expect(handleChatEvent(state, undefined)).toBe(null); + }); + + it("returns null when sessionKey does not match", () => { + const state = createState({ sessionKey: "main" }); + const payload: ChatEventPayload = { + runId: "run-1", + sessionKey: "other", + state: "final", + }; + expect(handleChatEvent(state, payload)).toBe(null); + }); + + it("returns null for delta from another run", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-user", + chatStream: "Hello", + }); + const payload: ChatEventPayload = { + runId: "run-announce", + sessionKey: "main", + state: "delta", + message: { role: "assistant", content: [{ type: "text", text: "Done" }] }, + }; + expect(handleChatEvent(state, payload)).toBe(null); + expect(state.chatRunId).toBe("run-user"); + expect(state.chatStream).toBe("Hello"); + }); + + it("returns 'final' for final from another run (e.g. sub-agent announce) without clearing state", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-user", + chatStream: "Working...", + chatStreamStartedAt: 123, + }); + const payload: ChatEventPayload = { + runId: "run-announce", + sessionKey: "main", + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text: "Sub-agent findings" }], + }, + }; + expect(handleChatEvent(state, payload)).toBe("final"); + expect(state.chatRunId).toBe("run-user"); + expect(state.chatStream).toBe("Working..."); + expect(state.chatStreamStartedAt).toBe(123); + }); + + it("processes final from own run and clears state", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatStream: "Reply", + chatStreamStartedAt: 100, + }); + const payload: ChatEventPayload = { + runId: "run-1", + sessionKey: "main", + state: "final", + }; + expect(handleChatEvent(state, payload)).toBe("final"); + expect(state.chatRunId).toBe(null); + expect(state.chatStream).toBe(null); + expect(state.chatStreamStartedAt).toBe(null); + }); +}); diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts new file mode 100644 index 0000000000000000000000000000000000000000..582105114e8b905a90d9963424101667c95b9dd3 --- /dev/null +++ b/ui/src/ui/controllers/chat.ts @@ -0,0 +1,190 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { ChatAttachment } from "../ui-types"; +import { extractText } from "../chat/message-extract"; +import { generateUUID } from "../uuid"; + +export type ChatState = { + client: GatewayBrowserClient | null; + connected: boolean; + sessionKey: string; + chatLoading: boolean; + chatMessages: unknown[]; + chatThinkingLevel: string | null; + chatSending: boolean; + chatMessage: string; + chatAttachments: ChatAttachment[]; + chatRunId: string | null; + chatStream: string | null; + chatStreamStartedAt: number | null; + lastError: string | null; +}; + +export type ChatEventPayload = { + runId: string; + sessionKey: string; + state: "delta" | "final" | "aborted" | "error"; + message?: unknown; + errorMessage?: string; +}; + +export async function loadChatHistory(state: ChatState) { + if (!state.client || !state.connected) return; + state.chatLoading = true; + state.lastError = null; + try { + const res = (await state.client.request("chat.history", { + sessionKey: state.sessionKey, + limit: 200, + })) as { messages?: unknown[]; thinkingLevel?: string | null }; + state.chatMessages = Array.isArray(res.messages) ? res.messages : []; + state.chatThinkingLevel = res.thinkingLevel ?? null; + } catch (err) { + state.lastError = String(err); + } finally { + state.chatLoading = false; + } +} + +function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null { + const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl); + if (!match) return null; + return { mimeType: match[1], content: match[2] }; +} + +export async function sendChatMessage( + state: ChatState, + message: string, + attachments?: ChatAttachment[], +): Promise { + if (!state.client || !state.connected) return null; + const msg = message.trim(); + const hasAttachments = attachments && attachments.length > 0; + if (!msg && !hasAttachments) return null; + + const now = Date.now(); + + // Build user message content blocks + const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = []; + if (msg) { + contentBlocks.push({ type: "text", text: msg }); + } + // Add image previews to the message for display + if (hasAttachments) { + for (const att of attachments) { + contentBlocks.push({ + type: "image", + source: { type: "base64", media_type: att.mimeType, data: att.dataUrl }, + }); + } + } + + state.chatMessages = [ + ...state.chatMessages, + { + role: "user", + content: contentBlocks, + timestamp: now, + }, + ]; + + state.chatSending = true; + state.lastError = null; + const runId = generateUUID(); + state.chatRunId = runId; + state.chatStream = ""; + state.chatStreamStartedAt = now; + + // Convert attachments to API format + const apiAttachments = hasAttachments + ? attachments + .map((att) => { + const parsed = dataUrlToBase64(att.dataUrl); + if (!parsed) return null; + return { + type: "image", + mimeType: parsed.mimeType, + content: parsed.content, + }; + }) + .filter((a): a is NonNullable => a !== null) + : undefined; + + try { + await state.client.request("chat.send", { + sessionKey: state.sessionKey, + message: msg, + deliver: false, + idempotencyKey: runId, + attachments: apiAttachments, + }); + return runId; + } catch (err) { + const error = String(err); + state.chatRunId = null; + state.chatStream = null; + state.chatStreamStartedAt = null; + state.lastError = error; + state.chatMessages = [ + ...state.chatMessages, + { + role: "assistant", + content: [{ type: "text", text: "Error: " + error }], + timestamp: Date.now(), + }, + ]; + return null; + } finally { + state.chatSending = false; + } +} + +export async function abortChatRun(state: ChatState): Promise { + if (!state.client || !state.connected) return false; + const runId = state.chatRunId; + try { + await state.client.request( + "chat.abort", + runId ? { sessionKey: state.sessionKey, runId } : { sessionKey: state.sessionKey }, + ); + return true; + } catch (err) { + state.lastError = String(err); + return false; + } +} + +export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) { + if (!payload) return null; + if (payload.sessionKey !== state.sessionKey) return null; + + // Final from another run (e.g. sub-agent announce): refresh history to show new message. + // See https://github.com/openclaw/openclaw/issues/1909 + if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId) { + if (payload.state === "final") return "final"; + return null; + } + + if (payload.state === "delta") { + const next = extractText(payload.message); + if (typeof next === "string") { + const current = state.chatStream ?? ""; + if (!current || next.length >= current.length) { + state.chatStream = next; + } + } + } else if (payload.state === "final") { + state.chatStream = null; + state.chatRunId = null; + state.chatStreamStartedAt = null; + } else if (payload.state === "aborted") { + state.chatStream = null; + state.chatRunId = null; + state.chatStreamStartedAt = null; + } else if (payload.state === "error") { + state.chatStream = null; + state.chatRunId = null; + state.chatStreamStartedAt = null; + state.lastError = payload.errorMessage ?? "chat error"; + } + return payload.state; +} diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3b120f61077624bfb79e6001de0093faa35ab39 --- /dev/null +++ b/ui/src/ui/controllers/config.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from "vitest"; +import { + applyConfigSnapshot, + applyConfig, + runUpdate, + updateConfigFormValue, + type ConfigState, +} from "./config"; + +function createState(): ConfigState { + return { + client: null, + connected: false, + applySessionKey: "main", + configLoading: false, + configRaw: "", + configRawOriginal: "", + configValid: null, + configIssues: [], + configSaving: false, + configApplying: false, + updateRunning: false, + configSnapshot: null, + configSchema: null, + configSchemaVersion: null, + configSchemaLoading: false, + configUiHints: {}, + configForm: null, + configFormOriginal: null, + configFormDirty: false, + configFormMode: "form", + lastError: null, + }; +} + +describe("applyConfigSnapshot", () => { + it("does not clobber form edits while dirty", () => { + const state = createState(); + state.configFormMode = "form"; + state.configFormDirty = true; + state.configForm = { gateway: { mode: "local", port: 18789 } }; + state.configRaw = "{\n}\n"; + + applyConfigSnapshot(state, { + config: { gateway: { mode: "remote", port: 9999 } }, + valid: true, + issues: [], + raw: '{\n "gateway": { "mode": "remote", "port": 9999 }\n}\n', + }); + + expect(state.configRaw).toBe( + '{\n "gateway": {\n "mode": "local",\n "port": 18789\n }\n}\n', + ); + }); + + it("updates config form when clean", () => { + const state = createState(); + applyConfigSnapshot(state, { + config: { gateway: { mode: "local" } }, + valid: true, + issues: [], + raw: "{}", + }); + + expect(state.configForm).toEqual({ gateway: { mode: "local" } }); + }); + + it("sets configRawOriginal when clean for change detection", () => { + const state = createState(); + applyConfigSnapshot(state, { + config: { gateway: { mode: "local" } }, + valid: true, + issues: [], + raw: '{ "gateway": { "mode": "local" } }', + }); + + expect(state.configRawOriginal).toBe('{ "gateway": { "mode": "local" } }'); + expect(state.configFormOriginal).toEqual({ gateway: { mode: "local" } }); + }); + + it("preserves configRawOriginal when dirty", () => { + const state = createState(); + state.configFormDirty = true; + state.configRawOriginal = '{ "original": true }'; + state.configFormOriginal = { original: true }; + + applyConfigSnapshot(state, { + config: { gateway: { mode: "local" } }, + valid: true, + issues: [], + raw: '{ "gateway": { "mode": "local" } }', + }); + + // Original values should be preserved when dirty + expect(state.configRawOriginal).toBe('{ "original": true }'); + expect(state.configFormOriginal).toEqual({ original: true }); + }); +}); + +describe("updateConfigFormValue", () => { + it("seeds from snapshot when form is null", () => { + const state = createState(); + state.configSnapshot = { + config: { channels: { telegram: { botToken: "t" } }, gateway: { mode: "local" } }, + valid: true, + issues: [], + raw: "{}", + }; + + updateConfigFormValue(state, ["gateway", "port"], 18789); + + expect(state.configFormDirty).toBe(true); + expect(state.configForm).toEqual({ + channels: { telegram: { botToken: "t" } }, + gateway: { mode: "local", port: 18789 }, + }); + }); + + it("keeps raw in sync while editing the form", () => { + const state = createState(); + state.configSnapshot = { + config: { gateway: { mode: "local" } }, + valid: true, + issues: [], + raw: "{\n}\n", + }; + + updateConfigFormValue(state, ["gateway", "port"], 18789); + + expect(state.configRaw).toBe( + '{\n "gateway": {\n "mode": "local",\n "port": 18789\n }\n}\n', + ); + }); +}); + +describe("applyConfig", () => { + it("sends config.apply with raw and session key", async () => { + const request = vi.fn().mockResolvedValue({}); + const state = createState(); + state.connected = true; + state.client = { request } as unknown as ConfigState["client"]; + state.applySessionKey = "agent:main:whatsapp:dm:+15555550123"; + state.configFormMode = "raw"; + state.configRaw = '{\n agent: { workspace: "~/openclaw" }\n}\n'; + state.configSnapshot = { + hash: "hash-123", + }; + + await applyConfig(state); + + expect(request).toHaveBeenCalledWith("config.apply", { + raw: '{\n agent: { workspace: "~/openclaw" }\n}\n', + baseHash: "hash-123", + sessionKey: "agent:main:whatsapp:dm:+15555550123", + }); + }); +}); + +describe("runUpdate", () => { + it("sends update.run with session key", async () => { + const request = vi.fn().mockResolvedValue({}); + const state = createState(); + state.connected = true; + state.client = { request } as unknown as ConfigState["client"]; + state.applySessionKey = "agent:main:whatsapp:dm:+15555550123"; + + await runUpdate(state); + + expect(request).toHaveBeenCalledWith("update.run", { + sessionKey: "agent:main:whatsapp:dm:+15555550123", + }); + }); +}); diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..84b9ae5158626287a0c637c82626e13af015ab1f --- /dev/null +++ b/ui/src/ui/controllers/config.ts @@ -0,0 +1,185 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../types"; +import { + cloneConfigObject, + removePathValue, + serializeConfigForm, + setPathValue, +} from "./config/form-utils"; + +export type ConfigState = { + client: GatewayBrowserClient | null; + connected: boolean; + applySessionKey: string; + configLoading: boolean; + configRaw: string; + configRawOriginal: string; + configValid: boolean | null; + configIssues: unknown[]; + configSaving: boolean; + configApplying: boolean; + updateRunning: boolean; + configSnapshot: ConfigSnapshot | null; + configSchema: unknown | null; + configSchemaVersion: string | null; + configSchemaLoading: boolean; + configUiHints: ConfigUiHints; + configForm: Record | null; + configFormOriginal: Record | null; + configFormDirty: boolean; + configFormMode: "form" | "raw"; + configSearchQuery: string; + configActiveSection: string | null; + configActiveSubsection: string | null; + lastError: string | null; +}; + +export async function loadConfig(state: ConfigState) { + if (!state.client || !state.connected) return; + state.configLoading = true; + state.lastError = null; + try { + const res = (await state.client.request("config.get", {})) as ConfigSnapshot; + applyConfigSnapshot(state, res); + } catch (err) { + state.lastError = String(err); + } finally { + state.configLoading = false; + } +} + +export async function loadConfigSchema(state: ConfigState) { + if (!state.client || !state.connected) return; + if (state.configSchemaLoading) return; + state.configSchemaLoading = true; + try { + const res = (await state.client.request("config.schema", {})) as ConfigSchemaResponse; + applyConfigSchema(state, res); + } catch (err) { + state.lastError = String(err); + } finally { + state.configSchemaLoading = false; + } +} + +export function applyConfigSchema(state: ConfigState, res: ConfigSchemaResponse) { + state.configSchema = res.schema ?? null; + state.configUiHints = res.uiHints ?? {}; + state.configSchemaVersion = res.version ?? null; +} + +export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot) { + state.configSnapshot = snapshot; + const rawFromSnapshot = + typeof snapshot.raw === "string" + ? snapshot.raw + : snapshot.config && typeof snapshot.config === "object" + ? serializeConfigForm(snapshot.config as Record) + : state.configRaw; + if (!state.configFormDirty || state.configFormMode === "raw") { + state.configRaw = rawFromSnapshot; + } else if (state.configForm) { + state.configRaw = serializeConfigForm(state.configForm); + } else { + state.configRaw = rawFromSnapshot; + } + state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null; + state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : []; + + if (!state.configFormDirty) { + state.configForm = cloneConfigObject(snapshot.config ?? {}); + state.configFormOriginal = cloneConfigObject(snapshot.config ?? {}); + state.configRawOriginal = rawFromSnapshot; + } +} + +export async function saveConfig(state: ConfigState) { + if (!state.client || !state.connected) return; + state.configSaving = true; + state.lastError = null; + try { + const raw = + state.configFormMode === "form" && state.configForm + ? serializeConfigForm(state.configForm) + : state.configRaw; + const baseHash = state.configSnapshot?.hash; + if (!baseHash) { + state.lastError = "Config hash missing; reload and retry."; + return; + } + await state.client.request("config.set", { raw, baseHash }); + state.configFormDirty = false; + await loadConfig(state); + } catch (err) { + state.lastError = String(err); + } finally { + state.configSaving = false; + } +} + +export async function applyConfig(state: ConfigState) { + if (!state.client || !state.connected) return; + state.configApplying = true; + state.lastError = null; + try { + const raw = + state.configFormMode === "form" && state.configForm + ? serializeConfigForm(state.configForm) + : state.configRaw; + const baseHash = state.configSnapshot?.hash; + if (!baseHash) { + state.lastError = "Config hash missing; reload and retry."; + return; + } + await state.client.request("config.apply", { + raw, + baseHash, + sessionKey: state.applySessionKey, + }); + state.configFormDirty = false; + await loadConfig(state); + } catch (err) { + state.lastError = String(err); + } finally { + state.configApplying = false; + } +} + +export async function runUpdate(state: ConfigState) { + if (!state.client || !state.connected) return; + state.updateRunning = true; + state.lastError = null; + try { + await state.client.request("update.run", { + sessionKey: state.applySessionKey, + }); + } catch (err) { + state.lastError = String(err); + } finally { + state.updateRunning = false; + } +} + +export function updateConfigFormValue( + state: ConfigState, + path: Array, + value: unknown, +) { + const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {}); + setPathValue(base, path, value); + state.configForm = base; + state.configFormDirty = true; + if (state.configFormMode === "form") { + state.configRaw = serializeConfigForm(base); + } +} + +export function removeConfigFormValue(state: ConfigState, path: Array) { + const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {}); + removePathValue(base, path); + state.configForm = base; + state.configFormDirty = true; + if (state.configFormMode === "form") { + state.configRaw = serializeConfigForm(base); + } +} diff --git a/ui/src/ui/controllers/config/form-utils.ts b/ui/src/ui/controllers/config/form-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..1edd97b9c6d374d0e32d1c0f87438f06dc97b884 --- /dev/null +++ b/ui/src/ui/controllers/config/form-utils.ts @@ -0,0 +1,72 @@ +export function cloneConfigObject(value: T): T { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)) as T; +} + +export function serializeConfigForm(form: Record): string { + return `${JSON.stringify(form, null, 2).trimEnd()}\n`; +} + +export function setPathValue( + obj: Record | unknown[], + path: Array, + value: unknown, +) { + if (path.length === 0) return; + let current: Record | unknown[] = obj; + for (let i = 0; i < path.length - 1; i += 1) { + const key = path[i]; + const nextKey = path[i + 1]; + if (typeof key === "number") { + if (!Array.isArray(current)) return; + if (current[key] == null) { + current[key] = typeof nextKey === "number" ? [] : ({} as Record); + } + current = current[key] as Record | unknown[]; + } else { + if (typeof current !== "object" || current == null) return; + const record = current as Record; + if (record[key] == null) { + record[key] = typeof nextKey === "number" ? [] : ({} as Record); + } + current = record[key] as Record | unknown[]; + } + } + const lastKey = path[path.length - 1]; + if (typeof lastKey === "number") { + if (Array.isArray(current)) current[lastKey] = value; + return; + } + if (typeof current === "object" && current != null) { + (current as Record)[lastKey] = value; + } +} + +export function removePathValue( + obj: Record | unknown[], + path: Array, +) { + if (path.length === 0) return; + let current: Record | unknown[] = obj; + for (let i = 0; i < path.length - 1; i += 1) { + const key = path[i]; + if (typeof key === "number") { + if (!Array.isArray(current)) return; + current = current[key] as Record | unknown[]; + } else { + if (typeof current !== "object" || current == null) return; + current = (current as Record)[key] as Record | unknown[]; + } + if (current == null) return; + } + const lastKey = path[path.length - 1]; + if (typeof lastKey === "number") { + if (Array.isArray(current)) current.splice(lastKey, 1); + return; + } + if (typeof current === "object" && current != null) { + delete (current as Record)[lastKey]; + } +} diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac128cab8a96e88477cd4c766e6ba933fc1e18c9 --- /dev/null +++ b/ui/src/ui/controllers/cron.ts @@ -0,0 +1,187 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { CronJob, CronRunLogEntry, CronStatus } from "../types"; +import type { CronFormState } from "../ui-types"; +import { toNumber } from "../format"; + +export type CronState = { + client: GatewayBrowserClient | null; + connected: boolean; + cronLoading: boolean; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + cronError: string | null; + cronForm: CronFormState; + cronRunsJobId: string | null; + cronRuns: CronRunLogEntry[]; + cronBusy: boolean; +}; + +export async function loadCronStatus(state: CronState) { + if (!state.client || !state.connected) return; + try { + const res = (await state.client.request("cron.status", {})) as CronStatus; + state.cronStatus = res; + } catch (err) { + state.cronError = String(err); + } +} + +export async function loadCronJobs(state: CronState) { + if (!state.client || !state.connected) return; + if (state.cronLoading) return; + state.cronLoading = true; + state.cronError = null; + try { + const res = (await state.client.request("cron.list", { + includeDisabled: true, + })) as { jobs?: CronJob[] }; + state.cronJobs = Array.isArray(res.jobs) ? res.jobs : []; + } catch (err) { + state.cronError = String(err); + } finally { + state.cronLoading = false; + } +} + +export function buildCronSchedule(form: CronFormState) { + if (form.scheduleKind === "at") { + const ms = Date.parse(form.scheduleAt); + if (!Number.isFinite(ms)) throw new Error("Invalid run time."); + return { kind: "at" as const, atMs: ms }; + } + if (form.scheduleKind === "every") { + const amount = toNumber(form.everyAmount, 0); + if (amount <= 0) throw new Error("Invalid interval amount."); + const unit = form.everyUnit; + const mult = unit === "minutes" ? 60_000 : unit === "hours" ? 3_600_000 : 86_400_000; + return { kind: "every" as const, everyMs: amount * mult }; + } + const expr = form.cronExpr.trim(); + if (!expr) throw new Error("Cron expression required."); + return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined }; +} + +export function buildCronPayload(form: CronFormState) { + if (form.payloadKind === "systemEvent") { + const text = form.payloadText.trim(); + if (!text) throw new Error("System event text required."); + return { kind: "systemEvent" as const, text }; + } + const message = form.payloadText.trim(); + if (!message) throw new Error("Agent message required."); + const payload: { + kind: "agentTurn"; + message: string; + deliver?: boolean; + channel?: string; + to?: string; + timeoutSeconds?: number; + } = { kind: "agentTurn", message }; + if (form.deliver) payload.deliver = true; + if (form.channel) payload.channel = form.channel; + if (form.to.trim()) payload.to = form.to.trim(); + const timeoutSeconds = toNumber(form.timeoutSeconds, 0); + if (timeoutSeconds > 0) payload.timeoutSeconds = timeoutSeconds; + return payload; +} + +export async function addCronJob(state: CronState) { + if (!state.client || !state.connected || state.cronBusy) return; + state.cronBusy = true; + state.cronError = null; + try { + const schedule = buildCronSchedule(state.cronForm); + const payload = buildCronPayload(state.cronForm); + const agentId = state.cronForm.agentId.trim(); + const job = { + name: state.cronForm.name.trim(), + description: state.cronForm.description.trim() || undefined, + agentId: agentId || undefined, + enabled: state.cronForm.enabled, + schedule, + sessionTarget: state.cronForm.sessionTarget, + wakeMode: state.cronForm.wakeMode, + payload, + isolation: + state.cronForm.postToMainPrefix.trim() && state.cronForm.sessionTarget === "isolated" + ? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() } + : undefined, + }; + if (!job.name) throw new Error("Name required."); + await state.client.request("cron.add", job); + state.cronForm = { + ...state.cronForm, + name: "", + description: "", + payloadText: "", + }; + await loadCronJobs(state); + await loadCronStatus(state); + } catch (err) { + state.cronError = String(err); + } finally { + state.cronBusy = false; + } +} + +export async function toggleCronJob(state: CronState, job: CronJob, enabled: boolean) { + if (!state.client || !state.connected || state.cronBusy) return; + state.cronBusy = true; + state.cronError = null; + try { + await state.client.request("cron.update", { id: job.id, patch: { enabled } }); + await loadCronJobs(state); + await loadCronStatus(state); + } catch (err) { + state.cronError = String(err); + } finally { + state.cronBusy = false; + } +} + +export async function runCronJob(state: CronState, job: CronJob) { + if (!state.client || !state.connected || state.cronBusy) return; + state.cronBusy = true; + state.cronError = null; + try { + await state.client.request("cron.run", { id: job.id, mode: "force" }); + await loadCronRuns(state, job.id); + } catch (err) { + state.cronError = String(err); + } finally { + state.cronBusy = false; + } +} + +export async function removeCronJob(state: CronState, job: CronJob) { + if (!state.client || !state.connected || state.cronBusy) return; + state.cronBusy = true; + state.cronError = null; + try { + await state.client.request("cron.remove", { id: job.id }); + if (state.cronRunsJobId === job.id) { + state.cronRunsJobId = null; + state.cronRuns = []; + } + await loadCronJobs(state); + await loadCronStatus(state); + } catch (err) { + state.cronError = String(err); + } finally { + state.cronBusy = false; + } +} + +export async function loadCronRuns(state: CronState, jobId: string) { + if (!state.client || !state.connected) return; + try { + const res = (await state.client.request("cron.runs", { + id: jobId, + limit: 50, + })) as { entries?: CronRunLogEntry[] }; + state.cronRunsJobId = jobId; + state.cronRuns = Array.isArray(res.entries) ? res.entries : []; + } catch (err) { + state.cronError = String(err); + } +} diff --git a/ui/src/ui/controllers/debug.ts b/ui/src/ui/controllers/debug.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f189af88f2adf20021f14deff5356b8b7f04582 --- /dev/null +++ b/ui/src/ui/controllers/debug.ts @@ -0,0 +1,54 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { HealthSnapshot, StatusSummary } from "../types"; + +export type DebugState = { + client: GatewayBrowserClient | null; + connected: boolean; + debugLoading: boolean; + debugStatus: StatusSummary | null; + debugHealth: HealthSnapshot | null; + debugModels: unknown[]; + debugHeartbeat: unknown | null; + debugCallMethod: string; + debugCallParams: string; + debugCallResult: string | null; + debugCallError: string | null; +}; + +export async function loadDebug(state: DebugState) { + if (!state.client || !state.connected) return; + if (state.debugLoading) return; + state.debugLoading = true; + try { + const [status, health, models, heartbeat] = await Promise.all([ + state.client.request("status", {}), + state.client.request("health", {}), + state.client.request("models.list", {}), + state.client.request("last-heartbeat", {}), + ]); + state.debugStatus = status as StatusSummary; + state.debugHealth = health as HealthSnapshot; + const modelPayload = models as { models?: unknown[] } | undefined; + state.debugModels = Array.isArray(modelPayload?.models) ? modelPayload?.models : []; + state.debugHeartbeat = heartbeat as unknown; + } catch (err) { + state.debugCallError = String(err); + } finally { + state.debugLoading = false; + } +} + +export async function callDebugMethod(state: DebugState) { + if (!state.client || !state.connected) return; + state.debugCallError = null; + state.debugCallResult = null; + try { + const params = state.debugCallParams.trim() + ? (JSON.parse(state.debugCallParams) as unknown) + : {}; + const res = await state.client.request(state.debugCallMethod.trim(), params); + state.debugCallResult = JSON.stringify(res, null, 2); + } catch (err) { + state.debugCallError = String(err); + } +} diff --git a/ui/src/ui/controllers/devices.ts b/ui/src/ui/controllers/devices.ts new file mode 100644 index 0000000000000000000000000000000000000000..e63547ba72ed11b70b3f93c838e34ff958287688 --- /dev/null +++ b/ui/src/ui/controllers/devices.ts @@ -0,0 +1,133 @@ +import type { GatewayBrowserClient } from "../gateway"; +import { clearDeviceAuthToken, storeDeviceAuthToken } from "../device-auth"; +import { loadOrCreateDeviceIdentity } from "../device-identity"; + +export type DeviceTokenSummary = { + role: string; + scopes?: string[]; + createdAtMs?: number; + rotatedAtMs?: number; + revokedAtMs?: number; + lastUsedAtMs?: number; +}; + +export type PendingDevice = { + requestId: string; + deviceId: string; + displayName?: string; + role?: string; + remoteIp?: string; + isRepair?: boolean; + ts?: number; +}; + +export type PairedDevice = { + deviceId: string; + displayName?: string; + roles?: string[]; + scopes?: string[]; + remoteIp?: string; + tokens?: DeviceTokenSummary[]; + createdAtMs?: number; + approvedAtMs?: number; +}; + +export type DevicePairingList = { + pending: PendingDevice[]; + paired: PairedDevice[]; +}; + +export type DevicesState = { + client: GatewayBrowserClient | null; + connected: boolean; + devicesLoading: boolean; + devicesError: string | null; + devicesList: DevicePairingList | null; +}; + +export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) { + if (!state.client || !state.connected) return; + if (state.devicesLoading) return; + state.devicesLoading = true; + if (!opts?.quiet) state.devicesError = null; + try { + const res = (await state.client.request("device.pair.list", {})) as DevicePairingList | null; + state.devicesList = { + pending: Array.isArray(res?.pending) ? res!.pending : [], + paired: Array.isArray(res?.paired) ? res!.paired : [], + }; + } catch (err) { + if (!opts?.quiet) state.devicesError = String(err); + } finally { + state.devicesLoading = false; + } +} + +export async function approveDevicePairing(state: DevicesState, requestId: string) { + if (!state.client || !state.connected) return; + try { + await state.client.request("device.pair.approve", { requestId }); + await loadDevices(state); + } catch (err) { + state.devicesError = String(err); + } +} + +export async function rejectDevicePairing(state: DevicesState, requestId: string) { + if (!state.client || !state.connected) return; + const confirmed = window.confirm("Reject this device pairing request?"); + if (!confirmed) return; + try { + await state.client.request("device.pair.reject", { requestId }); + await loadDevices(state); + } catch (err) { + state.devicesError = String(err); + } +} + +export async function rotateDeviceToken( + state: DevicesState, + params: { deviceId: string; role: string; scopes?: string[] }, +) { + if (!state.client || !state.connected) return; + try { + const res = (await state.client.request("device.token.rotate", params)) as + | { token?: string; role?: string; deviceId?: string; scopes?: string[] } + | undefined; + if (res?.token) { + const identity = await loadOrCreateDeviceIdentity(); + const role = res.role ?? params.role; + if (res.deviceId === identity.deviceId || params.deviceId === identity.deviceId) { + storeDeviceAuthToken({ + deviceId: identity.deviceId, + role, + token: res.token, + scopes: res.scopes ?? params.scopes ?? [], + }); + } + window.prompt("New device token (copy and store securely):", res.token); + } + await loadDevices(state); + } catch (err) { + state.devicesError = String(err); + } +} + +export async function revokeDeviceToken( + state: DevicesState, + params: { deviceId: string; role: string }, +) { + if (!state.client || !state.connected) return; + const confirmed = window.confirm(`Revoke token for ${params.deviceId} (${params.role})?`); + if (!confirmed) return; + try { + await state.client.request("device.token.revoke", params); + const identity = await loadOrCreateDeviceIdentity(); + if (params.deviceId === identity.deviceId) { + clearDeviceAuthToken({ deviceId: identity.deviceId, role: params.role }); + } + await loadDevices(state); + } catch (err) { + state.devicesError = String(err); + } +} diff --git a/ui/src/ui/controllers/exec-approval.ts b/ui/src/ui/controllers/exec-approval.ts new file mode 100644 index 0000000000000000000000000000000000000000..968b14efcf44f91b9c20d8c231383599afb7b259 --- /dev/null +++ b/ui/src/ui/controllers/exec-approval.ts @@ -0,0 +1,88 @@ +export type ExecApprovalRequestPayload = { + command: string; + cwd?: string | null; + host?: string | null; + security?: string | null; + ask?: string | null; + agentId?: string | null; + resolvedPath?: string | null; + sessionKey?: string | null; +}; + +export type ExecApprovalRequest = { + id: string; + request: ExecApprovalRequestPayload; + createdAtMs: number; + expiresAtMs: number; +}; + +export type ExecApprovalResolved = { + id: string; + decision?: string | null; + resolvedBy?: string | null; + ts?: number | null; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null { + if (!isRecord(payload)) return null; + const id = typeof payload.id === "string" ? payload.id.trim() : ""; + const request = payload.request; + if (!id || !isRecord(request)) return null; + const command = typeof request.command === "string" ? request.command.trim() : ""; + if (!command) return null; + const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0; + const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0; + if (!createdAtMs || !expiresAtMs) return null; + return { + id, + request: { + command, + cwd: typeof request.cwd === "string" ? request.cwd : null, + host: typeof request.host === "string" ? request.host : null, + security: typeof request.security === "string" ? request.security : null, + ask: typeof request.ask === "string" ? request.ask : null, + agentId: typeof request.agentId === "string" ? request.agentId : null, + resolvedPath: typeof request.resolvedPath === "string" ? request.resolvedPath : null, + sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null, + }, + createdAtMs, + expiresAtMs, + }; +} + +export function parseExecApprovalResolved(payload: unknown): ExecApprovalResolved | null { + if (!isRecord(payload)) return null; + const id = typeof payload.id === "string" ? payload.id.trim() : ""; + if (!id) return null; + return { + id, + decision: typeof payload.decision === "string" ? payload.decision : null, + resolvedBy: typeof payload.resolvedBy === "string" ? payload.resolvedBy : null, + ts: typeof payload.ts === "number" ? payload.ts : null, + }; +} + +export function pruneExecApprovalQueue(queue: ExecApprovalRequest[]): ExecApprovalRequest[] { + const now = Date.now(); + return queue.filter((entry) => entry.expiresAtMs > now); +} + +export function addExecApproval( + queue: ExecApprovalRequest[], + entry: ExecApprovalRequest, +): ExecApprovalRequest[] { + const next = pruneExecApprovalQueue(queue).filter((item) => item.id !== entry.id); + next.push(entry); + return next; +} + +export function removeExecApproval( + queue: ExecApprovalRequest[], + id: string, +): ExecApprovalRequest[] { + return pruneExecApprovalQueue(queue).filter((entry) => entry.id !== id); +} diff --git a/ui/src/ui/controllers/exec-approvals.ts b/ui/src/ui/controllers/exec-approvals.ts new file mode 100644 index 0000000000000000000000000000000000000000..87804642fdf47dbc30dd51d57731d02ee8b8a13e --- /dev/null +++ b/ui/src/ui/controllers/exec-approvals.ts @@ -0,0 +1,160 @@ +import type { GatewayBrowserClient } from "../gateway"; +import { cloneConfigObject, removePathValue, setPathValue } from "./config/form-utils"; + +export type ExecApprovalsDefaults = { + security?: string; + ask?: string; + askFallback?: string; + autoAllowSkills?: boolean; +}; + +export type ExecApprovalsAllowlistEntry = { + id?: string; + pattern: string; + lastUsedAt?: number; + lastUsedCommand?: string; + lastResolvedPath?: string; +}; + +export type ExecApprovalsAgent = ExecApprovalsDefaults & { + allowlist?: ExecApprovalsAllowlistEntry[]; +}; + +export type ExecApprovalsFile = { + version?: number; + socket?: { path?: string }; + defaults?: ExecApprovalsDefaults; + agents?: Record; +}; + +export type ExecApprovalsSnapshot = { + path: string; + exists: boolean; + hash: string; + file: ExecApprovalsFile; +}; + +export type ExecApprovalsTarget = { kind: "gateway" } | { kind: "node"; nodeId: string }; + +export type ExecApprovalsState = { + client: GatewayBrowserClient | null; + connected: boolean; + execApprovalsLoading: boolean; + execApprovalsSaving: boolean; + execApprovalsDirty: boolean; + execApprovalsSnapshot: ExecApprovalsSnapshot | null; + execApprovalsForm: ExecApprovalsFile | null; + execApprovalsSelectedAgent: string | null; + lastError: string | null; +}; + +function resolveExecApprovalsRpc(target?: ExecApprovalsTarget | null): { + method: string; + params: Record; +} | null { + if (!target || target.kind === "gateway") { + return { method: "exec.approvals.get", params: {} }; + } + const nodeId = target.nodeId.trim(); + if (!nodeId) return null; + return { method: "exec.approvals.node.get", params: { nodeId } }; +} + +function resolveExecApprovalsSaveRpc( + target: ExecApprovalsTarget | null | undefined, + params: { file: ExecApprovalsFile; baseHash: string }, +): { method: string; params: Record } | null { + if (!target || target.kind === "gateway") { + return { method: "exec.approvals.set", params }; + } + const nodeId = target.nodeId.trim(); + if (!nodeId) return null; + return { method: "exec.approvals.node.set", params: { ...params, nodeId } }; +} + +export async function loadExecApprovals( + state: ExecApprovalsState, + target?: ExecApprovalsTarget | null, +) { + if (!state.client || !state.connected) return; + if (state.execApprovalsLoading) return; + state.execApprovalsLoading = true; + state.lastError = null; + try { + const rpc = resolveExecApprovalsRpc(target); + if (!rpc) { + state.lastError = "Select a node before loading exec approvals."; + return; + } + const res = (await state.client.request(rpc.method, rpc.params)) as ExecApprovalsSnapshot; + applyExecApprovalsSnapshot(state, res); + } catch (err) { + state.lastError = String(err); + } finally { + state.execApprovalsLoading = false; + } +} + +export function applyExecApprovalsSnapshot( + state: ExecApprovalsState, + snapshot: ExecApprovalsSnapshot, +) { + state.execApprovalsSnapshot = snapshot; + if (!state.execApprovalsDirty) { + state.execApprovalsForm = cloneConfigObject(snapshot.file ?? {}); + } +} + +export async function saveExecApprovals( + state: ExecApprovalsState, + target?: ExecApprovalsTarget | null, +) { + if (!state.client || !state.connected) return; + state.execApprovalsSaving = true; + state.lastError = null; + try { + const baseHash = state.execApprovalsSnapshot?.hash; + if (!baseHash) { + state.lastError = "Exec approvals hash missing; reload and retry."; + return; + } + const file = state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}; + const rpc = resolveExecApprovalsSaveRpc(target, { file, baseHash }); + if (!rpc) { + state.lastError = "Select a node before saving exec approvals."; + return; + } + await state.client.request(rpc.method, rpc.params); + state.execApprovalsDirty = false; + await loadExecApprovals(state, target); + } catch (err) { + state.lastError = String(err); + } finally { + state.execApprovalsSaving = false; + } +} + +export function updateExecApprovalsFormValue( + state: ExecApprovalsState, + path: Array, + value: unknown, +) { + const base = cloneConfigObject( + state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}, + ); + setPathValue(base, path, value); + state.execApprovalsForm = base; + state.execApprovalsDirty = true; +} + +export function removeExecApprovalsFormValue( + state: ExecApprovalsState, + path: Array, +) { + const base = cloneConfigObject( + state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}, + ); + removePathValue(base, path); + state.execApprovalsForm = base; + state.execApprovalsDirty = true; +} diff --git a/ui/src/ui/controllers/logs.ts b/ui/src/ui/controllers/logs.ts new file mode 100644 index 0000000000000000000000000000000000000000..662b5d7cb2cfdf02695ebc9fa6203b37bba96949 --- /dev/null +++ b/ui/src/ui/controllers/logs.ts @@ -0,0 +1,122 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { LogEntry, LogLevel } from "../types"; + +export type LogsState = { + client: GatewayBrowserClient | null; + connected: boolean; + logsLoading: boolean; + logsError: string | null; + logsCursor: number | null; + logsFile: string | null; + logsEntries: LogEntry[]; + logsTruncated: boolean; + logsLastFetchAt: number | null; + logsLimit: number; + logsMaxBytes: number; +}; + +const LOG_BUFFER_LIMIT = 2000; +const LEVELS = new Set(["trace", "debug", "info", "warn", "error", "fatal"]); + +function parseMaybeJsonString(value: unknown) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== "object") return null; + return parsed as Record; + } catch { + return null; + } +} + +function normalizeLevel(value: unknown): LogLevel | null { + if (typeof value !== "string") return null; + const lowered = value.toLowerCase() as LogLevel; + return LEVELS.has(lowered) ? lowered : null; +} + +export function parseLogLine(line: string): LogEntry { + if (!line.trim()) return { raw: line, message: line }; + try { + const obj = JSON.parse(line) as Record; + const meta = + obj && typeof obj._meta === "object" && obj._meta !== null + ? (obj._meta as Record) + : null; + const time = + typeof obj.time === "string" ? obj.time : typeof meta?.date === "string" ? meta?.date : null; + const level = normalizeLevel(meta?.logLevelName ?? meta?.level); + + const contextCandidate = + typeof obj["0"] === "string" + ? (obj["0"] as string) + : typeof meta?.name === "string" + ? (meta?.name as string) + : null; + const contextObj = parseMaybeJsonString(contextCandidate); + let subsystem: string | null = null; + if (contextObj) { + if (typeof contextObj.subsystem === "string") subsystem = contextObj.subsystem; + else if (typeof contextObj.module === "string") subsystem = contextObj.module; + } + if (!subsystem && contextCandidate && contextCandidate.length < 120) { + subsystem = contextCandidate; + } + + let message: string | null = null; + if (typeof obj["1"] === "string") message = obj["1"] as string; + else if (!contextObj && typeof obj["0"] === "string") message = obj["0"] as string; + else if (typeof obj.message === "string") message = obj.message as string; + + return { + raw: line, + time, + level, + subsystem, + message: message ?? line, + meta: meta ?? undefined, + }; + } catch { + return { raw: line, message: line }; + } +} + +export async function loadLogs(state: LogsState, opts?: { reset?: boolean; quiet?: boolean }) { + if (!state.client || !state.connected) return; + if (state.logsLoading && !opts?.quiet) return; + if (!opts?.quiet) state.logsLoading = true; + state.logsError = null; + try { + const res = await state.client.request("logs.tail", { + cursor: opts?.reset ? undefined : (state.logsCursor ?? undefined), + limit: state.logsLimit, + maxBytes: state.logsMaxBytes, + }); + const payload = res as { + file?: string; + cursor?: number; + size?: number; + lines?: unknown; + truncated?: boolean; + reset?: boolean; + }; + const lines = Array.isArray(payload.lines) + ? (payload.lines.filter((line) => typeof line === "string") as string[]) + : []; + const entries = lines.map(parseLogLine); + const shouldReset = Boolean(opts?.reset || payload.reset || state.logsCursor == null); + state.logsEntries = shouldReset + ? entries + : [...state.logsEntries, ...entries].slice(-LOG_BUFFER_LIMIT); + if (typeof payload.cursor === "number") state.logsCursor = payload.cursor; + if (typeof payload.file === "string") state.logsFile = payload.file; + state.logsTruncated = Boolean(payload.truncated); + state.logsLastFetchAt = Date.now(); + } catch (err) { + state.logsError = String(err); + } finally { + if (!opts?.quiet) state.logsLoading = false; + } +} diff --git a/ui/src/ui/controllers/nodes.ts b/ui/src/ui/controllers/nodes.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9255aaea7b0a4ea7326d7aefb605553c0fe3c0f --- /dev/null +++ b/ui/src/ui/controllers/nodes.ts @@ -0,0 +1,26 @@ +import type { GatewayBrowserClient } from "../gateway"; + +export type NodesState = { + client: GatewayBrowserClient | null; + connected: boolean; + nodesLoading: boolean; + nodes: Array>; + lastError: string | null; +}; + +export async function loadNodes(state: NodesState, opts?: { quiet?: boolean }) { + if (!state.client || !state.connected) return; + if (state.nodesLoading) return; + state.nodesLoading = true; + if (!opts?.quiet) state.lastError = null; + try { + const res = (await state.client.request("node.list", {})) as { + nodes?: Array>; + }; + state.nodes = Array.isArray(res.nodes) ? res.nodes : []; + } catch (err) { + if (!opts?.quiet) state.lastError = String(err); + } finally { + state.nodesLoading = false; + } +} diff --git a/ui/src/ui/controllers/presence.ts b/ui/src/ui/controllers/presence.ts new file mode 100644 index 0000000000000000000000000000000000000000..3dbec90618707bdd793bcd25eedcddb2f3d8d3cc --- /dev/null +++ b/ui/src/ui/controllers/presence.ts @@ -0,0 +1,33 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { PresenceEntry } from "../types"; + +export type PresenceState = { + client: GatewayBrowserClient | null; + connected: boolean; + presenceLoading: boolean; + presenceEntries: PresenceEntry[]; + presenceError: string | null; + presenceStatus: string | null; +}; + +export async function loadPresence(state: PresenceState) { + if (!state.client || !state.connected) return; + if (state.presenceLoading) return; + state.presenceLoading = true; + state.presenceError = null; + state.presenceStatus = null; + try { + const res = (await state.client.request("system-presence", {})) as PresenceEntry[] | undefined; + if (Array.isArray(res)) { + state.presenceEntries = res; + state.presenceStatus = res.length === 0 ? "No instances yet." : null; + } else { + state.presenceEntries = []; + state.presenceStatus = "No presence payload."; + } + } catch (err) { + state.presenceError = String(err); + } finally { + state.presenceLoading = false; + } +} diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts new file mode 100644 index 0000000000000000000000000000000000000000..82e8a8db170f7262afc67b19dac92a7119700b67 --- /dev/null +++ b/ui/src/ui/controllers/sessions.ts @@ -0,0 +1,93 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { SessionsListResult } from "../types"; +import { toNumber } from "../format"; + +export type SessionsState = { + client: GatewayBrowserClient | null; + connected: boolean; + sessionsLoading: boolean; + sessionsResult: SessionsListResult | null; + sessionsError: string | null; + sessionsFilterActive: string; + sessionsFilterLimit: string; + sessionsIncludeGlobal: boolean; + sessionsIncludeUnknown: boolean; +}; + +export async function loadSessions( + state: SessionsState, + overrides?: { + activeMinutes?: number; + limit?: number; + includeGlobal?: boolean; + includeUnknown?: boolean; + }, +) { + if (!state.client || !state.connected) return; + if (state.sessionsLoading) return; + state.sessionsLoading = true; + state.sessionsError = null; + try { + const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal; + const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown; + const activeMinutes = overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0); + const limit = overrides?.limit ?? toNumber(state.sessionsFilterLimit, 0); + const params: Record = { + includeGlobal, + includeUnknown, + }; + if (activeMinutes > 0) params.activeMinutes = activeMinutes; + if (limit > 0) params.limit = limit; + const res = (await state.client.request("sessions.list", params)) as + | SessionsListResult + | undefined; + if (res) state.sessionsResult = res; + } catch (err) { + state.sessionsError = String(err); + } finally { + state.sessionsLoading = false; + } +} + +export async function patchSession( + state: SessionsState, + key: string, + patch: { + label?: string | null; + thinkingLevel?: string | null; + verboseLevel?: string | null; + reasoningLevel?: string | null; + }, +) { + if (!state.client || !state.connected) return; + const params: Record = { key }; + if ("label" in patch) params.label = patch.label; + if ("thinkingLevel" in patch) params.thinkingLevel = patch.thinkingLevel; + if ("verboseLevel" in patch) params.verboseLevel = patch.verboseLevel; + if ("reasoningLevel" in patch) params.reasoningLevel = patch.reasoningLevel; + try { + await state.client.request("sessions.patch", params); + await loadSessions(state); + } catch (err) { + state.sessionsError = String(err); + } +} + +export async function deleteSession(state: SessionsState, key: string) { + if (!state.client || !state.connected) return; + if (state.sessionsLoading) return; + const confirmed = window.confirm( + `Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`, + ); + if (!confirmed) return; + state.sessionsLoading = true; + state.sessionsError = null; + try { + await state.client.request("sessions.delete", { key, deleteTranscript: true }); + await loadSessions(state); + } catch (err) { + state.sessionsError = String(err); + } finally { + state.sessionsLoading = false; + } +} diff --git a/ui/src/ui/controllers/skills.ts b/ui/src/ui/controllers/skills.ts new file mode 100644 index 0000000000000000000000000000000000000000..5708b12ef24ca3f1b7ad1300962f4ab49e0666d7 --- /dev/null +++ b/ui/src/ui/controllers/skills.ts @@ -0,0 +1,138 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { SkillStatusReport } from "../types"; + +export type SkillsState = { + client: GatewayBrowserClient | null; + connected: boolean; + skillsLoading: boolean; + skillsReport: SkillStatusReport | null; + skillsError: string | null; + skillsBusyKey: string | null; + skillEdits: Record; + skillMessages: SkillMessageMap; +}; + +export type SkillMessage = { + kind: "success" | "error"; + message: string; +}; + +export type SkillMessageMap = Record; + +type LoadSkillsOptions = { + clearMessages?: boolean; +}; + +function setSkillMessage(state: SkillsState, key: string, message?: SkillMessage) { + if (!key.trim()) return; + const next = { ...state.skillMessages }; + if (message) next[key] = message; + else delete next[key]; + state.skillMessages = next; +} + +function getErrorMessage(err: unknown) { + if (err instanceof Error) return err.message; + return String(err); +} + +export async function loadSkills(state: SkillsState, options?: LoadSkillsOptions) { + if (options?.clearMessages && Object.keys(state.skillMessages).length > 0) { + state.skillMessages = {}; + } + if (!state.client || !state.connected) return; + if (state.skillsLoading) return; + state.skillsLoading = true; + state.skillsError = null; + try { + const res = (await state.client.request("skills.status", {})) as SkillStatusReport | undefined; + if (res) state.skillsReport = res; + } catch (err) { + state.skillsError = getErrorMessage(err); + } finally { + state.skillsLoading = false; + } +} + +export function updateSkillEdit(state: SkillsState, skillKey: string, value: string) { + state.skillEdits = { ...state.skillEdits, [skillKey]: value }; +} + +export async function updateSkillEnabled(state: SkillsState, skillKey: string, enabled: boolean) { + if (!state.client || !state.connected) return; + state.skillsBusyKey = skillKey; + state.skillsError = null; + try { + await state.client.request("skills.update", { skillKey, enabled }); + await loadSkills(state); + setSkillMessage(state, skillKey, { + kind: "success", + message: enabled ? "Skill enabled" : "Skill disabled", + }); + } catch (err) { + const message = getErrorMessage(err); + state.skillsError = message; + setSkillMessage(state, skillKey, { + kind: "error", + message, + }); + } finally { + state.skillsBusyKey = null; + } +} + +export async function saveSkillApiKey(state: SkillsState, skillKey: string) { + if (!state.client || !state.connected) return; + state.skillsBusyKey = skillKey; + state.skillsError = null; + try { + const apiKey = state.skillEdits[skillKey] ?? ""; + await state.client.request("skills.update", { skillKey, apiKey }); + await loadSkills(state); + setSkillMessage(state, skillKey, { + kind: "success", + message: "API key saved", + }); + } catch (err) { + const message = getErrorMessage(err); + state.skillsError = message; + setSkillMessage(state, skillKey, { + kind: "error", + message, + }); + } finally { + state.skillsBusyKey = null; + } +} + +export async function installSkill( + state: SkillsState, + skillKey: string, + name: string, + installId: string, +) { + if (!state.client || !state.connected) return; + state.skillsBusyKey = skillKey; + state.skillsError = null; + try { + const result = (await state.client.request("skills.install", { + name, + installId, + timeoutMs: 120000, + })) as { ok?: boolean; message?: string }; + await loadSkills(state); + setSkillMessage(state, skillKey, { + kind: "success", + message: result?.message ?? "Installed", + }); + } catch (err) { + const message = getErrorMessage(err); + state.skillsError = message; + setSkillMessage(state, skillKey, { + kind: "error", + message, + }); + } finally { + state.skillsBusyKey = null; + } +} diff --git a/ui/src/ui/data/moonshot-kimi-k2.ts b/ui/src/ui/data/moonshot-kimi-k2.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5357b5d8366506ea77a242a553c2c35b578bae1 --- /dev/null +++ b/ui/src/ui/data/moonshot-kimi-k2.ts @@ -0,0 +1,39 @@ +export const MOONSHOT_KIMI_K2_DEFAULT_ID = "kimi-k2-0905-preview"; +export const MOONSHOT_KIMI_K2_CONTEXT_WINDOW = 256000; +export const MOONSHOT_KIMI_K2_MAX_TOKENS = 8192; +export const MOONSHOT_KIMI_K2_INPUT = ["text"] as const; +export const MOONSHOT_KIMI_K2_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +} as const; + +export const MOONSHOT_KIMI_K2_MODELS = [ + { + id: "kimi-k2-0905-preview", + name: "Kimi K2 0905 Preview", + alias: "Kimi K2", + reasoning: false, + }, + { + id: "kimi-k2-turbo-preview", + name: "Kimi K2 Turbo", + alias: "Kimi K2 Turbo", + reasoning: false, + }, + { + id: "kimi-k2-thinking", + name: "Kimi K2 Thinking", + alias: "Kimi K2 Thinking", + reasoning: true, + }, + { + id: "kimi-k2-thinking-turbo", + name: "Kimi K2 Thinking Turbo", + alias: "Kimi K2 Thinking Turbo", + reasoning: true, + }, +] as const; + +export type MoonshotKimiK2Model = (typeof MOONSHOT_KIMI_K2_MODELS)[number]; diff --git a/ui/src/ui/device-auth.ts b/ui/src/ui/device-auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..e06d50611511ed1ff7496a45280f81f47787217c --- /dev/null +++ b/ui/src/ui/device-auth.ts @@ -0,0 +1,99 @@ +export type DeviceAuthEntry = { + token: string; + role: string; + scopes: string[]; + updatedAtMs: number; +}; + +type DeviceAuthStore = { + version: 1; + deviceId: string; + tokens: Record; +}; + +const STORAGE_KEY = "openclaw.device.auth.v1"; + +function normalizeRole(role: string): string { + return role.trim(); +} + +function normalizeScopes(scopes: string[] | undefined): string[] { + if (!Array.isArray(scopes)) return []; + const out = new Set(); + for (const scope of scopes) { + const trimmed = scope.trim(); + if (trimmed) out.add(trimmed); + } + return [...out].sort(); +} + +function readStore(): DeviceAuthStore | null { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as DeviceAuthStore; + if (!parsed || parsed.version !== 1) return null; + if (!parsed.deviceId || typeof parsed.deviceId !== "string") return null; + if (!parsed.tokens || typeof parsed.tokens !== "object") return null; + return parsed; + } catch { + return null; + } +} + +function writeStore(store: DeviceAuthStore) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } catch { + // best-effort + } +} + +export function loadDeviceAuthToken(params: { + deviceId: string; + role: string; +}): DeviceAuthEntry | null { + const store = readStore(); + if (!store || store.deviceId !== params.deviceId) return null; + const role = normalizeRole(params.role); + const entry = store.tokens[role]; + if (!entry || typeof entry.token !== "string") return null; + return entry; +} + +export function storeDeviceAuthToken(params: { + deviceId: string; + role: string; + token: string; + scopes?: string[]; +}): DeviceAuthEntry { + const role = normalizeRole(params.role); + const next: DeviceAuthStore = { + version: 1, + deviceId: params.deviceId, + tokens: {}, + }; + const existing = readStore(); + if (existing && existing.deviceId === params.deviceId) { + next.tokens = { ...existing.tokens }; + } + const entry: DeviceAuthEntry = { + token: params.token, + role, + scopes: normalizeScopes(params.scopes), + updatedAtMs: Date.now(), + }; + next.tokens[role] = entry; + writeStore(next); + return entry; +} + +export function clearDeviceAuthToken(params: { deviceId: string; role: string }) { + const store = readStore(); + if (!store || store.deviceId !== params.deviceId) return; + const role = normalizeRole(params.role); + if (!store.tokens[role]) return; + const next = { ...store, tokens: { ...store.tokens } }; + delete next.tokens[role]; + writeStore(next); +} diff --git a/ui/src/ui/device-identity.ts b/ui/src/ui/device-identity.ts new file mode 100644 index 0000000000000000000000000000000000000000..2070fbdc1b1b47e4982a7bde384e3807a9a55197 --- /dev/null +++ b/ui/src/ui/device-identity.ts @@ -0,0 +1,108 @@ +import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519"; + +type StoredIdentity = { + version: 1; + deviceId: string; + publicKey: string; + privateKey: string; + createdAtMs: number; +}; + +export type DeviceIdentity = { + deviceId: string; + publicKey: string; + privateKey: string; +}; + +const STORAGE_KEY = "openclaw-device-identity-v1"; + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +} + +function base64UrlDecode(input: string): Uint8Array { + const normalized = input.replaceAll("-", "+").replaceAll("_", "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); + return out; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function fingerprintPublicKey(publicKey: Uint8Array): Promise { + const hash = await crypto.subtle.digest("SHA-256", publicKey); + return bytesToHex(new Uint8Array(hash)); +} + +async function generateIdentity(): Promise { + const privateKey = utils.randomSecretKey(); + const publicKey = await getPublicKeyAsync(privateKey); + const deviceId = await fingerprintPublicKey(publicKey); + return { + deviceId, + publicKey: base64UrlEncode(publicKey), + privateKey: base64UrlEncode(privateKey), + }; +} + +export async function loadOrCreateDeviceIdentity(): Promise { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as StoredIdentity; + if ( + parsed?.version === 1 && + typeof parsed.deviceId === "string" && + typeof parsed.publicKey === "string" && + typeof parsed.privateKey === "string" + ) { + const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey)); + if (derivedId !== parsed.deviceId) { + const updated: StoredIdentity = { + ...parsed, + deviceId: derivedId, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return { + deviceId: derivedId, + publicKey: parsed.publicKey, + privateKey: parsed.privateKey, + }; + } + return { + deviceId: parsed.deviceId, + publicKey: parsed.publicKey, + privateKey: parsed.privateKey, + }; + } + } + } catch { + // fall through to regenerate + } + + const identity = await generateIdentity(); + const stored: StoredIdentity = { + version: 1, + deviceId: identity.deviceId, + publicKey: identity.publicKey, + privateKey: identity.privateKey, + createdAtMs: Date.now(), + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); + return identity; +} + +export async function signDevicePayload(privateKeyBase64Url: string, payload: string) { + const key = base64UrlDecode(privateKeyBase64Url); + const data = new TextEncoder().encode(payload); + const sig = await signAsync(data, key); + return base64UrlEncode(sig); +} diff --git a/ui/src/ui/focus-mode.browser.test.ts b/ui/src/ui/focus-mode.browser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e8164d85bfc6e04e8ee1fcb3567a4e5fff8fc0d --- /dev/null +++ b/ui/src/ui/focus-mode.browser.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { OpenClawApp } from "./app"; + +const originalConnect = OpenClawApp.prototype.connect; + +function mountApp(pathname: string) { + window.history.replaceState({}, "", pathname); + const app = document.createElement("openclaw-app") as OpenClawApp; + document.body.append(app); + return app; +} + +beforeEach(() => { + OpenClawApp.prototype.connect = () => { + // no-op: avoid real gateway WS connections in browser tests + }; + window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); + document.body.innerHTML = ""; +}); + +afterEach(() => { + OpenClawApp.prototype.connect = originalConnect; + window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); + document.body.innerHTML = ""; +}); + +describe("chat focus mode", () => { + it("collapses header + sidebar on chat tab only", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + const shell = app.querySelector(".shell"); + expect(shell).not.toBeNull(); + expect(shell?.classList.contains("shell--chat-focus")).toBe(false); + + const toggle = app.querySelector('button[title^="Toggle focus mode"]'); + expect(toggle).not.toBeNull(); + toggle?.click(); + + await app.updateComplete; + expect(shell?.classList.contains("shell--chat-focus")).toBe(true); + + const link = app.querySelector('a.nav-item[href="/channels"]'); + expect(link).not.toBeNull(); + link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); + + await app.updateComplete; + expect(app.tab).toBe("channels"); + expect(shell?.classList.contains("shell--chat-focus")).toBe(false); + + const chatLink = app.querySelector('a.nav-item[href="/chat"]'); + chatLink?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), + ); + + await app.updateComplete; + expect(app.tab).toBe("chat"); + expect(shell?.classList.contains("shell--chat-focus")).toBe(true); + }); +}); diff --git a/ui/src/ui/format.test.ts b/ui/src/ui/format.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b52dc0e4fb8b09c12bbf3169f3e03f246ff5a815 --- /dev/null +++ b/ui/src/ui/format.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { stripThinkingTags } from "./format"; + +describe("stripThinkingTags", () => { + it("strips segments", () => { + const input = ["", "secret", "", "", "Hello"].join("\n"); + expect(stripThinkingTags(input)).toBe("Hello"); + }); + + it("strips segments", () => { + const input = ["", "secret", "", "", "Hello"].join("\n"); + expect(stripThinkingTags(input)).toBe("Hello"); + }); + + it("keeps text when tags are unpaired", () => { + expect(stripThinkingTags("\nsecret\nHello")).toBe("secret\nHello"); + expect(stripThinkingTags("Hello\n")).toBe("Hello\n"); + }); + + it("returns original text when no tags exist", () => { + expect(stripThinkingTags("Hello")).toBe("Hello"); + }); + + it("strips segments", () => { + const input = "\n\nHello there\n\n"; + expect(stripThinkingTags(input)).toBe("Hello there\n\n"); + }); + + it("strips mixed and tags", () => { + const input = "reasoning\n\nHello"; + expect(stripThinkingTags(input)).toBe("Hello"); + }); + + it("handles incomplete { + // When streaming splits mid-tag, we may see "" + // This should not crash and should handle gracefully + expect(stripThinkingTags("")).toBe("Hello"); + }); +}); diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts new file mode 100644 index 0000000000000000000000000000000000000000..cdefd2f564baeec2b3f197246616f99a990554b0 --- /dev/null +++ b/ui/src/ui/format.ts @@ -0,0 +1,77 @@ +import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js"; + +export function formatMs(ms?: number | null): string { + if (!ms && ms !== 0) return "n/a"; + return new Date(ms).toLocaleString(); +} + +export function formatAgo(ms?: number | null): string { + if (!ms && ms !== 0) return "n/a"; + const diff = Date.now() - ms; + if (diff < 0) return "just now"; + const sec = Math.round(diff / 1000); + if (sec < 60) return `${sec}s ago`; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.round(min / 60); + if (hr < 48) return `${hr}h ago`; + const day = Math.round(hr / 24); + return `${day}d ago`; +} + +export function formatDurationMs(ms?: number | null): string { + if (!ms && ms !== 0) return "n/a"; + if (ms < 1000) return `${ms}ms`; + const sec = Math.round(ms / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m`; + const hr = Math.round(min / 60); + if (hr < 48) return `${hr}h`; + const day = Math.round(hr / 24); + return `${day}d`; +} + +export function formatList(values?: Array): string { + if (!values || values.length === 0) return "none"; + return values.filter((v): v is string => Boolean(v && v.trim())).join(", "); +} + +export function clampText(value: string, max = 120): string { + if (value.length <= max) return value; + return `${value.slice(0, Math.max(0, max - 1))}…`; +} + +export function truncateText( + value: string, + max: number, +): { + text: string; + truncated: boolean; + total: number; +} { + if (value.length <= max) { + return { text: value, truncated: false, total: value.length }; + } + return { + text: value.slice(0, Math.max(0, max)), + truncated: true, + total: value.length, + }; +} + +export function toNumber(value: string, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +export function parseList(input: string): string[] { + return input + .split(/[,\n]/) + .map((v) => v.trim()) + .filter((v) => v.length > 0); +} + +export function stripThinkingTags(value: string): string { + return stripReasoningTagsFromText(value, { mode: "preserve", trim: "start" }); +} diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts new file mode 100644 index 0000000000000000000000000000000000000000..3336e09b50872fcad5ffdf4be0211fb4e29e9ead --- /dev/null +++ b/ui/src/ui/gateway.ts @@ -0,0 +1,297 @@ +import { buildDeviceAuthPayload } from "../../../src/gateway/device-auth.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + type GatewayClientMode, + type GatewayClientName, +} from "../../../src/gateway/protocol/client-info.js"; +import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth"; +import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity"; +import { generateUUID } from "./uuid"; + +export type GatewayEventFrame = { + type: "event"; + event: string; + payload?: unknown; + seq?: number; + stateVersion?: { presence: number; health: number }; +}; + +export type GatewayResponseFrame = { + type: "res"; + id: string; + ok: boolean; + payload?: unknown; + error?: { code: string; message: string; details?: unknown }; +}; + +export type GatewayHelloOk = { + type: "hello-ok"; + protocol: number; + features?: { methods?: string[]; events?: string[] }; + snapshot?: unknown; + auth?: { + deviceToken?: string; + role?: string; + scopes?: string[]; + issuedAtMs?: number; + }; + policy?: { tickIntervalMs?: number }; +}; + +type Pending = { + resolve: (value: unknown) => void; + reject: (err: unknown) => void; +}; + +export type GatewayBrowserClientOptions = { + url: string; + token?: string; + password?: string; + clientName?: GatewayClientName; + clientVersion?: string; + platform?: string; + mode?: GatewayClientMode; + instanceId?: string; + onHello?: (hello: GatewayHelloOk) => void; + onEvent?: (evt: GatewayEventFrame) => void; + onClose?: (info: { code: number; reason: string }) => void; + onGap?: (info: { expected: number; received: number }) => void; +}; + +// 4008 = application-defined code (browser rejects 1008 "Policy Violation") +const CONNECT_FAILED_CLOSE_CODE = 4008; + +export class GatewayBrowserClient { + private ws: WebSocket | null = null; + private pending = new Map(); + private closed = false; + private lastSeq: number | null = null; + private connectNonce: string | null = null; + private connectSent = false; + private connectTimer: number | null = null; + private backoffMs = 800; + + constructor(private opts: GatewayBrowserClientOptions) {} + + start() { + this.closed = false; + this.connect(); + } + + stop() { + this.closed = true; + this.ws?.close(); + this.ws = null; + this.flushPending(new Error("gateway client stopped")); + } + + get connected() { + return this.ws?.readyState === WebSocket.OPEN; + } + + private connect() { + if (this.closed) return; + this.ws = new WebSocket(this.opts.url); + this.ws.onopen = () => this.queueConnect(); + this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? "")); + this.ws.onclose = (ev) => { + const reason = String(ev.reason ?? ""); + this.ws = null; + this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`)); + this.opts.onClose?.({ code: ev.code, reason }); + this.scheduleReconnect(); + }; + this.ws.onerror = () => { + // ignored; close handler will fire + }; + } + + private scheduleReconnect() { + if (this.closed) return; + const delay = this.backoffMs; + this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000); + window.setTimeout(() => this.connect(), delay); + } + + private flushPending(err: Error) { + for (const [, p] of this.pending) p.reject(err); + this.pending.clear(); + } + + private async sendConnect() { + if (this.connectSent) return; + this.connectSent = true; + if (this.connectTimer !== null) { + window.clearTimeout(this.connectTimer); + this.connectTimer = null; + } + + // crypto.subtle is only available in secure contexts (HTTPS, localhost). + // Over plain HTTP, we skip device identity and fall back to token-only auth. + // Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled. + const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle; + + const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; + const role = "operator"; + let deviceIdentity: Awaited> | null = null; + let canFallbackToShared = false; + let authToken = this.opts.token; + + if (isSecureContext) { + deviceIdentity = await loadOrCreateDeviceIdentity(); + const storedToken = loadDeviceAuthToken({ + deviceId: deviceIdentity.deviceId, + role, + })?.token; + authToken = storedToken ?? this.opts.token; + canFallbackToShared = Boolean(storedToken && this.opts.token); + } + const auth = + authToken || this.opts.password + ? { + token: authToken, + password: this.opts.password, + } + : undefined; + + let device: + | { + id: string; + publicKey: string; + signature: string; + signedAt: number; + nonce: string | undefined; + } + | undefined; + + if (isSecureContext && deviceIdentity) { + const signedAtMs = Date.now(); + const nonce = this.connectNonce ?? undefined; + const payload = buildDeviceAuthPayload({ + deviceId: deviceIdentity.deviceId, + clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, + role, + scopes, + signedAtMs, + token: authToken ?? null, + nonce, + }); + const signature = await signDevicePayload(deviceIdentity.privateKey, payload); + device = { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKey, + signature, + signedAt: signedAtMs, + nonce, + }; + } + const params = { + minProtocol: 3, + maxProtocol: 3, + client: { + id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: this.opts.clientVersion ?? "dev", + platform: this.opts.platform ?? navigator.platform ?? "web", + mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, + instanceId: this.opts.instanceId, + }, + role, + scopes, + device, + caps: [], + auth, + userAgent: navigator.userAgent, + locale: navigator.language, + }; + + void this.request("connect", params) + .then((hello) => { + if (hello?.auth?.deviceToken && deviceIdentity) { + storeDeviceAuthToken({ + deviceId: deviceIdentity.deviceId, + role: hello.auth.role ?? role, + token: hello.auth.deviceToken, + scopes: hello.auth.scopes ?? [], + }); + } + this.backoffMs = 800; + this.opts.onHello?.(hello); + }) + .catch(() => { + if (canFallbackToShared && deviceIdentity) { + clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role }); + } + this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed"); + }); + } + + private handleMessage(raw: string) { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return; + } + + const frame = parsed as { type?: unknown }; + if (frame.type === "event") { + const evt = parsed as GatewayEventFrame; + if (evt.event === "connect.challenge") { + const payload = evt.payload as { nonce?: unknown } | undefined; + const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null; + if (nonce) { + this.connectNonce = nonce; + void this.sendConnect(); + } + return; + } + const seq = typeof evt.seq === "number" ? evt.seq : null; + if (seq !== null) { + if (this.lastSeq !== null && seq > this.lastSeq + 1) { + this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq }); + } + this.lastSeq = seq; + } + try { + this.opts.onEvent?.(evt); + } catch (err) { + console.error("[gateway] event handler error:", err); + } + return; + } + + if (frame.type === "res") { + const res = parsed as GatewayResponseFrame; + const pending = this.pending.get(res.id); + if (!pending) return; + this.pending.delete(res.id); + if (res.ok) pending.resolve(res.payload); + else pending.reject(new Error(res.error?.message ?? "request failed")); + return; + } + } + + request(method: string, params?: unknown): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("gateway not connected")); + } + const id = generateUUID(); + const frame = { type: "req", id, method, params }; + const p = new Promise((resolve, reject) => { + this.pending.set(id, { resolve: (v) => resolve(v as T), reject }); + }); + this.ws.send(JSON.stringify(frame)); + return p; + } + + private queueConnect() { + this.connectNonce = null; + this.connectSent = false; + if (this.connectTimer !== null) window.clearTimeout(this.connectTimer); + this.connectTimer = window.setTimeout(() => { + void this.sendConnect(); + }, 750); + } +} diff --git a/ui/src/ui/icons.ts b/ui/src/ui/icons.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6e9c6b856e0db2a82ee6dd1c23354616f209b03 --- /dev/null +++ b/ui/src/ui/icons.ts @@ -0,0 +1,248 @@ +import { html, type TemplateResult } from "lit"; + +// Lucide-style SVG icons +// All icons use currentColor for stroke + +export const icons = { + // Navigation icons + messageSquare: html` + + + + `, + barChart: html` + + + + + + `, + link: html` + + + + + `, + radio: html` + + + + + `, + fileText: html` + + + + + + + + `, + zap: html` + + `, + monitor: html` + + + + + + `, + settings: html` + + + + + `, + bug: html` + + + + + + + + + + + + + + `, + scrollText: html` + + + + + + + `, + folder: html` + + + + `, + + // UI icons + menu: html` + + + + + + `, + x: html` + + + + + `, + check: html` + + `, + copy: html` + + + + + `, + search: html` + + + + + `, + brain: html` + + + + + + + + + + + + `, + book: html` + + + + `, + loader: html` + + + + + + + + + + + `, + + // Tool icons + wrench: html` + + + + `, + fileCode: html` + + + + + + + `, + edit: html` + + + + + `, + penLine: html` + + + + + `, + paperclip: html` + + + + `, + globe: html` + + + + + + `, + image: html` + + + + + + `, + smartphone: html` + + + + + `, + plug: html` + + + + + + + `, + circle: html` + + `, + puzzle: html` + + + + `, +} as const; + +export type IconName = keyof typeof icons; + +export function icon(name: IconName): TemplateResult { + return icons[name]; +} + +export function renderIcon(name: IconName, className = "nav-item__icon"): TemplateResult { + return html``; +} + +// Legacy function for compatibility +export function renderEmojiIcon( + iconContent: string | TemplateResult, + className: string, +): TemplateResult { + return html``; +} + +export function setEmojiIcon(target: HTMLElement | null, icon: string): void { + if (!target) return; + target.textContent = icon; +} diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..278485fe7a07e5b6ab71f8d472c7bb393382dad1 --- /dev/null +++ b/ui/src/ui/markdown.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { toSanitizedMarkdownHtml } from "./markdown"; + +describe("toSanitizedMarkdownHtml", () => { + it("renders basic markdown", () => { + const html = toSanitizedMarkdownHtml("Hello **world**"); + expect(html).toContain("world"); + }); + + it("strips scripts and unsafe links", () => { + const html = toSanitizedMarkdownHtml( + [ + "", + "", + "[x](javascript:alert(1))", + "", + "[ok](https://example.com)", + ].join("\n"), + ); + expect(html).not.toContain(" { + const html = toSanitizedMarkdownHtml(["```ts", "console.log(1)", "```"].join("\n")); + expect(html).toContain("
      ");
      +    expect(html).toContain("();
      +
      +function getCachedMarkdown(key: string): string | null {
      +  const cached = markdownCache.get(key);
      +  if (cached === undefined) return null;
      +  markdownCache.delete(key);
      +  markdownCache.set(key, cached);
      +  return cached;
      +}
      +
      +function setCachedMarkdown(key: string, value: string) {
      +  markdownCache.set(key, value);
      +  if (markdownCache.size <= MARKDOWN_CACHE_LIMIT) return;
      +  const oldest = markdownCache.keys().next().value;
      +  if (oldest) markdownCache.delete(oldest);
      +}
      +
      +function installHooks() {
      +  if (hooksInstalled) return;
      +  hooksInstalled = true;
      +
      +  DOMPurify.addHook("afterSanitizeAttributes", (node) => {
      +    if (!(node instanceof HTMLAnchorElement)) return;
      +    const href = node.getAttribute("href");
      +    if (!href) return;
      +    node.setAttribute("rel", "noreferrer noopener");
      +    node.setAttribute("target", "_blank");
      +  });
      +}
      +
      +export function toSanitizedMarkdownHtml(markdown: string): string {
      +  const input = markdown.trim();
      +  if (!input) return "";
      +  installHooks();
      +  if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
      +    const cached = getCachedMarkdown(input);
      +    if (cached !== null) return cached;
      +  }
      +  const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
      +  const suffix = truncated.truncated
      +    ? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
      +    : "";
      +  if (truncated.text.length > MARKDOWN_PARSE_LIMIT) {
      +    const escaped = escapeHtml(`${truncated.text}${suffix}`);
      +    const html = `
      ${escaped}
      `; + const sanitized = DOMPurify.sanitize(html, { + ALLOWED_TAGS: allowedTags, + ALLOWED_ATTR: allowedAttrs, + }); + if (input.length <= MARKDOWN_CACHE_MAX_CHARS) { + setCachedMarkdown(input, sanitized); + } + return sanitized; + } + const rendered = marked.parse(`${truncated.text}${suffix}`) as string; + const sanitized = DOMPurify.sanitize(rendered, { + ALLOWED_TAGS: allowedTags, + ALLOWED_ATTR: allowedAttrs, + }); + if (input.length <= MARKDOWN_CACHE_MAX_CHARS) { + setCachedMarkdown(input, sanitized); + } + return sanitized; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e4246840a52c38a2a9475b3d9cce0e00df1251b --- /dev/null +++ b/ui/src/ui/navigation.browser.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { OpenClawApp } from "./app"; +import "../styles.css"; + +const originalConnect = OpenClawApp.prototype.connect; + +function mountApp(pathname: string) { + window.history.replaceState({}, "", pathname); + const app = document.createElement("openclaw-app") as OpenClawApp; + document.body.append(app); + return app; +} + +function nextFrame() { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +beforeEach(() => { + OpenClawApp.prototype.connect = () => { + // no-op: avoid real gateway WS connections in browser tests + }; + window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); + document.body.innerHTML = ""; +}); + +afterEach(() => { + OpenClawApp.prototype.connect = originalConnect; + window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); + document.body.innerHTML = ""; +}); + +describe("control UI routing", () => { + it("hydrates the tab from the location", async () => { + const app = mountApp("/sessions"); + await app.updateComplete; + + expect(app.tab).toBe("sessions"); + expect(window.location.pathname).toBe("/sessions"); + }); + + it("respects /ui base paths", async () => { + const app = mountApp("/ui/cron"); + await app.updateComplete; + + expect(app.basePath).toBe("/ui"); + expect(app.tab).toBe("cron"); + expect(window.location.pathname).toBe("/ui/cron"); + }); + + it("infers nested base paths", async () => { + const app = mountApp("/apps/openclaw/cron"); + await app.updateComplete; + + expect(app.basePath).toBe("/apps/openclaw"); + expect(app.tab).toBe("cron"); + expect(window.location.pathname).toBe("/apps/openclaw/cron"); + }); + + it("honors explicit base path overrides", async () => { + window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = "/openclaw"; + const app = mountApp("/openclaw/sessions"); + await app.updateComplete; + + expect(app.basePath).toBe("/openclaw"); + expect(app.tab).toBe("sessions"); + expect(window.location.pathname).toBe("/openclaw/sessions"); + }); + + it("updates the URL when clicking nav items", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + const link = app.querySelector('a.nav-item[href="/channels"]'); + expect(link).not.toBeNull(); + link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); + + await app.updateComplete; + expect(app.tab).toBe("channels"); + expect(window.location.pathname).toBe("/channels"); + }); + + it("keeps chat and nav usable on narrow viewports", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + expect(window.matchMedia("(max-width: 768px)").matches).toBe(true); + + const split = app.querySelector(".chat-split-container") as HTMLElement | null; + expect(split).not.toBeNull(); + if (split) { + expect(getComputedStyle(split).position).not.toBe("fixed"); + } + + const chatMain = app.querySelector(".chat-main") as HTMLElement | null; + expect(chatMain).not.toBeNull(); + if (chatMain) { + expect(getComputedStyle(chatMain).display).not.toBe("none"); + } + + if (split) { + split.classList.add("chat-split-container--open"); + await app.updateComplete; + expect(getComputedStyle(split).position).toBe("fixed"); + } + if (chatMain) { + expect(getComputedStyle(chatMain).display).toBe("none"); + } + }); + + it("auto-scrolls chat history to the latest message", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + const initialContainer = app.querySelector(".chat-thread") as HTMLElement | null; + expect(initialContainer).not.toBeNull(); + if (!initialContainer) return; + initialContainer.style.maxHeight = "180px"; + initialContainer.style.overflow = "auto"; + + app.chatMessages = Array.from({ length: 60 }, (_, index) => ({ + role: "assistant", + content: `Line ${index} - ${"x".repeat(200)}`, + timestamp: Date.now() + index, + })); + + await app.updateComplete; + for (let i = 0; i < 6; i++) { + await nextFrame(); + } + + const container = app.querySelector(".chat-thread") as HTMLElement | null; + expect(container).not.toBeNull(); + if (!container) return; + const maxScroll = container.scrollHeight - container.clientHeight; + expect(maxScroll).toBeGreaterThan(0); + for (let i = 0; i < 10; i++) { + if (container.scrollTop === maxScroll) break; + await nextFrame(); + } + expect(container.scrollTop).toBe(maxScroll); + }); + + it("hydrates token from URL params and strips it", async () => { + const app = mountApp("/ui/overview?token=abc123"); + await app.updateComplete; + + expect(app.settings.token).toBe("abc123"); + expect(window.location.pathname).toBe("/ui/overview"); + expect(window.location.search).toBe(""); + }); + + it("hydrates password from URL params and strips it", async () => { + const app = mountApp("/ui/overview?password=sekret"); + await app.updateComplete; + + expect(app.password).toBe("sekret"); + expect(window.location.pathname).toBe("/ui/overview"); + expect(window.location.search).toBe(""); + }); + + it("hydrates token from URL params even when settings already set", async () => { + localStorage.setItem( + "openclaw.control.settings.v1", + JSON.stringify({ token: "existing-token" }), + ); + const app = mountApp("/ui/overview?token=abc123"); + await app.updateComplete; + + expect(app.settings.token).toBe("abc123"); + expect(window.location.pathname).toBe("/ui/overview"); + expect(window.location.search).toBe(""); + }); +}); diff --git a/ui/src/ui/navigation.test.ts b/ui/src/ui/navigation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3348ad4623eb8c00b7bc0df7dd5e70c5ee997637 --- /dev/null +++ b/ui/src/ui/navigation.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { + TAB_GROUPS, + iconForTab, + inferBasePathFromPathname, + normalizeBasePath, + normalizePath, + pathForTab, + subtitleForTab, + tabFromPath, + titleForTab, + type Tab, +} from "./navigation"; + +/** All valid tab identifiers derived from TAB_GROUPS */ +const ALL_TABS: Tab[] = TAB_GROUPS.flatMap((group) => group.tabs) as Tab[]; + +describe("iconForTab", () => { + it("returns a non-empty string for every tab", () => { + for (const tab of ALL_TABS) { + const icon = iconForTab(tab); + expect(icon).toBeTruthy(); + expect(typeof icon).toBe("string"); + expect(icon.length).toBeGreaterThan(0); + } + }); + + it("returns stable icons for known tabs", () => { + expect(iconForTab("chat")).toBe("💬"); + expect(iconForTab("overview")).toBe("📊"); + expect(iconForTab("channels")).toBe("🔗"); + expect(iconForTab("instances")).toBe("📡"); + expect(iconForTab("sessions")).toBe("📄"); + expect(iconForTab("cron")).toBe("⏰"); + expect(iconForTab("skills")).toBe("⚡️"); + expect(iconForTab("nodes")).toBe("🖥️"); + expect(iconForTab("config")).toBe("⚙️"); + expect(iconForTab("debug")).toBe("🐞"); + expect(iconForTab("logs")).toBe("🧾"); + }); + + it("returns a fallback icon for unknown tab", () => { + // TypeScript won't allow this normally, but runtime could receive unexpected values + const unknownTab = "unknown" as Tab; + expect(iconForTab(unknownTab)).toBe("📁"); + }); +}); + +describe("titleForTab", () => { + it("returns a non-empty string for every tab", () => { + for (const tab of ALL_TABS) { + const title = titleForTab(tab); + expect(title).toBeTruthy(); + expect(typeof title).toBe("string"); + } + }); + + it("returns expected titles", () => { + expect(titleForTab("chat")).toBe("Chat"); + expect(titleForTab("overview")).toBe("Overview"); + expect(titleForTab("cron")).toBe("Cron Jobs"); + }); +}); + +describe("subtitleForTab", () => { + it("returns a string for every tab", () => { + for (const tab of ALL_TABS) { + const subtitle = subtitleForTab(tab); + expect(typeof subtitle).toBe("string"); + } + }); + + it("returns descriptive subtitles", () => { + expect(subtitleForTab("chat")).toContain("chat session"); + expect(subtitleForTab("config")).toContain("openclaw.json"); + }); +}); + +describe("normalizeBasePath", () => { + it("returns empty string for falsy input", () => { + expect(normalizeBasePath("")).toBe(""); + }); + + it("adds leading slash if missing", () => { + expect(normalizeBasePath("ui")).toBe("/ui"); + }); + + it("removes trailing slash", () => { + expect(normalizeBasePath("/ui/")).toBe("/ui"); + }); + + it("returns empty string for root path", () => { + expect(normalizeBasePath("/")).toBe(""); + }); + + it("handles nested paths", () => { + expect(normalizeBasePath("/apps/openclaw")).toBe("/apps/openclaw"); + }); +}); + +describe("normalizePath", () => { + it("returns / for falsy input", () => { + expect(normalizePath("")).toBe("/"); + }); + + it("adds leading slash if missing", () => { + expect(normalizePath("chat")).toBe("/chat"); + }); + + it("removes trailing slash except for root", () => { + expect(normalizePath("/chat/")).toBe("/chat"); + expect(normalizePath("/")).toBe("/"); + }); +}); + +describe("pathForTab", () => { + it("returns correct path without base", () => { + expect(pathForTab("chat")).toBe("/chat"); + expect(pathForTab("overview")).toBe("/overview"); + }); + + it("prepends base path", () => { + expect(pathForTab("chat", "/ui")).toBe("/ui/chat"); + expect(pathForTab("sessions", "/apps/openclaw")).toBe("/apps/openclaw/sessions"); + }); +}); + +describe("tabFromPath", () => { + it("returns tab for valid path", () => { + expect(tabFromPath("/chat")).toBe("chat"); + expect(tabFromPath("/overview")).toBe("overview"); + expect(tabFromPath("/sessions")).toBe("sessions"); + }); + + it("returns chat for root path", () => { + expect(tabFromPath("/")).toBe("chat"); + }); + + it("handles base paths", () => { + expect(tabFromPath("/ui/chat", "/ui")).toBe("chat"); + expect(tabFromPath("/apps/openclaw/sessions", "/apps/openclaw")).toBe("sessions"); + }); + + it("returns null for unknown path", () => { + expect(tabFromPath("/unknown")).toBeNull(); + }); + + it("is case-insensitive", () => { + expect(tabFromPath("/CHAT")).toBe("chat"); + expect(tabFromPath("/Overview")).toBe("overview"); + }); +}); + +describe("inferBasePathFromPathname", () => { + it("returns empty string for root", () => { + expect(inferBasePathFromPathname("/")).toBe(""); + }); + + it("returns empty string for direct tab path", () => { + expect(inferBasePathFromPathname("/chat")).toBe(""); + expect(inferBasePathFromPathname("/overview")).toBe(""); + }); + + it("infers base path from nested paths", () => { + expect(inferBasePathFromPathname("/ui/chat")).toBe("/ui"); + expect(inferBasePathFromPathname("/apps/openclaw/sessions")).toBe("/apps/openclaw"); + }); + + it("handles index.html suffix", () => { + expect(inferBasePathFromPathname("/index.html")).toBe(""); + expect(inferBasePathFromPathname("/ui/index.html")).toBe("/ui"); + }); +}); + +describe("TAB_GROUPS", () => { + it("contains all expected groups", () => { + const labels = TAB_GROUPS.map((g) => g.label); + expect(labels).toContain("Chat"); + expect(labels).toContain("Control"); + expect(labels).toContain("Agent"); + expect(labels).toContain("Settings"); + }); + + it("all tabs are unique", () => { + const allTabs = TAB_GROUPS.flatMap((g) => g.tabs); + const uniqueTabs = new Set(allTabs); + expect(uniqueTabs.size).toBe(allTabs.length); + }); +}); diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts new file mode 100644 index 0000000000000000000000000000000000000000..8557a21f440df77c5f18a39a8fe5953da3aece90 --- /dev/null +++ b/ui/src/ui/navigation.ts @@ -0,0 +1,186 @@ +import type { IconName } from "./icons.js"; + +export const TAB_GROUPS = [ + { label: "Chat", tabs: ["chat"] }, + { + label: "Control", + tabs: ["overview", "channels", "instances", "sessions", "cron"], + }, + { label: "Agent", tabs: ["skills", "nodes"] }, + { label: "Settings", tabs: ["config", "debug", "logs"] }, +] as const; + +export type Tab = + | "overview" + | "channels" + | "instances" + | "sessions" + | "cron" + | "skills" + | "nodes" + | "chat" + | "config" + | "debug" + | "logs"; + +const TAB_PATHS: Record = { + overview: "/overview", + channels: "/channels", + instances: "/instances", + sessions: "/sessions", + cron: "/cron", + skills: "/skills", + nodes: "/nodes", + chat: "/chat", + config: "/config", + debug: "/debug", + logs: "/logs", +}; + +const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab])); + +export function normalizeBasePath(basePath: string): string { + if (!basePath) return ""; + let base = basePath.trim(); + if (!base.startsWith("/")) base = `/${base}`; + if (base === "/") return ""; + if (base.endsWith("/")) base = base.slice(0, -1); + return base; +} + +export function normalizePath(path: string): string { + if (!path) return "/"; + let normalized = path.trim(); + if (!normalized.startsWith("/")) normalized = `/${normalized}`; + if (normalized.length > 1 && normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +export function pathForTab(tab: Tab, basePath = ""): string { + const base = normalizeBasePath(basePath); + const path = TAB_PATHS[tab]; + return base ? `${base}${path}` : path; +} + +export function tabFromPath(pathname: string, basePath = ""): Tab | null { + const base = normalizeBasePath(basePath); + let path = pathname || "/"; + if (base) { + if (path === base) { + path = "/"; + } else if (path.startsWith(`${base}/`)) { + path = path.slice(base.length); + } + } + let normalized = normalizePath(path).toLowerCase(); + if (normalized.endsWith("/index.html")) normalized = "/"; + if (normalized === "/") return "chat"; + return PATH_TO_TAB.get(normalized) ?? null; +} + +export function inferBasePathFromPathname(pathname: string): string { + let normalized = normalizePath(pathname); + if (normalized.endsWith("/index.html")) { + normalized = normalizePath(normalized.slice(0, -"/index.html".length)); + } + if (normalized === "/") return ""; + const segments = normalized.split("/").filter(Boolean); + if (segments.length === 0) return ""; + for (let i = 0; i < segments.length; i++) { + const candidate = `/${segments.slice(i).join("/")}`.toLowerCase(); + if (PATH_TO_TAB.has(candidate)) { + const prefix = segments.slice(0, i); + return prefix.length ? `/${prefix.join("/")}` : ""; + } + } + return `/${segments.join("/")}`; +} + +export function iconForTab(tab: Tab): IconName { + switch (tab) { + case "chat": + return "messageSquare"; + case "overview": + return "barChart"; + case "channels": + return "link"; + case "instances": + return "radio"; + case "sessions": + return "fileText"; + case "cron": + return "loader"; + case "skills": + return "zap"; + case "nodes": + return "monitor"; + case "config": + return "settings"; + case "debug": + return "bug"; + case "logs": + return "scrollText"; + default: + return "folder"; + } +} + +export function titleForTab(tab: Tab) { + switch (tab) { + case "overview": + return "Overview"; + case "channels": + return "Channels"; + case "instances": + return "Instances"; + case "sessions": + return "Sessions"; + case "cron": + return "Cron Jobs"; + case "skills": + return "Skills"; + case "nodes": + return "Nodes"; + case "chat": + return "Chat"; + case "config": + return "Config"; + case "debug": + return "Debug"; + case "logs": + return "Logs"; + default: + return "Control"; + } +} + +export function subtitleForTab(tab: Tab) { + switch (tab) { + case "overview": + return "Gateway status, entry points, and a fast health read."; + case "channels": + return "Manage channels and settings."; + case "instances": + return "Presence beacons from connected clients and nodes."; + case "sessions": + return "Inspect active sessions and adjust per-session defaults."; + case "cron": + return "Schedule wakeups and recurring agent runs."; + case "skills": + return "Manage skill availability and API key injection."; + case "nodes": + return "Paired devices, capabilities, and command exposure."; + case "chat": + return "Direct gateway chat session for quick interventions."; + case "config": + return "Edit ~/.openclaw/openclaw.json safely."; + case "debug": + return "Gateway snapshots, events, and manual RPC calls."; + case "logs": + return "Live tail of the gateway file logs."; + default: + return ""; + } +} diff --git a/ui/src/ui/presenter.ts b/ui/src/ui/presenter.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fd1533217d5a5a8c75911b00079373353bb845a --- /dev/null +++ b/ui/src/ui/presenter.ts @@ -0,0 +1,57 @@ +import type { CronJob, GatewaySessionRow, PresenceEntry } from "./types"; +import { formatAgo, formatDurationMs, formatMs } from "./format"; + +export function formatPresenceSummary(entry: PresenceEntry): string { + const host = entry.host ?? "unknown"; + const ip = entry.ip ? `(${entry.ip})` : ""; + const mode = entry.mode ?? ""; + const version = entry.version ?? ""; + return `${host} ${ip} ${mode} ${version}`.trim(); +} + +export function formatPresenceAge(entry: PresenceEntry): string { + const ts = entry.ts ?? null; + return ts ? formatAgo(ts) : "n/a"; +} + +export function formatNextRun(ms?: number | null) { + if (!ms) return "n/a"; + return `${formatMs(ms)} (${formatAgo(ms)})`; +} + +export function formatSessionTokens(row: GatewaySessionRow) { + if (row.totalTokens == null) return "n/a"; + const total = row.totalTokens ?? 0; + const ctx = row.contextTokens ?? 0; + return ctx ? `${total} / ${ctx}` : String(total); +} + +export function formatEventPayload(payload: unknown): string { + if (payload == null) return ""; + try { + return JSON.stringify(payload, null, 2); + } catch { + return String(payload); + } +} + +export function formatCronState(job: CronJob) { + const state = job.state ?? {}; + const next = state.nextRunAtMs ? formatMs(state.nextRunAtMs) : "n/a"; + const last = state.lastRunAtMs ? formatMs(state.lastRunAtMs) : "n/a"; + const status = state.lastStatus ?? "n/a"; + return `${status} · next ${next} · last ${last}`; +} + +export function formatCronSchedule(job: CronJob) { + const s = job.schedule; + if (s.kind === "at") return `At ${formatMs(s.atMs)}`; + if (s.kind === "every") return `Every ${formatDurationMs(s.everyMs)}`; + return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`; +} + +export function formatCronPayload(job: CronJob) { + const p = job.payload; + if (p.kind === "systemEvent") return `System: ${p.text}`; + return `Agent: ${p.message}`; +} diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e1214f112209cf07f8d6fe20961513f72cefe3b --- /dev/null +++ b/ui/src/ui/storage.ts @@ -0,0 +1,86 @@ +const KEY = "openclaw.control.settings.v1"; + +import type { ThemeMode } from "./theme"; + +export type UiSettings = { + gatewayUrl: string; + token: string; + sessionKey: string; + lastActiveSessionKey: string; + theme: ThemeMode; + chatFocusMode: boolean; + chatShowThinking: boolean; + splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6) + navCollapsed: boolean; // Collapsible sidebar state + navGroupsCollapsed: Record; // Which nav groups are collapsed +}; + +export function loadSettings(): UiSettings { + const defaultUrl = (() => { + const proto = location.protocol === "https:" ? "wss" : "ws"; + return `${proto}://${location.host}`; + })(); + + const defaults: UiSettings = { + gatewayUrl: defaultUrl, + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }; + + try { + const raw = localStorage.getItem(KEY); + if (!raw) return defaults; + const parsed = JSON.parse(raw) as Partial; + return { + gatewayUrl: + typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim() + ? parsed.gatewayUrl.trim() + : defaults.gatewayUrl, + token: typeof parsed.token === "string" ? parsed.token : defaults.token, + sessionKey: + typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() + ? parsed.sessionKey.trim() + : defaults.sessionKey, + lastActiveSessionKey: + typeof parsed.lastActiveSessionKey === "string" && parsed.lastActiveSessionKey.trim() + ? parsed.lastActiveSessionKey.trim() + : (typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()) || + defaults.lastActiveSessionKey, + theme: + parsed.theme === "light" || parsed.theme === "dark" || parsed.theme === "system" + ? parsed.theme + : defaults.theme, + chatFocusMode: + typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode : defaults.chatFocusMode, + chatShowThinking: + typeof parsed.chatShowThinking === "boolean" + ? parsed.chatShowThinking + : defaults.chatShowThinking, + splitRatio: + typeof parsed.splitRatio === "number" && + parsed.splitRatio >= 0.4 && + parsed.splitRatio <= 0.7 + ? parsed.splitRatio + : defaults.splitRatio, + navCollapsed: + typeof parsed.navCollapsed === "boolean" ? parsed.navCollapsed : defaults.navCollapsed, + navGroupsCollapsed: + typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null + ? parsed.navGroupsCollapsed + : defaults.navGroupsCollapsed, + }; + } catch { + return defaults; + } +} + +export function saveSettings(next: UiSettings) { + localStorage.setItem(KEY, JSON.stringify(next)); +} diff --git a/ui/src/ui/theme-transition.ts b/ui/src/ui/theme-transition.ts new file mode 100644 index 0000000000000000000000000000000000000000..10c3942c9c905038f05c953b4ab3bc2273b679e9 --- /dev/null +++ b/ui/src/ui/theme-transition.ts @@ -0,0 +1,101 @@ +import type { ThemeMode } from "./theme"; + +export type ThemeTransitionContext = { + element?: HTMLElement | null; + pointerClientX?: number; + pointerClientY?: number; +}; + +export type ThemeTransitionOptions = { + nextTheme: ThemeMode; + applyTheme: () => void; + context?: ThemeTransitionContext; + currentTheme?: ThemeMode | null; +}; + +type DocumentWithViewTransition = Document & { + startViewTransition?: (callback: () => void) => { finished: Promise }; +}; + +const clamp01 = (value: number) => { + if (Number.isNaN(value)) return 0.5; + if (value <= 0) return 0; + if (value >= 1) return 1; + return value; +}; + +const hasReducedMotionPreference = () => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + return window.matchMedia("(prefers-reduced-motion: reduce)").matches ?? false; +}; + +const cleanupThemeTransition = (root: HTMLElement) => { + root.classList.remove("theme-transition"); + root.style.removeProperty("--theme-switch-x"); + root.style.removeProperty("--theme-switch-y"); +}; + +export const startThemeTransition = ({ + nextTheme, + applyTheme, + context, + currentTheme, +}: ThemeTransitionOptions) => { + if (currentTheme === nextTheme) return; + + const documentReference = globalThis.document ?? null; + if (!documentReference) { + applyTheme(); + return; + } + + const root = documentReference.documentElement; + const document_ = documentReference as DocumentWithViewTransition; + const prefersReducedMotion = hasReducedMotionPreference(); + + const canUseViewTransition = Boolean(document_.startViewTransition) && !prefersReducedMotion; + + if (canUseViewTransition) { + let xPercent = 0.5; + let yPercent = 0.5; + + if ( + context?.pointerClientX !== undefined && + context?.pointerClientY !== undefined && + typeof window !== "undefined" + ) { + xPercent = clamp01(context.pointerClientX / window.innerWidth); + yPercent = clamp01(context.pointerClientY / window.innerHeight); + } else if (context?.element) { + const rect = context.element.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0 && typeof window !== "undefined") { + xPercent = clamp01((rect.left + rect.width / 2) / window.innerWidth); + yPercent = clamp01((rect.top + rect.height / 2) / window.innerHeight); + } + } + + root.style.setProperty("--theme-switch-x", `${xPercent * 100}%`); + root.style.setProperty("--theme-switch-y", `${yPercent * 100}%`); + root.classList.add("theme-transition"); + + try { + const transition = document_.startViewTransition?.(() => { + applyTheme(); + }); + if (transition?.finished) { + void transition.finished.finally(() => cleanupThemeTransition(root)); + } else { + cleanupThemeTransition(root); + } + } catch { + cleanupThemeTransition(root); + applyTheme(); + } + return; + } + + applyTheme(); + cleanupThemeTransition(root); +}; diff --git a/ui/src/ui/theme.ts b/ui/src/ui/theme.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d2db4f2787d21cdc1e85c474ecf7fdab4b79c66 --- /dev/null +++ b/ui/src/ui/theme.ts @@ -0,0 +1,14 @@ +export type ThemeMode = "system" | "light" | "dark"; +export type ResolvedTheme = "light" | "dark"; + +export function getSystemTheme(): ResolvedTheme { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return "dark"; + } + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +export function resolveTheme(mode: ThemeMode): ResolvedTheme { + if (mode === "system") return getSystemTheme(); + return mode; +} diff --git a/ui/src/ui/tool-display.json b/ui/src/ui/tool-display.json new file mode 100644 index 0000000000000000000000000000000000000000..e4cea776eb4a5d67a9254b275e0188e470ddf97b --- /dev/null +++ b/ui/src/ui/tool-display.json @@ -0,0 +1,236 @@ +{ + "version": 1, + "fallback": { + "icon": "puzzle", + "detailKeys": [ + "command", + "path", + "url", + "targetUrl", + "targetId", + "ref", + "element", + "node", + "nodeId", + "id", + "requestId", + "to", + "channelId", + "guildId", + "userId", + "name", + "query", + "pattern", + "messageId" + ] + }, + "tools": { + "bash": { + "icon": "wrench", + "title": "Bash", + "detailKeys": ["command"] + }, + "process": { + "icon": "wrench", + "title": "Process", + "detailKeys": ["sessionId"] + }, + "read": { + "icon": "fileText", + "title": "Read", + "detailKeys": ["path"] + }, + "write": { + "icon": "edit", + "title": "Write", + "detailKeys": ["path"] + }, + "edit": { + "icon": "penLine", + "title": "Edit", + "detailKeys": ["path"] + }, + "attach": { + "icon": "paperclip", + "title": "Attach", + "detailKeys": ["path", "url", "fileName"] + }, + "browser": { + "icon": "globe", + "title": "Browser", + "actions": { + "status": { "label": "status" }, + "start": { "label": "start" }, + "stop": { "label": "stop" }, + "tabs": { "label": "tabs" }, + "open": { "label": "open", "detailKeys": ["targetUrl"] }, + "focus": { "label": "focus", "detailKeys": ["targetId"] }, + "close": { "label": "close", "detailKeys": ["targetId"] }, + "snapshot": { + "label": "snapshot", + "detailKeys": ["targetUrl", "targetId", "ref", "element", "format"] + }, + "screenshot": { + "label": "screenshot", + "detailKeys": ["targetUrl", "targetId", "ref", "element"] + }, + "navigate": { + "label": "navigate", + "detailKeys": ["targetUrl", "targetId"] + }, + "console": { "label": "console", "detailKeys": ["level", "targetId"] }, + "pdf": { "label": "pdf", "detailKeys": ["targetId"] }, + "upload": { + "label": "upload", + "detailKeys": ["paths", "ref", "inputRef", "element", "targetId"] + }, + "dialog": { + "label": "dialog", + "detailKeys": ["accept", "promptText", "targetId"] + }, + "act": { + "label": "act", + "detailKeys": [ + "request.kind", + "request.ref", + "request.selector", + "request.text", + "request.value" + ] + } + } + }, + "canvas": { + "icon": "image", + "title": "Canvas", + "actions": { + "present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] }, + "hide": { "label": "hide", "detailKeys": ["node", "nodeId"] }, + "navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] }, + "eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] }, + "snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] }, + "a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] }, + "a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] } + } + }, + "nodes": { + "icon": "smartphone", + "title": "Nodes", + "actions": { + "status": { "label": "status" }, + "describe": { "label": "describe", "detailKeys": ["node", "nodeId"] }, + "pending": { "label": "pending" }, + "approve": { "label": "approve", "detailKeys": ["requestId"] }, + "reject": { "label": "reject", "detailKeys": ["requestId"] }, + "notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] }, + "camera_snap": { + "label": "camera snap", + "detailKeys": ["node", "nodeId", "facing", "deviceId"] + }, + "camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] }, + "camera_clip": { + "label": "camera clip", + "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] + }, + "screen_record": { + "label": "screen record", + "detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"] + } + } + }, + "cron": { + "icon": "loader", + "title": "Cron", + "actions": { + "status": { "label": "status" }, + "list": { "label": "list" }, + "add": { + "label": "add", + "detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"] + }, + "update": { "label": "update", "detailKeys": ["id"] }, + "remove": { "label": "remove", "detailKeys": ["id"] }, + "run": { "label": "run", "detailKeys": ["id"] }, + "runs": { "label": "runs", "detailKeys": ["id"] }, + "wake": { "label": "wake", "detailKeys": ["text", "mode"] } + } + }, + "gateway": { + "icon": "plug", + "title": "Gateway", + "actions": { + "restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }, + "config.get": { "label": "config get" }, + "config.schema": { "label": "config schema" }, + "config.apply": { + "label": "config apply", + "detailKeys": ["restartDelayMs"] + }, + "update.run": { + "label": "update run", + "detailKeys": ["restartDelayMs"] + } + } + }, + "whatsapp_login": { + "icon": "circle", + "title": "WhatsApp Login", + "actions": { + "start": { "label": "start" }, + "wait": { "label": "wait" } + } + }, + "discord": { + "icon": "messageSquare", + "title": "Discord", + "actions": { + "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] }, + "reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] }, + "sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] }, + "poll": { "label": "poll", "detailKeys": ["question", "to"] }, + "permissions": { "label": "permissions", "detailKeys": ["channelId"] }, + "readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] }, + "sendMessage": { "label": "send", "detailKeys": ["to", "content"] }, + "editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] }, + "deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] }, + "threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] }, + "threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] }, + "threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] }, + "pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] }, + "unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] }, + "listPins": { "label": "list pins", "detailKeys": ["channelId"] }, + "searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] }, + "memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] }, + "roleInfo": { "label": "roles", "detailKeys": ["guildId"] }, + "emojiList": { "label": "emoji list", "detailKeys": ["guildId"] }, + "roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] }, + "roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] }, + "channelInfo": { "label": "channel", "detailKeys": ["channelId"] }, + "channelList": { "label": "channels", "detailKeys": ["guildId"] }, + "voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] }, + "eventList": { "label": "events", "detailKeys": ["guildId"] }, + "eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] }, + "timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] }, + "kick": { "label": "kick", "detailKeys": ["guildId", "userId"] }, + "ban": { "label": "ban", "detailKeys": ["guildId", "userId"] } + } + }, + "slack": { + "icon": "messageSquare", + "title": "Slack", + "actions": { + "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] }, + "reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] }, + "sendMessage": { "label": "send", "detailKeys": ["to", "content"] }, + "editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] }, + "deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] }, + "readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] }, + "pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] }, + "unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] }, + "listPins": { "label": "list pins", "detailKeys": ["channelId"] }, + "memberInfo": { "label": "member", "detailKeys": ["userId"] }, + "emojiList": { "label": "emoji list" } + } + } + } +} diff --git a/ui/src/ui/tool-display.ts b/ui/src/ui/tool-display.ts new file mode 100644 index 0000000000000000000000000000000000000000..4acbe0b473e5aae35207a32c214f85d58919c6b3 --- /dev/null +++ b/ui/src/ui/tool-display.ts @@ -0,0 +1,195 @@ +import type { IconName } from "./icons"; +import rawConfig from "./tool-display.json"; + +type ToolDisplayActionSpec = { + label?: string; + detailKeys?: string[]; +}; + +type ToolDisplaySpec = { + icon?: string; + title?: string; + label?: string; + detailKeys?: string[]; + actions?: Record; +}; + +type ToolDisplayConfig = { + version?: number; + fallback?: ToolDisplaySpec; + tools?: Record; +}; + +export type ToolDisplay = { + name: string; + icon: IconName; + title: string; + label: string; + verb?: string; + detail?: string; +}; + +const TOOL_DISPLAY_CONFIG = rawConfig as ToolDisplayConfig; +const FALLBACK = TOOL_DISPLAY_CONFIG.fallback ?? { icon: "puzzle" }; +const TOOL_MAP = TOOL_DISPLAY_CONFIG.tools ?? {}; + +function normalizeToolName(name?: string): string { + return (name ?? "tool").trim(); +} + +function defaultTitle(name: string): string { + const cleaned = name.replace(/_/g, " ").trim(); + if (!cleaned) return "Tool"; + return cleaned + .split(/\s+/) + .map((part) => + part.length <= 2 && part.toUpperCase() === part + ? part + : `${part.at(0)?.toUpperCase() ?? ""}${part.slice(1)}`, + ) + .join(" "); +} + +function normalizeVerb(value?: string): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + return trimmed.replace(/_/g, " "); +} + +function coerceDisplayValue(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? ""; + if (!firstLine) return undefined; + return firstLine.length > 160 ? `${firstLine.slice(0, 157)}…` : firstLine; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) { + const values = value + .map((item) => coerceDisplayValue(item)) + .filter((item): item is string => Boolean(item)); + if (values.length === 0) return undefined; + const preview = values.slice(0, 3).join(", "); + return values.length > 3 ? `${preview}…` : preview; + } + return undefined; +} + +function lookupValueByPath(args: unknown, path: string): unknown { + if (!args || typeof args !== "object") return undefined; + let current: unknown = args; + for (const segment of path.split(".")) { + if (!segment) return undefined; + if (!current || typeof current !== "object") return undefined; + const record = current as Record; + current = record[segment]; + } + return current; +} + +function resolveDetailFromKeys(args: unknown, keys: string[]): string | undefined { + for (const key of keys) { + const value = lookupValueByPath(args, key); + const display = coerceDisplayValue(value); + if (display) return display; + } + return undefined; +} + +function resolveReadDetail(args: unknown): string | undefined { + if (!args || typeof args !== "object") return undefined; + const record = args as Record; + const path = typeof record.path === "string" ? record.path : undefined; + if (!path) return undefined; + const offset = typeof record.offset === "number" ? record.offset : undefined; + const limit = typeof record.limit === "number" ? record.limit : undefined; + if (offset !== undefined && limit !== undefined) { + return `${path}:${offset}-${offset + limit}`; + } + return path; +} + +function resolveWriteDetail(args: unknown): string | undefined { + if (!args || typeof args !== "object") return undefined; + const record = args as Record; + const path = typeof record.path === "string" ? record.path : undefined; + return path; +} + +function resolveActionSpec( + spec: ToolDisplaySpec | undefined, + action: string | undefined, +): ToolDisplayActionSpec | undefined { + if (!spec || !action) return undefined; + return spec.actions?.[action] ?? undefined; +} + +export function resolveToolDisplay(params: { + name?: string; + args?: unknown; + meta?: string; +}): ToolDisplay { + const name = normalizeToolName(params.name); + const key = name.toLowerCase(); + const spec = TOOL_MAP[key]; + const icon = (spec?.icon ?? FALLBACK.icon ?? "puzzle") as IconName; + const title = spec?.title ?? defaultTitle(name); + const label = spec?.label ?? name; + const actionRaw = + params.args && typeof params.args === "object" + ? ((params.args as Record).action as string | undefined) + : undefined; + const action = typeof actionRaw === "string" ? actionRaw.trim() : undefined; + const actionSpec = resolveActionSpec(spec, action); + const verb = normalizeVerb(actionSpec?.label ?? action); + + let detail: string | undefined; + if (key === "read") detail = resolveReadDetail(params.args); + if (!detail && (key === "write" || key === "edit" || key === "attach")) { + detail = resolveWriteDetail(params.args); + } + + const detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? []; + if (!detail && detailKeys.length > 0) { + detail = resolveDetailFromKeys(params.args, detailKeys); + } + + if (!detail && params.meta) { + detail = params.meta; + } + + if (detail) { + detail = shortenHomeInString(detail); + } + + return { + name, + icon, + title, + label, + verb, + detail, + }; +} + +export function formatToolDetail(display: ToolDisplay): string | undefined { + const parts: string[] = []; + if (display.verb) parts.push(display.verb); + if (display.detail) parts.push(display.detail); + if (parts.length === 0) return undefined; + return parts.join(" · "); +} + +export function formatToolSummary(display: ToolDisplay): string { + const detail = formatToolDetail(display); + return detail ? `${display.label}: ${detail}` : display.label; +} + +function shortenHomeInString(input: string): string { + if (!input) return input; + return input.replace(/\/Users\/[^/]+/g, "~").replace(/\/home\/[^/]+/g, "~"); +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2ce740c515d2cfdc3f7fcf09869490ea141446b --- /dev/null +++ b/ui/src/ui/types.ts @@ -0,0 +1,526 @@ +export type ChannelsStatusSnapshot = { + ts: number; + channelOrder: string[]; + channelLabels: Record; + channelDetailLabels?: Record; + channelSystemImages?: Record; + channelMeta?: ChannelUiMetaEntry[]; + channels: Record; + channelAccounts: Record; + channelDefaultAccountId: Record; +}; + +export type ChannelUiMetaEntry = { + id: string; + label: string; + detailLabel: string; + systemImage?: string; +}; + +export const CRON_CHANNEL_LAST = "last"; + +export type ChannelAccountSnapshot = { + accountId: string; + name?: string | null; + enabled?: boolean | null; + configured?: boolean | null; + linked?: boolean | null; + running?: boolean | null; + connected?: boolean | null; + reconnectAttempts?: number | null; + lastConnectedAt?: number | null; + lastError?: string | null; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastInboundAt?: number | null; + lastOutboundAt?: number | null; + lastProbeAt?: number | null; + mode?: string | null; + dmPolicy?: string | null; + allowFrom?: string[] | null; + tokenSource?: string | null; + botTokenSource?: string | null; + appTokenSource?: string | null; + credentialSource?: string | null; + audienceType?: string | null; + audience?: string | null; + webhookPath?: string | null; + webhookUrl?: string | null; + baseUrl?: string | null; + allowUnmentionedGroups?: boolean | null; + cliPath?: string | null; + dbPath?: string | null; + port?: number | null; + probe?: unknown; + audit?: unknown; + application?: unknown; +}; + +export type WhatsAppSelf = { + e164?: string | null; + jid?: string | null; +}; + +export type WhatsAppDisconnect = { + at: number; + status?: number | null; + error?: string | null; + loggedOut?: boolean | null; +}; + +export type WhatsAppStatus = { + configured: boolean; + linked: boolean; + authAgeMs?: number | null; + self?: WhatsAppSelf | null; + running: boolean; + connected: boolean; + lastConnectedAt?: number | null; + lastDisconnect?: WhatsAppDisconnect | null; + reconnectAttempts: number; + lastMessageAt?: number | null; + lastEventAt?: number | null; + lastError?: string | null; +}; + +export type TelegramBot = { + id?: number | null; + username?: string | null; +}; + +export type TelegramWebhook = { + url?: string | null; + hasCustomCert?: boolean | null; +}; + +export type TelegramProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs?: number | null; + bot?: TelegramBot | null; + webhook?: TelegramWebhook | null; +}; + +export type TelegramStatus = { + configured: boolean; + tokenSource?: string | null; + running: boolean; + mode?: string | null; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + probe?: TelegramProbe | null; + lastProbeAt?: number | null; +}; + +export type DiscordBot = { + id?: string | null; + username?: string | null; +}; + +export type DiscordProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs?: number | null; + bot?: DiscordBot | null; +}; + +export type DiscordStatus = { + configured: boolean; + tokenSource?: string | null; + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + probe?: DiscordProbe | null; + lastProbeAt?: number | null; +}; + +export type GoogleChatProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs?: number | null; +}; + +export type GoogleChatStatus = { + configured: boolean; + credentialSource?: string | null; + audienceType?: string | null; + audience?: string | null; + webhookPath?: string | null; + webhookUrl?: string | null; + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + probe?: GoogleChatProbe | null; + lastProbeAt?: number | null; +}; + +export type SlackBot = { + id?: string | null; + name?: string | null; +}; + +export type SlackTeam = { + id?: string | null; + name?: string | null; +}; + +export type SlackProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs?: number | null; + bot?: SlackBot | null; + team?: SlackTeam | null; +}; + +export type SlackStatus = { + configured: boolean; + botTokenSource?: string | null; + appTokenSource?: string | null; + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + probe?: SlackProbe | null; + lastProbeAt?: number | null; +}; + +export type SignalProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs?: number | null; + version?: string | null; +}; + +export type SignalStatus = { + configured: boolean; + baseUrl: string; + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + probe?: SignalProbe | null; + lastProbeAt?: number | null; +}; + +export type IMessageProbe = { + ok: boolean; + error?: string | null; +}; + +export type IMessageStatus = { + configured: boolean; + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + cliPath?: string | null; + dbPath?: string | null; + probe?: IMessageProbe | null; + lastProbeAt?: number | null; +}; + +export type NostrProfile = { + name?: string | null; + displayName?: string | null; + about?: string | null; + picture?: string | null; + banner?: string | null; + website?: string | null; + nip05?: string | null; + lud16?: string | null; +}; + +export type NostrStatus = { + configured: boolean; + publicKey?: string | null; + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + profile?: NostrProfile | null; +}; + +export type MSTeamsProbe = { + ok: boolean; + error?: string | null; + appId?: string | null; +}; + +export type MSTeamsStatus = { + configured: boolean; + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + port?: number | null; + probe?: MSTeamsProbe | null; + lastProbeAt?: number | null; +}; + +export type ConfigSnapshotIssue = { + path: string; + message: string; +}; + +export type ConfigSnapshot = { + path?: string | null; + exists?: boolean | null; + raw?: string | null; + hash?: string | null; + parsed?: unknown; + valid?: boolean | null; + config?: Record | null; + issues?: ConfigSnapshotIssue[] | null; +}; + +export type ConfigUiHint = { + label?: string; + help?: string; + group?: string; + order?: number; + advanced?: boolean; + sensitive?: boolean; + placeholder?: string; + itemTemplate?: unknown; +}; + +export type ConfigUiHints = Record; + +export type ConfigSchemaResponse = { + schema: unknown; + uiHints: ConfigUiHints; + version: string; + generatedAt: string; +}; + +export type PresenceEntry = { + instanceId?: string | null; + host?: string | null; + ip?: string | null; + version?: string | null; + platform?: string | null; + deviceFamily?: string | null; + modelIdentifier?: string | null; + mode?: string | null; + lastInputSeconds?: number | null; + reason?: string | null; + text?: string | null; + ts?: number | null; +}; + +export type GatewaySessionsDefaults = { + model: string | null; + contextTokens: number | null; +}; + +export type GatewayAgentRow = { + id: string; + name?: string; + identity?: { + name?: string; + theme?: string; + emoji?: string; + avatar?: string; + avatarUrl?: string; + }; +}; + +export type AgentsListResult = { + defaultId: string; + mainKey: string; + scope: string; + agents: GatewayAgentRow[]; +}; + +export type GatewaySessionRow = { + key: string; + kind: "direct" | "group" | "global" | "unknown"; + label?: string; + displayName?: string; + surface?: string; + subject?: string; + room?: string; + space?: string; + updatedAt: number | null; + sessionId?: string; + systemSent?: boolean; + abortedLastRun?: boolean; + thinkingLevel?: string; + verboseLevel?: string; + reasoningLevel?: string; + elevatedLevel?: string; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + model?: string; + modelProvider?: string; + contextTokens?: number; +}; + +export type SessionsListResult = { + ts: number; + path: string; + count: number; + defaults: GatewaySessionsDefaults; + sessions: GatewaySessionRow[]; +}; + +export type SessionsPatchResult = { + ok: true; + path: string; + key: string; + entry: { + sessionId: string; + updatedAt?: number; + thinkingLevel?: string; + verboseLevel?: string; + reasoningLevel?: string; + elevatedLevel?: string; + }; +}; + +export type CronSchedule = + | { kind: "at"; atMs: number } + | { kind: "every"; everyMs: number; anchorMs?: number } + | { kind: "cron"; expr: string; tz?: string }; + +export type CronSessionTarget = "main" | "isolated"; +export type CronWakeMode = "next-heartbeat" | "now"; + +export type CronPayload = + | { kind: "systemEvent"; text: string } + | { + kind: "agentTurn"; + message: string; + thinking?: string; + timeoutSeconds?: number; + deliver?: boolean; + provider?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage" + | "msteams"; + to?: string; + bestEffortDeliver?: boolean; + }; + +export type CronIsolation = { + postToMainPrefix?: string; +}; + +export type CronJobState = { + nextRunAtMs?: number; + runningAtMs?: number; + lastRunAtMs?: number; + lastStatus?: "ok" | "error" | "skipped"; + lastError?: string; + lastDurationMs?: number; +}; + +export type CronJob = { + id: string; + agentId?: string; + name: string; + description?: string; + enabled: boolean; + deleteAfterRun?: boolean; + createdAtMs: number; + updatedAtMs: number; + schedule: CronSchedule; + sessionTarget: CronSessionTarget; + wakeMode: CronWakeMode; + payload: CronPayload; + isolation?: CronIsolation; + state?: CronJobState; +}; + +export type CronStatus = { + enabled: boolean; + jobs: number; + nextWakeAtMs?: number | null; +}; + +export type CronRunLogEntry = { + ts: number; + jobId: string; + status: "ok" | "error" | "skipped"; + durationMs?: number; + error?: string; + summary?: string; +}; + +export type SkillsStatusConfigCheck = { + path: string; + value: unknown; + satisfied: boolean; +}; + +export type SkillInstallOption = { + id: string; + kind: "brew" | "node" | "go" | "uv"; + label: string; + bins: string[]; +}; + +export type SkillStatusEntry = { + name: string; + description: string; + source: string; + filePath: string; + baseDir: string; + skillKey: string; + primaryEnv?: string; + emoji?: string; + homepage?: string; + always: boolean; + disabled: boolean; + blockedByAllowlist: boolean; + eligible: boolean; + requirements: { + bins: string[]; + env: string[]; + config: string[]; + os: string[]; + }; + missing: { + bins: string[]; + env: string[]; + config: string[]; + os: string[]; + }; + configChecks: SkillsStatusConfigCheck[]; + install: SkillInstallOption[]; +}; + +export type SkillStatusReport = { + workspaceDir: string; + managedSkillsDir: string; + skills: SkillStatusEntry[]; +}; + +export type StatusSummary = Record; + +export type HealthSnapshot = Record; + +export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"; + +export type LogEntry = { + raw: string; + time?: string | null; + level?: LogLevel | null; + subsystem?: string | null; + message?: string | null; + meta?: Record | null; +}; diff --git a/ui/src/ui/types/chat-types.ts b/ui/src/ui/types/chat-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..0638f494d4e0d6eda6f4d7307037459539e9b27f --- /dev/null +++ b/ui/src/ui/types/chat-types.ts @@ -0,0 +1,43 @@ +/** + * Chat message types for the UI layer. + */ + +/** Union type for items in the chat thread */ +export type ChatItem = + | { kind: "message"; key: string; message: unknown } + | { kind: "stream"; key: string; text: string; startedAt: number } + | { kind: "reading-indicator"; key: string }; + +/** A group of consecutive messages from the same role (Slack-style layout) */ +export type MessageGroup = { + kind: "group"; + key: string; + role: string; + messages: Array<{ message: unknown; key: string }>; + timestamp: number; + isStreaming: boolean; +}; + +/** Content item types in a normalized message */ +export type MessageContentItem = { + type: "text" | "tool_call" | "tool_result"; + text?: string; + name?: string; + args?: unknown; +}; + +/** Normalized message structure for rendering */ +export type NormalizedMessage = { + role: string; + content: MessageContentItem[]; + timestamp: number; + id?: string; +}; + +/** Tool card representation for tool calls and results */ +export type ToolCard = { + kind: "call" | "result"; + name: string; + args?: unknown; + text?: string; +}; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..afb80c179bd6d8549bd4e4937d4f665c725b3367 --- /dev/null +++ b/ui/src/ui/ui-types.ts @@ -0,0 +1,37 @@ +export type ChatAttachment = { + id: string; + dataUrl: string; + mimeType: string; +}; + +export type ChatQueueItem = { + id: string; + text: string; + createdAt: number; + attachments?: ChatAttachment[]; + refreshSessions?: boolean; +}; + +export const CRON_CHANNEL_LAST = "last"; + +export type CronFormState = { + name: string; + description: string; + agentId: string; + enabled: boolean; + scheduleKind: "at" | "every" | "cron"; + scheduleAt: string; + everyAmount: string; + everyUnit: "minutes" | "hours" | "days"; + cronExpr: string; + cronTz: string; + sessionTarget: "main" | "isolated"; + wakeMode: "next-heartbeat" | "now"; + payloadKind: "systemEvent" | "agentTurn"; + payloadText: string; + deliver: boolean; + channel: string; + to: string; + timeoutSeconds: string; + postToMainPrefix: string; +}; diff --git a/ui/src/ui/uuid.test.ts b/ui/src/ui/uuid.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..946a1866d34090b8cc9b0088f415792b24386e12 --- /dev/null +++ b/ui/src/ui/uuid.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { generateUUID } from "./uuid"; + +describe("generateUUID", () => { + it("uses crypto.randomUUID when available", () => { + const id = generateUUID({ + randomUUID: () => "randomuuid", + getRandomValues: () => { + throw new Error("should not be called"); + }, + }); + + expect(id).toBe("randomuuid"); + }); + + it("falls back to crypto.getRandomValues", () => { + const id = generateUUID({ + getRandomValues: (bytes) => { + for (let i = 0; i < bytes.length; i++) bytes[i] = i; + return bytes; + }, + }); + + expect(id).toBe("00010203-0405-4607-8809-0a0b0c0d0e0f"); + }); + + it("still returns a v4 UUID when crypto is missing", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const id = generateUUID(null); + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(warnSpy).toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); +}); diff --git a/ui/src/ui/uuid.ts b/ui/src/ui/uuid.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c927cda1f3b453665dba345dd11f1d3a4cb26de --- /dev/null +++ b/ui/src/ui/uuid.ts @@ -0,0 +1,51 @@ +export type CryptoLike = { + randomUUID?: (() => string) | undefined; + getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined; +}; + +let warnedWeakCrypto = false; + +function uuidFromBytes(bytes: Uint8Array): string { + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + + let hex = ""; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i]!.toString(16).padStart(2, "0"); + } + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice( + 16, + 20, + )}-${hex.slice(20)}`; +} + +function weakRandomBytes(): Uint8Array { + const bytes = new Uint8Array(16); + const now = Date.now(); + for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + bytes[0] ^= now & 0xff; + bytes[1] ^= (now >>> 8) & 0xff; + bytes[2] ^= (now >>> 16) & 0xff; + bytes[3] ^= (now >>> 24) & 0xff; + return bytes; +} + +function warnWeakCryptoOnce() { + if (warnedWeakCrypto) return; + warnedWeakCrypto = true; + console.warn("[uuid] crypto API missing; falling back to weak randomness"); +} + +export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string { + if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID(); + + if (cryptoLike && typeof cryptoLike.getRandomValues === "function") { + const bytes = new Uint8Array(16); + cryptoLike.getRandomValues(bytes); + return uuidFromBytes(bytes); + } + + warnWeakCryptoOnce(); + return uuidFromBytes(weakRandomBytes()); +} diff --git a/ui/src/ui/views/channels.config.ts b/ui/src/ui/views/channels.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c2eb62099f4088ee40a4dfe273f6dd86373d468 --- /dev/null +++ b/ui/src/ui/views/channels.config.ts @@ -0,0 +1,131 @@ +import { html } from "lit"; +import type { ConfigUiHints } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { analyzeConfigSchema, renderNode, schemaType, type JsonSchema } from "./config-form"; + +type ChannelConfigFormProps = { + channelId: string; + configValue: Record | null; + schema: unknown | null; + uiHints: ConfigUiHints; + disabled: boolean; + onPatch: (path: Array, value: unknown) => void; +}; + +function resolveSchemaNode( + schema: JsonSchema | null, + path: Array, +): JsonSchema | null { + let current = schema; + for (const key of path) { + if (!current) return null; + const type = schemaType(current); + if (type === "object") { + const properties = current.properties ?? {}; + if (typeof key === "string" && properties[key]) { + current = properties[key]; + continue; + } + const additional = current.additionalProperties; + if (typeof key === "string" && additional && typeof additional === "object") { + current = additional as JsonSchema; + continue; + } + return null; + } + if (type === "array") { + if (typeof key !== "number") return null; + const items = Array.isArray(current.items) ? current.items[0] : current.items; + current = items ?? null; + continue; + } + return null; + } + return current; +} + +function resolveChannelValue( + config: Record, + channelId: string, +): Record { + const channels = (config.channels ?? {}) as Record; + const fromChannels = channels[channelId]; + const fallback = config[channelId]; + const resolved = + (fromChannels && typeof fromChannels === "object" + ? (fromChannels as Record) + : null) ?? + (fallback && typeof fallback === "object" ? (fallback as Record) : null); + return resolved ?? {}; +} + +export function renderChannelConfigForm(props: ChannelConfigFormProps) { + const analysis = analyzeConfigSchema(props.schema); + const normalized = analysis.schema; + if (!normalized) { + return html` +
      Schema unavailable. Use Raw.
      + `; + } + const node = resolveSchemaNode(normalized, ["channels", props.channelId]); + if (!node) { + return html` +
      Channel config schema unavailable.
      + `; + } + const configValue = props.configValue ?? {}; + const value = resolveChannelValue(configValue, props.channelId); + return html` +
      + ${renderNode({ + schema: node, + value, + path: ["channels", props.channelId], + hints: props.uiHints, + unsupported: new Set(analysis.unsupportedPaths), + disabled: props.disabled, + showLabel: false, + onPatch: props.onPatch, + })} +
      + `; +} + +export function renderChannelConfigSection(params: { channelId: string; props: ChannelsProps }) { + const { channelId, props } = params; + const disabled = props.configSaving || props.configSchemaLoading; + return html` +
      + ${ + props.configSchemaLoading + ? html` +
      Loading config schema…
      + ` + : renderChannelConfigForm({ + channelId, + configValue: props.configForm, + schema: props.configSchema, + uiHints: props.configUiHints, + disabled, + onPatch: props.onConfigPatch, + }) + } +
      + + +
      +
      + `; +} diff --git a/ui/src/ui/views/channels.discord.ts b/ui/src/ui/views/channels.discord.ts new file mode 100644 index 0000000000000000000000000000000000000000..59d2e65b23baf0f17dbee4e30568dd26b53970a6 --- /dev/null +++ b/ui/src/ui/views/channels.discord.ts @@ -0,0 +1,65 @@ +import { html, nothing } from "lit"; +import type { DiscordStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { formatAgo } from "../format"; +import { renderChannelConfigSection } from "./channels.config"; + +export function renderDiscordCard(params: { + props: ChannelsProps; + discord?: DiscordStatus | null; + accountCountLabel: unknown; +}) { + const { props, discord, accountCountLabel } = params; + + return html` +
      +
      Discord
      +
      Bot status and channel configuration.
      + ${accountCountLabel} + +
      +
      + Configured + ${discord?.configured ? "Yes" : "No"} +
      +
      + Running + ${discord?.running ? "Yes" : "No"} +
      +
      + Last start + ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"} +
      +
      + Last probe + ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"} +
      +
      + + ${ + discord?.lastError + ? html`
      + ${discord.lastError} +
      ` + : nothing + } + + ${ + discord?.probe + ? html`
      + Probe ${discord.probe.ok ? "ok" : "failed"} · + ${discord.probe.status ?? ""} ${discord.probe.error ?? ""} +
      ` + : nothing + } + + ${renderChannelConfigSection({ channelId: "discord", props })} + +
      + +
      +
      + `; +} diff --git a/ui/src/ui/views/channels.googlechat.ts b/ui/src/ui/views/channels.googlechat.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcfeffd2229fdce5a3b613ab14bc8608b28c17ae --- /dev/null +++ b/ui/src/ui/views/channels.googlechat.ts @@ -0,0 +1,79 @@ +import { html, nothing } from "lit"; +import type { GoogleChatStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { formatAgo } from "../format"; +import { renderChannelConfigSection } from "./channels.config"; + +export function renderGoogleChatCard(params: { + props: ChannelsProps; + googleChat?: GoogleChatStatus | null; + accountCountLabel: unknown; +}) { + const { props, googleChat, accountCountLabel } = params; + + return html` +
      +
      Google Chat
      +
      Chat API webhook status and channel configuration.
      + ${accountCountLabel} + +
      +
      + Configured + ${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"} +
      +
      + Running + ${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"} +
      +
      + Credential + ${googleChat?.credentialSource ?? "n/a"} +
      +
      + Audience + + ${ + googleChat?.audienceType + ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}` + : "n/a" + } + +
      +
      + Last start + ${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"} +
      +
      + Last probe + ${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"} +
      +
      + + ${ + googleChat?.lastError + ? html`
      + ${googleChat.lastError} +
      ` + : nothing + } + + ${ + googleChat?.probe + ? html`
      + Probe ${googleChat.probe.ok ? "ok" : "failed"} · + ${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""} +
      ` + : nothing + } + + ${renderChannelConfigSection({ channelId: "googlechat", props })} + +
      + +
      +
      + `; +} diff --git a/ui/src/ui/views/channels.imessage.ts b/ui/src/ui/views/channels.imessage.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a67b148c86e8f8f8a1e797f758163de69eaf8df --- /dev/null +++ b/ui/src/ui/views/channels.imessage.ts @@ -0,0 +1,65 @@ +import { html, nothing } from "lit"; +import type { IMessageStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { formatAgo } from "../format"; +import { renderChannelConfigSection } from "./channels.config"; + +export function renderIMessageCard(params: { + props: ChannelsProps; + imessage?: IMessageStatus | null; + accountCountLabel: unknown; +}) { + const { props, imessage, accountCountLabel } = params; + + return html` +
      +
      iMessage
      +
      macOS bridge status and channel configuration.
      + ${accountCountLabel} + +
      +
      + Configured + ${imessage?.configured ? "Yes" : "No"} +
      +
      + Running + ${imessage?.running ? "Yes" : "No"} +
      +
      + Last start + ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"} +
      +
      + Last probe + ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"} +
      +
      + + ${ + imessage?.lastError + ? html`
      + ${imessage.lastError} +
      ` + : nothing + } + + ${ + imessage?.probe + ? html`
      + Probe ${imessage.probe.ok ? "ok" : "failed"} · + ${imessage.probe.error ?? ""} +
      ` + : nothing + } + + ${renderChannelConfigSection({ channelId: "imessage", props })} + +
      + +
      +
      + `; +} diff --git a/ui/src/ui/views/channels.nostr-profile-form.ts b/ui/src/ui/views/channels.nostr-profile-form.ts new file mode 100644 index 0000000000000000000000000000000000000000..a18d1c981ec0ef50b5bbf57c81b7c5b7eba493c7 --- /dev/null +++ b/ui/src/ui/views/channels.nostr-profile-form.ts @@ -0,0 +1,319 @@ +/** + * Nostr Profile Edit Form + * + * Provides UI for editing and publishing Nostr profile (kind:0). + */ + +import { html, nothing, type TemplateResult } from "lit"; +import type { NostrProfile as NostrProfileType } from "../types"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface NostrProfileFormState { + /** Current form values */ + values: NostrProfileType; + /** Original values for dirty detection */ + original: NostrProfileType; + /** Whether the form is currently submitting */ + saving: boolean; + /** Whether import is in progress */ + importing: boolean; + /** Last error message */ + error: string | null; + /** Last success message */ + success: string | null; + /** Validation errors per field */ + fieldErrors: Record; + /** Whether to show advanced fields */ + showAdvanced: boolean; +} + +export interface NostrProfileFormCallbacks { + /** Called when a field value changes */ + onFieldChange: (field: keyof NostrProfileType, value: string) => void; + /** Called when save is clicked */ + onSave: () => void; + /** Called when import is clicked */ + onImport: () => void; + /** Called when cancel is clicked */ + onCancel: () => void; + /** Called when toggle advanced is clicked */ + onToggleAdvanced: () => void; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function isFormDirty(state: NostrProfileFormState): boolean { + const { values, original } = state; + return ( + values.name !== original.name || + values.displayName !== original.displayName || + values.about !== original.about || + values.picture !== original.picture || + values.banner !== original.banner || + values.website !== original.website || + values.nip05 !== original.nip05 || + values.lud16 !== original.lud16 + ); +} + +// ============================================================================ +// Form Rendering +// ============================================================================ + +export function renderNostrProfileForm(params: { + state: NostrProfileFormState; + callbacks: NostrProfileFormCallbacks; + accountId: string; +}): TemplateResult { + const { state, callbacks, accountId } = params; + const isDirty = isFormDirty(state); + + const renderField = ( + field: keyof NostrProfileType, + label: string, + opts: { + type?: "text" | "url" | "textarea"; + placeholder?: string; + maxLength?: number; + help?: string; + } = {}, + ) => { + const { type = "text", placeholder, maxLength, help } = opts; + const value = state.values[field] ?? ""; + const error = state.fieldErrors[field]; + + const inputId = `nostr-profile-${field}`; + + if (type === "textarea") { + return html` +
      + + + ${help ? html`
      ${help}
      ` : nothing} + ${error ? html`
      ${error}
      ` : nothing} +
      + `; + } + + return html` +
      + + { + const target = e.target as HTMLInputElement; + callbacks.onFieldChange(field, target.value); + }} + ?disabled=${state.saving} + /> + ${help ? html`
      ${help}
      ` : nothing} + ${error ? html`
      ${error}
      ` : nothing} +
      + `; + }; + + const renderPicturePreview = () => { + const picture = state.values.picture; + if (!picture) return nothing; + + return html` +
      + Profile picture preview { + const img = e.target as HTMLImageElement; + img.style.display = "none"; + }} + @load=${(e: Event) => { + const img = e.target as HTMLImageElement; + img.style.display = "block"; + }} + /> +
      + `; + }; + + return html` +
      +
      +
      Edit Profile
      +
      Account: ${accountId}
      +
      + + ${ + state.error + ? html`
      ${state.error}
      ` + : nothing + } + + ${ + state.success + ? html`
      ${state.success}
      ` + : nothing + } + + ${renderPicturePreview()} + + ${renderField("name", "Username", { + placeholder: "satoshi", + maxLength: 256, + help: "Short username (e.g., satoshi)", + })} + + ${renderField("displayName", "Display Name", { + placeholder: "Satoshi Nakamoto", + maxLength: 256, + help: "Your full display name", + })} + + ${renderField("about", "Bio", { + type: "textarea", + placeholder: "Tell people about yourself...", + maxLength: 2000, + help: "A brief bio or description", + })} + + ${renderField("picture", "Avatar URL", { + type: "url", + placeholder: "https://example.com/avatar.jpg", + help: "HTTPS URL to your profile picture", + })} + + ${ + state.showAdvanced + ? html` +
      +
      Advanced
      + + ${renderField("banner", "Banner URL", { + type: "url", + placeholder: "https://example.com/banner.jpg", + help: "HTTPS URL to a banner image", + })} + + ${renderField("website", "Website", { + type: "url", + placeholder: "https://example.com", + help: "Your personal website", + })} + + ${renderField("nip05", "NIP-05 Identifier", { + placeholder: "you@example.com", + help: "Verifiable identifier (e.g., you@domain.com)", + })} + + ${renderField("lud16", "Lightning Address", { + placeholder: "you@getalby.com", + help: "Lightning address for tips (LUD-16)", + })} +
      + ` + : nothing + } + +
      + + + + + + + +
      + + ${ + isDirty + ? html` +
      + You have unsaved changes +
      + ` + : nothing + } +
      + `; +} + +// ============================================================================ +// Factory +// ============================================================================ + +/** + * Create initial form state from existing profile + */ +export function createNostrProfileFormState( + profile: NostrProfileType | undefined, +): NostrProfileFormState { + const values: NostrProfileType = { + name: profile?.name ?? "", + displayName: profile?.displayName ?? "", + about: profile?.about ?? "", + picture: profile?.picture ?? "", + banner: profile?.banner ?? "", + website: profile?.website ?? "", + nip05: profile?.nip05 ?? "", + lud16: profile?.lud16 ?? "", + }; + + return { + values, + original: { ...values }, + saving: false, + importing: false, + error: null, + success: null, + fieldErrors: {}, + showAdvanced: Boolean(profile?.banner || profile?.website || profile?.nip05 || profile?.lud16), + }; +} diff --git a/ui/src/ui/views/channels.nostr.ts b/ui/src/ui/views/channels.nostr.ts new file mode 100644 index 0000000000000000000000000000000000000000..0792f8046233212b0d29d01f9d65a2ab46441b75 --- /dev/null +++ b/ui/src/ui/views/channels.nostr.ts @@ -0,0 +1,233 @@ +import { html, nothing } from "lit"; +import type { ChannelAccountSnapshot, NostrStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { formatAgo } from "../format"; +import { renderChannelConfigSection } from "./channels.config"; +import { + renderNostrProfileForm, + type NostrProfileFormState, + type NostrProfileFormCallbacks, +} from "./channels.nostr-profile-form"; + +/** + * Truncate a pubkey for display (shows first and last 8 chars) + */ +function truncatePubkey(pubkey: string | null | undefined): string { + if (!pubkey) return "n/a"; + if (pubkey.length <= 20) return pubkey; + return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`; +} + +export function renderNostrCard(params: { + props: ChannelsProps; + nostr?: NostrStatus | null; + nostrAccounts: ChannelAccountSnapshot[]; + accountCountLabel: unknown; + /** Profile form state (optional - if provided, shows form) */ + profileFormState?: NostrProfileFormState | null; + /** Profile form callbacks */ + profileFormCallbacks?: NostrProfileFormCallbacks | null; + /** Called when Edit Profile is clicked */ + onEditProfile?: () => void; +}) { + const { + props, + nostr, + nostrAccounts, + accountCountLabel, + profileFormState, + profileFormCallbacks, + onEditProfile, + } = params; + const primaryAccount = nostrAccounts[0]; + const summaryConfigured = nostr?.configured ?? primaryAccount?.configured ?? false; + const summaryRunning = nostr?.running ?? primaryAccount?.running ?? false; + const summaryPublicKey = + nostr?.publicKey ?? (primaryAccount as { publicKey?: string } | undefined)?.publicKey; + const summaryLastStartAt = nostr?.lastStartAt ?? primaryAccount?.lastStartAt ?? null; + const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null; + const hasMultipleAccounts = nostrAccounts.length > 1; + const showingForm = profileFormState !== null && profileFormState !== undefined; + + const renderAccountCard = (account: ChannelAccountSnapshot) => { + const publicKey = (account as { publicKey?: string }).publicKey; + const profile = (account as { profile?: { name?: string; displayName?: string } }).profile; + const displayName = profile?.displayName ?? profile?.name ?? account.name ?? account.accountId; + + return html` + + `; + }; + + const renderProfileSection = () => { + // If showing form, render the form instead of the read-only view + if (showingForm && profileFormCallbacks) { + return renderNostrProfileForm({ + state: profileFormState, + callbacks: profileFormCallbacks, + accountId: nostrAccounts[0]?.accountId ?? "default", + }); + } + + const profile = + ( + primaryAccount as + | { + profile?: { + name?: string; + displayName?: string; + about?: string; + picture?: string; + nip05?: string; + }; + } + | undefined + )?.profile ?? nostr?.profile; + const { name, displayName, about, picture, nip05 } = profile ?? {}; + const hasAnyProfileData = name || displayName || about || picture || nip05; + + return html` +
      +
      +
      Profile
      + ${ + summaryConfigured + ? html` + + ` + : nothing + } +
      + ${ + hasAnyProfileData + ? html` +
      + ${ + picture + ? html` +
      + Profile picture { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
      + ` + : nothing + } + ${name ? html`
      Name${name}
      ` : nothing} + ${ + displayName + ? html`
      Display Name${displayName}
      ` + : nothing + } + ${ + about + ? html`
      About${about}
      ` + : nothing + } + ${nip05 ? html`
      NIP-05${nip05}
      ` : nothing} +
      + ` + : html` +
      + No profile set. Click "Edit Profile" to add your name, bio, and avatar. +
      + ` + } +
      + `; + }; + + return html` +
      +
      Nostr
      +
      Decentralized DMs via Nostr relays (NIP-04).
      + ${accountCountLabel} + + ${ + hasMultipleAccounts + ? html` + + ` + : html` +
      +
      + Configured + ${summaryConfigured ? "Yes" : "No"} +
      +
      + Running + ${summaryRunning ? "Yes" : "No"} +
      +
      + Public Key + ${truncatePubkey(summaryPublicKey)} +
      +
      + Last start + ${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"} +
      +
      + ` + } + + ${ + summaryLastError + ? html`
      ${summaryLastError}
      ` + : nothing + } + + ${renderProfileSection()} + + ${renderChannelConfigSection({ channelId: "nostr", props })} + +
      + +
      +
      + `; +} diff --git a/ui/src/ui/views/channels.shared.ts b/ui/src/ui/views/channels.shared.ts new file mode 100644 index 0000000000000000000000000000000000000000..7da38a713949d159df4b4ea64962a56b3f44fba4 --- /dev/null +++ b/ui/src/ui/views/channels.shared.ts @@ -0,0 +1,44 @@ +import { html, nothing } from "lit"; +import type { ChannelAccountSnapshot } from "../types"; +import type { ChannelKey, ChannelsProps } from "./channels.types"; + +export function formatDuration(ms?: number | null) { + if (!ms && ms !== 0) return "n/a"; + const sec = Math.round(ms / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m`; + const hr = Math.round(min / 60); + return `${hr}h`; +} + +export function channelEnabled(key: ChannelKey, props: ChannelsProps) { + const snapshot = props.snapshot; + const channels = snapshot?.channels as Record | null; + if (!snapshot || !channels) return false; + const channelStatus = channels[key] as Record | undefined; + const configured = typeof channelStatus?.configured === "boolean" && channelStatus.configured; + const running = typeof channelStatus?.running === "boolean" && channelStatus.running; + const connected = typeof channelStatus?.connected === "boolean" && channelStatus.connected; + const accounts = snapshot.channelAccounts?.[key] ?? []; + const accountActive = accounts.some( + (account) => account.configured || account.running || account.connected, + ); + return configured || running || connected || accountActive; +} + +export function getChannelAccountCount( + key: ChannelKey, + channelAccounts?: Record | null, +): number { + return channelAccounts?.[key]?.length ?? 0; +} + +export function renderChannelAccountCount( + key: ChannelKey, + channelAccounts?: Record | null, +) { + const count = getChannelAccountCount(key, channelAccounts); + if (count < 2) return nothing; + return html``; +} diff --git a/ui/src/ui/views/channels.signal.ts b/ui/src/ui/views/channels.signal.ts new file mode 100644 index 0000000000000000000000000000000000000000..050b14bb8cbcfda421adf9f019b2d4f7fa232cdc --- /dev/null +++ b/ui/src/ui/views/channels.signal.ts @@ -0,0 +1,69 @@ +import { html, nothing } from "lit"; +import type { SignalStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { formatAgo } from "../format"; +import { renderChannelConfigSection } from "./channels.config"; + +export function renderSignalCard(params: { + props: ChannelsProps; + signal?: SignalStatus | null; + accountCountLabel: unknown; +}) { + const { props, signal, accountCountLabel } = params; + + return html` +
      +
      Signal
      +
      signal-cli status and channel configuration.
      + ${accountCountLabel} + +
      +
      + Configured + ${signal?.configured ? "Yes" : "No"} +
      +
      + Running + ${signal?.running ? "Yes" : "No"} +
      +
      + Base URL + ${signal?.baseUrl ?? "n/a"} +
      +
      + Last start + ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"} +
      +
      + Last probe + ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"} +
      +
      + + ${ + signal?.lastError + ? html`
      + ${signal.lastError} +
      ` + : nothing + } + + ${ + signal?.probe + ? html`
      + Probe ${signal.probe.ok ? "ok" : "failed"} · + ${signal.probe.status ?? ""} ${signal.probe.error ?? ""} +
      ` + : nothing + } + + ${renderChannelConfigSection({ channelId: "signal", props })} + +
      + +
      +
      + `; +} diff --git a/ui/src/ui/views/channels.slack.ts b/ui/src/ui/views/channels.slack.ts new file mode 100644 index 0000000000000000000000000000000000000000..d018f40efb5e15ed7c622ad9f9bcf607214b5264 --- /dev/null +++ b/ui/src/ui/views/channels.slack.ts @@ -0,0 +1,65 @@ +import { html, nothing } from "lit"; +import type { SlackStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { formatAgo } from "../format"; +import { renderChannelConfigSection } from "./channels.config"; + +export function renderSlackCard(params: { + props: ChannelsProps; + slack?: SlackStatus | null; + accountCountLabel: unknown; +}) { + const { props, slack, accountCountLabel } = params; + + return html` +
      +
      Slack
      +
      Socket mode status and channel configuration.
      + ${accountCountLabel} + +
      +
      + Configured + ${slack?.configured ? "Yes" : "No"} +
      +
      + Running + ${slack?.running ? "Yes" : "No"} +
      +
      + Last start + ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"} +
      +
      + Last probe + ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"} +
      +
      + + ${ + slack?.lastError + ? html`
      + ${slack.lastError} +
      ` + : nothing + } + + ${ + slack?.probe + ? html`
      + Probe ${slack.probe.ok ? "ok" : "failed"} · + ${slack.probe.status ?? ""} ${slack.probe.error ?? ""} +
      ` + : nothing + } + + ${renderChannelConfigSection({ channelId: "slack", props })} + +
      + +
      +
      + `; +} diff --git a/ui/src/ui/views/channels.telegram.ts b/ui/src/ui/views/channels.telegram.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2347c0f8c9cecd1342ee12c37ff0d1e7b9af101 --- /dev/null +++ b/ui/src/ui/views/channels.telegram.ts @@ -0,0 +1,120 @@ +import { html, nothing } from "lit"; +import type { ChannelAccountSnapshot, TelegramStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { formatAgo } from "../format"; +import { renderChannelConfigSection } from "./channels.config"; + +export function renderTelegramCard(params: { + props: ChannelsProps; + telegram?: TelegramStatus; + telegramAccounts: ChannelAccountSnapshot[]; + accountCountLabel: unknown; +}) { + const { props, telegram, telegramAccounts, accountCountLabel } = params; + const hasMultipleAccounts = telegramAccounts.length > 1; + + const renderAccountCard = (account: ChannelAccountSnapshot) => { + const probe = account.probe as { bot?: { username?: string } } | undefined; + const botUsername = probe?.bot?.username; + const label = account.name || account.accountId; + return html` + + `; + }; + + return html` +
      +
      Telegram
      +
      Bot status and channel configuration.
      + ${accountCountLabel} + + ${ + hasMultipleAccounts + ? html` + + ` + : html` +
      +
      + Configured + ${telegram?.configured ? "Yes" : "No"} +
      +
      + Running + ${telegram?.running ? "Yes" : "No"} +
      +
      + Mode + ${telegram?.mode ?? "n/a"} +
      +
      + Last start + ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"} +
      +
      + Last probe + ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"} +
      +
      + ` + } + + ${ + telegram?.lastError + ? html`
      + ${telegram.lastError} +
      ` + : nothing + } + + ${ + telegram?.probe + ? html`
      + Probe ${telegram.probe.ok ? "ok" : "failed"} · + ${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""} +
      ` + : nothing + } + + ${renderChannelConfigSection({ channelId: "telegram", props })} + +
      + +
      +
      + `; +} diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts new file mode 100644 index 0000000000000000000000000000000000000000..444b22e59a68f6f4cb26780af6d14ef8df30dfdd --- /dev/null +++ b/ui/src/ui/views/channels.ts @@ -0,0 +1,309 @@ +import { html, nothing } from "lit"; +import type { + ChannelAccountSnapshot, + ChannelUiMetaEntry, + ChannelsStatusSnapshot, + DiscordStatus, + GoogleChatStatus, + IMessageStatus, + NostrProfile, + NostrStatus, + SignalStatus, + SlackStatus, + TelegramStatus, + WhatsAppStatus, +} from "../types"; +import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./channels.types"; +import { formatAgo } from "../format"; +import { renderChannelConfigSection } from "./channels.config"; +import { renderDiscordCard } from "./channels.discord"; +import { renderGoogleChatCard } from "./channels.googlechat"; +import { renderIMessageCard } from "./channels.imessage"; +import { renderNostrCard } from "./channels.nostr"; +import { channelEnabled, renderChannelAccountCount } from "./channels.shared"; +import { renderSignalCard } from "./channels.signal"; +import { renderSlackCard } from "./channels.slack"; +import { renderTelegramCard } from "./channels.telegram"; +import { renderWhatsAppCard } from "./channels.whatsapp"; + +export function renderChannels(props: ChannelsProps) { + const channels = props.snapshot?.channels as Record | null; + const whatsapp = (channels?.whatsapp ?? undefined) as WhatsAppStatus | undefined; + const telegram = (channels?.telegram ?? undefined) as TelegramStatus | undefined; + const discord = (channels?.discord ?? null) as DiscordStatus | null; + const googlechat = (channels?.googlechat ?? null) as GoogleChatStatus | null; + const slack = (channels?.slack ?? null) as SlackStatus | null; + const signal = (channels?.signal ?? null) as SignalStatus | null; + const imessage = (channels?.imessage ?? null) as IMessageStatus | null; + const nostr = (channels?.nostr ?? null) as NostrStatus | null; + const channelOrder = resolveChannelOrder(props.snapshot); + const orderedChannels = channelOrder + .map((key, index) => ({ + key, + enabled: channelEnabled(key, props), + order: index, + })) + .sort((a, b) => { + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; + return a.order - b.order; + }); + + return html` +
      + ${orderedChannels.map((channel) => + renderChannel(channel.key, props, { + whatsapp, + telegram, + discord, + googlechat, + slack, + signal, + imessage, + nostr, + channelAccounts: props.snapshot?.channelAccounts ?? null, + }), + )} +
      + +
      +
      +
      +
      Channel health
      +
      Channel status snapshots from the gateway.
      +
      +
      ${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}
      +
      + ${ + props.lastError + ? html`
      + ${props.lastError} +
      ` + : nothing + } +
      +${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
      +      
      +
      + `; +} + +function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKey[] { + if (snapshot?.channelMeta?.length) { + return snapshot.channelMeta.map((entry) => entry.id) as ChannelKey[]; + } + if (snapshot?.channelOrder?.length) { + return snapshot.channelOrder; + } + return ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage", "nostr"]; +} + +function renderChannel(key: ChannelKey, props: ChannelsProps, data: ChannelsChannelData) { + const accountCountLabel = renderChannelAccountCount(key, data.channelAccounts); + switch (key) { + case "whatsapp": + return renderWhatsAppCard({ + props, + whatsapp: data.whatsapp, + accountCountLabel, + }); + case "telegram": + return renderTelegramCard({ + props, + telegram: data.telegram, + telegramAccounts: data.channelAccounts?.telegram ?? [], + accountCountLabel, + }); + case "discord": + return renderDiscordCard({ + props, + discord: data.discord, + accountCountLabel, + }); + case "googlechat": + return renderGoogleChatCard({ + props, + googlechat: data.googlechat, + accountCountLabel, + }); + case "slack": + return renderSlackCard({ + props, + slack: data.slack, + accountCountLabel, + }); + case "signal": + return renderSignalCard({ + props, + signal: data.signal, + accountCountLabel, + }); + case "imessage": + return renderIMessageCard({ + props, + imessage: data.imessage, + accountCountLabel, + }); + case "nostr": { + const nostrAccounts = data.channelAccounts?.nostr ?? []; + const primaryAccount = nostrAccounts[0]; + const accountId = primaryAccount?.accountId ?? "default"; + const profile = + (primaryAccount as { profile?: NostrProfile | null } | undefined)?.profile ?? null; + const showForm = + props.nostrProfileAccountId === accountId ? props.nostrProfileFormState : null; + const profileFormCallbacks = showForm + ? { + onFieldChange: props.onNostrProfileFieldChange, + onSave: props.onNostrProfileSave, + onImport: props.onNostrProfileImport, + onCancel: props.onNostrProfileCancel, + onToggleAdvanced: props.onNostrProfileToggleAdvanced, + } + : null; + return renderNostrCard({ + props, + nostr: data.nostr, + nostrAccounts, + accountCountLabel, + profileFormState: showForm, + profileFormCallbacks, + onEditProfile: () => props.onNostrProfileEdit(accountId, profile), + }); + } + default: + return renderGenericChannelCard(key, props, data.channelAccounts ?? {}); + } +} + +function renderGenericChannelCard( + key: ChannelKey, + props: ChannelsProps, + channelAccounts: Record, +) { + const label = resolveChannelLabel(props.snapshot, key); + const status = props.snapshot?.channels?.[key] as Record | undefined; + const configured = typeof status?.configured === "boolean" ? status.configured : undefined; + const running = typeof status?.running === "boolean" ? status.running : undefined; + const connected = typeof status?.connected === "boolean" ? status.connected : undefined; + const lastError = typeof status?.lastError === "string" ? status.lastError : undefined; + const accounts = channelAccounts[key] ?? []; + const accountCountLabel = renderChannelAccountCount(key, channelAccounts); + + return html` +
      +
      ${label}
      +
      Channel status and configuration.
      + ${accountCountLabel} + + ${ + accounts.length > 0 + ? html` + + ` + : html` +
      +
      + Configured + ${configured == null ? "n/a" : configured ? "Yes" : "No"} +
      +
      + Running + ${running == null ? "n/a" : running ? "Yes" : "No"} +
      +
      + Connected + ${connected == null ? "n/a" : connected ? "Yes" : "No"} +
      +
      + ` + } + + ${ + lastError + ? html`
      + ${lastError} +
      ` + : nothing + } + + ${renderChannelConfigSection({ channelId: key, props })} +
      + `; +} + +function resolveChannelMetaMap( + snapshot: ChannelsStatusSnapshot | null, +): Record { + if (!snapshot?.channelMeta?.length) return {}; + return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry])); +} + +function resolveChannelLabel(snapshot: ChannelsStatusSnapshot | null, key: string): string { + const meta = resolveChannelMetaMap(snapshot)[key]; + return meta?.label ?? snapshot?.channelLabels?.[key] ?? key; +} + +const RECENT_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes + +function hasRecentActivity(account: ChannelAccountSnapshot): boolean { + if (!account.lastInboundAt) return false; + return Date.now() - account.lastInboundAt < RECENT_ACTIVITY_THRESHOLD_MS; +} + +function deriveRunningStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" { + if (account.running) return "Yes"; + // If we have recent inbound activity, the channel is effectively running + if (hasRecentActivity(account)) return "Active"; + return "No"; +} + +function deriveConnectedStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" | "n/a" { + if (account.connected === true) return "Yes"; + if (account.connected === false) return "No"; + // If connected is null/undefined but we have recent activity, show as active + if (hasRecentActivity(account)) return "Active"; + return "n/a"; +} + +function renderGenericAccount(account: ChannelAccountSnapshot) { + const runningStatus = deriveRunningStatus(account); + const connectedStatus = deriveConnectedStatus(account); + + return html` + + `; +} diff --git a/ui/src/ui/views/channels.types.ts b/ui/src/ui/views/channels.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa1a9094b55e76cb48cab99a32b66368bcc31a98 --- /dev/null +++ b/ui/src/ui/views/channels.types.ts @@ -0,0 +1,62 @@ +import type { + ChannelAccountSnapshot, + ChannelsStatusSnapshot, + ConfigUiHints, + DiscordStatus, + GoogleChatStatus, + IMessageStatus, + NostrProfile, + NostrStatus, + SignalStatus, + SlackStatus, + TelegramStatus, + WhatsAppStatus, +} from "../types"; +import type { NostrProfileFormState } from "./channels.nostr-profile-form"; + +export type ChannelKey = string; + +export type ChannelsProps = { + connected: boolean; + loading: boolean; + snapshot: ChannelsStatusSnapshot | null; + lastError: string | null; + lastSuccessAt: number | null; + whatsappMessage: string | null; + whatsappQrDataUrl: string | null; + whatsappConnected: boolean | null; + whatsappBusy: boolean; + configSchema: unknown | null; + configSchemaLoading: boolean; + configForm: Record | null; + configUiHints: ConfigUiHints; + configSaving: boolean; + configFormDirty: boolean; + nostrProfileFormState: NostrProfileFormState | null; + nostrProfileAccountId: string | null; + onRefresh: (probe: boolean) => void; + onWhatsAppStart: (force: boolean) => void; + onWhatsAppWait: () => void; + onWhatsAppLogout: () => void; + onConfigPatch: (path: Array, value: unknown) => void; + onConfigSave: () => void; + onConfigReload: () => void; + onNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void; + onNostrProfileCancel: () => void; + onNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void; + onNostrProfileSave: () => void; + onNostrProfileImport: () => void; + onNostrProfileToggleAdvanced: () => void; +}; + +export type ChannelsChannelData = { + whatsapp?: WhatsAppStatus; + telegram?: TelegramStatus; + discord?: DiscordStatus | null; + googlechat?: GoogleChatStatus | null; + slack?: SlackStatus | null; + signal?: SignalStatus | null; + imessage?: IMessageStatus | null; + nostr?: NostrStatus | null; + channelAccounts?: Record | null; +}; diff --git a/ui/src/ui/views/channels.whatsapp.ts b/ui/src/ui/views/channels.whatsapp.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad1fcf7a91c0ac563d238666084725872a0ab8f2 --- /dev/null +++ b/ui/src/ui/views/channels.whatsapp.ts @@ -0,0 +1,119 @@ +import { html, nothing } from "lit"; +import type { WhatsAppStatus } from "../types"; +import type { ChannelsProps } from "./channels.types"; +import { formatAgo } from "../format"; +import { renderChannelConfigSection } from "./channels.config"; +import { formatDuration } from "./channels.shared"; + +export function renderWhatsAppCard(params: { + props: ChannelsProps; + whatsapp?: WhatsAppStatus; + accountCountLabel: unknown; +}) { + const { props, whatsapp, accountCountLabel } = params; + + return html` +
      +
      WhatsApp
      +
      Link WhatsApp Web and monitor connection health.
      + ${accountCountLabel} + +
      +
      + Configured + ${whatsapp?.configured ? "Yes" : "No"} +
      +
      + Linked + ${whatsapp?.linked ? "Yes" : "No"} +
      +
      + Running + ${whatsapp?.running ? "Yes" : "No"} +
      +
      + Connected + ${whatsapp?.connected ? "Yes" : "No"} +
      +
      + Last connect + + ${whatsapp?.lastConnectedAt ? formatAgo(whatsapp.lastConnectedAt) : "n/a"} + +
      +
      + Last message + + ${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"} + +
      +
      + Auth age + + ${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"} + +
      +
      + + ${ + whatsapp?.lastError + ? html`
      + ${whatsapp.lastError} +
      ` + : nothing + } + + ${ + props.whatsappMessage + ? html`
      + ${props.whatsappMessage} +
      ` + : nothing + } + + ${ + props.whatsappQrDataUrl + ? html`
      + WhatsApp QR +
      ` + : nothing + } + +
      + + + + + +
      + + ${renderChannelConfigSection({ channelId: "whatsapp", props })} +
      + `; +} diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d121e99f6e0874d3b7eae3b8c5bf6af09c71d8a --- /dev/null +++ b/ui/src/ui/views/chat.test.ts @@ -0,0 +1,95 @@ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import type { SessionsListResult } from "../types"; +import { renderChat, type ChatProps } from "./chat"; + +function createSessions(): SessionsListResult { + return { + ts: 0, + path: "", + count: 0, + defaults: { model: null, contextTokens: null }, + sessions: [], + }; +} + +function createProps(overrides: Partial = {}): ChatProps { + return { + sessionKey: "main", + onSessionKeyChange: () => undefined, + thinkingLevel: null, + showThinking: false, + loading: false, + sending: false, + canAbort: false, + compactionStatus: null, + messages: [], + toolMessages: [], + stream: null, + streamStartedAt: null, + assistantAvatarUrl: null, + draft: "", + queue: [], + connected: true, + canSend: true, + disabledReason: null, + error: null, + sessions: createSessions(), + focusMode: false, + assistantName: "OpenClaw", + assistantAvatar: null, + onRefresh: () => undefined, + onToggleFocusMode: () => undefined, + onDraftChange: () => undefined, + onSend: () => undefined, + onQueueRemove: () => undefined, + onNewSession: () => undefined, + ...overrides, + }; +} + +describe("chat view", () => { + it("shows a stop button when aborting is available", () => { + const container = document.createElement("div"); + const onAbort = vi.fn(); + render( + renderChat( + createProps({ + canAbort: true, + onAbort, + }), + ), + container, + ); + + const stopButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Stop", + ); + expect(stopButton).not.toBeUndefined(); + stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onAbort).toHaveBeenCalledTimes(1); + expect(container.textContent).not.toContain("New session"); + }); + + it("shows a new session button when aborting is unavailable", () => { + const container = document.createElement("div"); + const onNewSession = vi.fn(); + render( + renderChat( + createProps({ + canAbort: false, + onNewSession, + }), + ), + container, + ); + + const newSessionButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "New session", + ); + expect(newSessionButton).not.toBeUndefined(); + newSessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onNewSession).toHaveBeenCalledTimes(1); + expect(container.textContent).not.toContain("Stop"); + }); +}); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts new file mode 100644 index 0000000000000000000000000000000000000000..070fb76a8c3d4eba6c0891edeee186b9a500c081 --- /dev/null +++ b/ui/src/ui/views/chat.ts @@ -0,0 +1,487 @@ +import { html, nothing } from "lit"; +import { ref } from "lit/directives/ref.js"; +import { repeat } from "lit/directives/repeat.js"; +import type { SessionsListResult } from "../types"; +import type { ChatItem, MessageGroup } from "../types/chat-types"; +import type { ChatAttachment, ChatQueueItem } from "../ui-types"; +import { + renderMessageGroup, + renderReadingIndicatorGroup, + renderStreamingGroup, +} from "../chat/grouped-render"; +import { normalizeMessage, normalizeRoleForGrouping } from "../chat/message-normalizer"; +import { icons } from "../icons"; +import { renderMarkdownSidebar } from "./markdown-sidebar"; +import "../components/resizable-divider"; + +export type CompactionIndicatorStatus = { + active: boolean; + startedAt: number | null; + completedAt: number | null; +}; + +export type ChatProps = { + sessionKey: string; + onSessionKeyChange: (next: string) => void; + thinkingLevel: string | null; + showThinking: boolean; + loading: boolean; + sending: boolean; + canAbort?: boolean; + compactionStatus?: CompactionIndicatorStatus | null; + messages: unknown[]; + toolMessages: unknown[]; + stream: string | null; + streamStartedAt: number | null; + assistantAvatarUrl?: string | null; + draft: string; + queue: ChatQueueItem[]; + connected: boolean; + canSend: boolean; + disabledReason: string | null; + error: string | null; + sessions: SessionsListResult | null; + // Focus mode + focusMode: boolean; + // Sidebar state + sidebarOpen?: boolean; + sidebarContent?: string | null; + sidebarError?: string | null; + splitRatio?: number; + assistantName: string; + assistantAvatar: string | null; + // Image attachments + attachments?: ChatAttachment[]; + onAttachmentsChange?: (attachments: ChatAttachment[]) => void; + // Event handlers + onRefresh: () => void; + onToggleFocusMode: () => void; + onDraftChange: (next: string) => void; + onSend: () => void; + onAbort?: () => void; + onQueueRemove: (id: string) => void; + onNewSession: () => void; + onOpenSidebar?: (content: string) => void; + onCloseSidebar?: () => void; + onSplitRatioChange?: (ratio: number) => void; + onChatScroll?: (event: Event) => void; +}; + +const COMPACTION_TOAST_DURATION_MS = 5000; + +function adjustTextareaHeight(el: HTMLTextAreaElement) { + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; +} + +function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) { + if (!status) return nothing; + + // Show "compacting..." while active + if (status.active) { + return html` +
      + ${icons.loader} Compacting context... +
      + `; + } + + // Show "compaction complete" briefly after completion + if (status.completedAt) { + const elapsed = Date.now() - status.completedAt; + if (elapsed < COMPACTION_TOAST_DURATION_MS) { + return html` +
      + ${icons.check} Context compacted +
      + `; + } + } + + return nothing; +} + +function generateAttachmentId(): string { + return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +function handlePaste(e: ClipboardEvent, props: ChatProps) { + const items = e.clipboardData?.items; + if (!items || !props.onAttachmentsChange) return; + + const imageItems: DataTransferItem[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type.startsWith("image/")) { + imageItems.push(item); + } + } + + if (imageItems.length === 0) return; + + e.preventDefault(); + + for (const item of imageItems) { + const file = item.getAsFile(); + if (!file) continue; + + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + const newAttachment: ChatAttachment = { + id: generateAttachmentId(), + dataUrl, + mimeType: file.type, + }; + const current = props.attachments ?? []; + props.onAttachmentsChange?.([...current, newAttachment]); + }; + reader.readAsDataURL(file); + } +} + +function renderAttachmentPreview(props: ChatProps) { + const attachments = props.attachments ?? []; + if (attachments.length === 0) return nothing; + + return html` +
      + ${attachments.map( + (att) => html` +
      + Attachment preview + +
      + `, + )} +
      + `; +} + +export function renderChat(props: ChatProps) { + const canCompose = props.connected; + const isBusy = props.sending || props.stream !== null; + const canAbort = Boolean(props.canAbort && props.onAbort); + const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey); + const reasoningLevel = activeSession?.reasoningLevel ?? "off"; + const showReasoning = props.showThinking && reasoningLevel !== "off"; + const assistantIdentity = { + name: props.assistantName, + avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null, + }; + + const hasAttachments = (props.attachments?.length ?? 0) > 0; + const composePlaceholder = props.connected + ? hasAttachments + ? "Add a message or paste more images..." + : "Message (↩ to send, Shift+↩ for line breaks, paste images)" + : "Connect to the gateway to start chatting…"; + + const splitRatio = props.splitRatio ?? 0.6; + const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar); + const thread = html` +
      + ${ + props.loading + ? html` +
      Loading chat…
      + ` + : nothing + } + ${repeat( + buildChatItems(props), + (item) => item.key, + (item) => { + if (item.kind === "reading-indicator") { + return renderReadingIndicatorGroup(assistantIdentity); + } + + if (item.kind === "stream") { + return renderStreamingGroup( + item.text, + item.startedAt, + props.onOpenSidebar, + assistantIdentity, + ); + } + + if (item.kind === "group") { + return renderMessageGroup(item, { + onOpenSidebar: props.onOpenSidebar, + showReasoning, + assistantName: props.assistantName, + assistantAvatar: assistantIdentity.avatar, + }); + } + + return nothing; + }, + )} +
      + `; + + return html` +
      + ${props.disabledReason ? html`
      ${props.disabledReason}
      ` : nothing} + + ${props.error ? html`
      ${props.error}
      ` : nothing} + + ${renderCompactionIndicator(props.compactionStatus)} + + ${ + props.focusMode + ? html` + + ` + : nothing + } + +
      +
      + ${thread} +
      + + ${ + sidebarOpen + ? html` + props.onSplitRatioChange?.(e.detail.splitRatio)} + > +
      + ${renderMarkdownSidebar({ + content: props.sidebarContent ?? null, + error: props.sidebarError ?? null, + onClose: props.onCloseSidebar!, + onViewRawText: () => { + if (!props.sidebarContent || !props.onOpenSidebar) return; + props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\n\`\`\``); + }, + })} +
      + ` + : nothing + } +
      + + ${ + props.queue.length + ? html` +
      +
      Queued (${props.queue.length})
      +
      + ${props.queue.map( + (item) => html` +
      +
      + ${ + item.text || + (item.attachments?.length ? `Image (${item.attachments.length})` : "") + } +
      + +
      + `, + )} +
      +
      + ` + : nothing + } + +
      + ${renderAttachmentPreview(props)} +
      + +
      + + +
      +
      +
      +
      + `; +} + +const CHAT_HISTORY_RENDER_LIMIT = 200; + +function groupMessages(items: ChatItem[]): Array { + const result: Array = []; + let currentGroup: MessageGroup | null = null; + + for (const item of items) { + if (item.kind !== "message") { + if (currentGroup) { + result.push(currentGroup); + currentGroup = null; + } + result.push(item); + continue; + } + + const normalized = normalizeMessage(item.message); + const role = normalizeRoleForGrouping(normalized.role); + const timestamp = normalized.timestamp || Date.now(); + + if (!currentGroup || currentGroup.role !== role) { + if (currentGroup) result.push(currentGroup); + currentGroup = { + kind: "group", + key: `group:${role}:${item.key}`, + role, + messages: [{ message: item.message, key: item.key }], + timestamp, + isStreaming: false, + }; + } else { + currentGroup.messages.push({ message: item.message, key: item.key }); + } + } + + if (currentGroup) result.push(currentGroup); + return result; +} + +function buildChatItems(props: ChatProps): Array { + const items: ChatItem[] = []; + const history = Array.isArray(props.messages) ? props.messages : []; + const tools = Array.isArray(props.toolMessages) ? props.toolMessages : []; + const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT); + if (historyStart > 0) { + items.push({ + kind: "message", + key: "chat:history:notice", + message: { + role: "system", + content: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${historyStart} hidden).`, + timestamp: Date.now(), + }, + }); + } + for (let i = historyStart; i < history.length; i++) { + const msg = history[i]; + const normalized = normalizeMessage(msg); + + if (!props.showThinking && normalized.role.toLowerCase() === "toolresult") { + continue; + } + + items.push({ + kind: "message", + key: messageKey(msg, i), + message: msg, + }); + } + if (props.showThinking) { + for (let i = 0; i < tools.length; i++) { + items.push({ + kind: "message", + key: messageKey(tools[i], i + history.length), + message: tools[i], + }); + } + } + + if (props.stream !== null) { + const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`; + if (props.stream.trim().length > 0) { + items.push({ + kind: "stream", + key, + text: props.stream, + startedAt: props.streamStartedAt ?? Date.now(), + }); + } else { + items.push({ kind: "reading-indicator", key }); + } + } + + return groupMessages(items); +} + +function messageKey(message: unknown, index: number): string { + const m = message as Record; + const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : ""; + if (toolCallId) return `tool:${toolCallId}`; + const id = typeof m.id === "string" ? m.id : ""; + if (id) return `msg:${id}`; + const messageId = typeof m.messageId === "string" ? m.messageId : ""; + if (messageId) return `msg:${messageId}`; + const timestamp = typeof m.timestamp === "number" ? m.timestamp : null; + const role = typeof m.role === "string" ? m.role : "unknown"; + if (timestamp != null) return `msg:${role}:${timestamp}:${index}`; + return `msg:${role}:${index}`; +} diff --git a/ui/src/ui/views/config-form.analyze.ts b/ui/src/ui/views/config-form.analyze.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6451806c16ef140b7b1f8c40692d9456ec739be --- /dev/null +++ b/ui/src/ui/views/config-form.analyze.ts @@ -0,0 +1,186 @@ +import { pathKey, schemaType, type JsonSchema } from "./config-form.shared"; + +export type ConfigSchemaAnalysis = { + schema: JsonSchema | null; + unsupportedPaths: string[]; +}; + +const META_KEYS = new Set(["title", "description", "default", "nullable"]); + +function isAnySchema(schema: JsonSchema): boolean { + const keys = Object.keys(schema ?? {}).filter((key) => !META_KEYS.has(key)); + return keys.length === 0; +} + +function normalizeEnum(values: unknown[]): { enumValues: unknown[]; nullable: boolean } { + const filtered = values.filter((value) => value != null); + const nullable = filtered.length !== values.length; + const enumValues: unknown[] = []; + for (const value of filtered) { + if (!enumValues.some((existing) => Object.is(existing, value))) { + enumValues.push(value); + } + } + return { enumValues, nullable }; +} + +export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis { + if (!raw || typeof raw !== "object") { + return { schema: null, unsupportedPaths: [""] }; + } + return normalizeSchemaNode(raw as JsonSchema, []); +} + +function normalizeSchemaNode( + schema: JsonSchema, + path: Array, +): ConfigSchemaAnalysis { + const unsupported = new Set(); + const normalized: JsonSchema = { ...schema }; + const pathLabel = pathKey(path) || ""; + + if (schema.anyOf || schema.oneOf || schema.allOf) { + const union = normalizeUnion(schema, path); + if (union) return union; + return { schema, unsupportedPaths: [pathLabel] }; + } + + const nullable = Array.isArray(schema.type) && schema.type.includes("null"); + const type = + schemaType(schema) ?? (schema.properties || schema.additionalProperties ? "object" : undefined); + normalized.type = type ?? schema.type; + normalized.nullable = nullable || schema.nullable; + + if (normalized.enum) { + const { enumValues, nullable: enumNullable } = normalizeEnum(normalized.enum); + normalized.enum = enumValues; + if (enumNullable) normalized.nullable = true; + if (enumValues.length === 0) unsupported.add(pathLabel); + } + + if (type === "object") { + const properties = schema.properties ?? {}; + const normalizedProps: Record = {}; + for (const [key, value] of Object.entries(properties)) { + const res = normalizeSchemaNode(value, [...path, key]); + if (res.schema) normalizedProps[key] = res.schema; + for (const entry of res.unsupportedPaths) unsupported.add(entry); + } + normalized.properties = normalizedProps; + + if (schema.additionalProperties === true) { + unsupported.add(pathLabel); + } else if (schema.additionalProperties === false) { + normalized.additionalProperties = false; + } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + if (!isAnySchema(schema.additionalProperties as JsonSchema)) { + const res = normalizeSchemaNode(schema.additionalProperties as JsonSchema, [...path, "*"]); + normalized.additionalProperties = res.schema ?? (schema.additionalProperties as JsonSchema); + if (res.unsupportedPaths.length > 0) unsupported.add(pathLabel); + } + } + } else if (type === "array") { + const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items; + if (!itemsSchema) { + unsupported.add(pathLabel); + } else { + const res = normalizeSchemaNode(itemsSchema, [...path, "*"]); + normalized.items = res.schema ?? itemsSchema; + if (res.unsupportedPaths.length > 0) unsupported.add(pathLabel); + } + } else if ( + type !== "string" && + type !== "number" && + type !== "integer" && + type !== "boolean" && + !normalized.enum + ) { + unsupported.add(pathLabel); + } + + return { + schema: normalized, + unsupportedPaths: Array.from(unsupported), + }; +} + +function normalizeUnion( + schema: JsonSchema, + path: Array, +): ConfigSchemaAnalysis | null { + if (schema.allOf) return null; + const union = schema.anyOf ?? schema.oneOf; + if (!union) return null; + + const literals: unknown[] = []; + const remaining: JsonSchema[] = []; + let nullable = false; + + for (const entry of union) { + if (!entry || typeof entry !== "object") return null; + if (Array.isArray(entry.enum)) { + const { enumValues, nullable: enumNullable } = normalizeEnum(entry.enum); + literals.push(...enumValues); + if (enumNullable) nullable = true; + continue; + } + if ("const" in entry) { + if (entry.const == null) { + nullable = true; + continue; + } + literals.push(entry.const); + continue; + } + if (schemaType(entry) === "null") { + nullable = true; + continue; + } + remaining.push(entry); + } + + if (literals.length > 0 && remaining.length === 0) { + const unique: unknown[] = []; + for (const value of literals) { + if (!unique.some((existing) => Object.is(existing, value))) { + unique.push(value); + } + } + return { + schema: { + ...schema, + enum: unique, + nullable, + anyOf: undefined, + oneOf: undefined, + allOf: undefined, + }, + unsupportedPaths: [], + }; + } + + if (remaining.length === 1) { + const res = normalizeSchemaNode(remaining[0], path); + if (res.schema) { + res.schema.nullable = nullable || res.schema.nullable; + } + return res; + } + + const primitiveTypes = ["string", "number", "integer", "boolean"]; + if ( + remaining.length > 0 && + literals.length === 0 && + remaining.every((entry) => entry.type && primitiveTypes.includes(String(entry.type))) + ) { + return { + schema: { + ...schema, + nullable, + }, + unsupportedPaths: [], + }; + } + + return null; +} diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts new file mode 100644 index 0000000000000000000000000000000000000000..768db4508c1e5fcf088e08a91ee8bfb9ebfe7e9f --- /dev/null +++ b/ui/src/ui/views/config-form.node.ts @@ -0,0 +1,778 @@ +import { html, nothing, type TemplateResult } from "lit"; +import type { ConfigUiHints } from "../types"; +import { + defaultValue, + hintForPath, + humanize, + isSensitivePath, + pathKey, + schemaType, + type JsonSchema, +} from "./config-form.shared"; + +const META_KEYS = new Set(["title", "description", "default", "nullable"]); + +function isAnySchema(schema: JsonSchema): boolean { + const keys = Object.keys(schema ?? {}).filter((key) => !META_KEYS.has(key)); + return keys.length === 0; +} + +function jsonValue(value: unknown): string { + if (value === undefined) return ""; + try { + return JSON.stringify(value, null, 2) ?? ""; + } catch { + return ""; + } +} + +// SVG Icons as template literals +const icons = { + chevronDown: html` + + + + `, + plus: html` + + + + + `, + minus: html` + + + + `, + trash: html` + + + + + `, + edit: html` + + + + + `, +}; + +export function renderNode(params: { + schema: JsonSchema; + value: unknown; + path: Array; + hints: ConfigUiHints; + unsupported: Set; + disabled: boolean; + showLabel?: boolean; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult | typeof nothing { + const { schema, value, path, hints, unsupported, disabled, onPatch } = params; + const showLabel = params.showLabel ?? true; + const type = schemaType(schema); + const hint = hintForPath(path, hints); + const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); + const help = hint?.help ?? schema.description; + const key = pathKey(path); + + if (unsupported.has(key)) { + return html`
      +
      ${label}
      +
      Unsupported schema node. Use Raw mode.
      +
      `; + } + + // Handle anyOf/oneOf unions + if (schema.anyOf || schema.oneOf) { + const variants = schema.anyOf ?? schema.oneOf ?? []; + const nonNull = variants.filter( + (v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))), + ); + + if (nonNull.length === 1) { + return renderNode({ ...params, schema: nonNull[0] }); + } + + // Check if it's a set of literal values (enum-like) + const extractLiteral = (v: JsonSchema): unknown | undefined => { + if (v.const !== undefined) return v.const; + if (v.enum && v.enum.length === 1) return v.enum[0]; + return undefined; + }; + const literals = nonNull.map(extractLiteral); + const allLiterals = literals.every((v) => v !== undefined); + + if (allLiterals && literals.length > 0 && literals.length <= 5) { + // Use segmented control for small sets + const resolvedValue = value ?? schema.default; + return html` +
      + ${showLabel ? html`` : nothing} + ${help ? html`
      ${help}
      ` : nothing} +
      + ${literals.map( + (lit, idx) => html` + + `, + )} +
      +
      + `; + } + + if (allLiterals && literals.length > 5) { + // Use dropdown for larger sets + return renderSelect({ ...params, options: literals, value: value ?? schema.default }); + } + + // Handle mixed primitive types + const primitiveTypes = new Set(nonNull.map((variant) => schemaType(variant)).filter(Boolean)); + const normalizedTypes = new Set( + [...primitiveTypes].map((v) => (v === "integer" ? "number" : v)), + ); + + if ([...normalizedTypes].every((v) => ["string", "number", "boolean"].includes(v as string))) { + const hasString = normalizedTypes.has("string"); + const hasNumber = normalizedTypes.has("number"); + const hasBoolean = normalizedTypes.has("boolean"); + + if (hasBoolean && normalizedTypes.size === 1) { + return renderNode({ + ...params, + schema: { ...schema, type: "boolean", anyOf: undefined, oneOf: undefined }, + }); + } + + if (hasString || hasNumber) { + return renderTextInput({ + ...params, + inputType: hasNumber && !hasString ? "number" : "text", + }); + } + } + } + + // Enum - use segmented for small, dropdown for large + if (schema.enum) { + const options = schema.enum; + if (options.length <= 5) { + const resolvedValue = value ?? schema.default; + return html` +
      + ${showLabel ? html`` : nothing} + ${help ? html`
      ${help}
      ` : nothing} +
      + ${options.map( + (opt) => html` + + `, + )} +
      +
      + `; + } + return renderSelect({ ...params, options, value: value ?? schema.default }); + } + + // Object type - collapsible section + if (type === "object") { + return renderObject(params); + } + + // Array type + if (type === "array") { + return renderArray(params); + } + + // Boolean - toggle row + if (type === "boolean") { + const displayValue = + typeof value === "boolean" + ? value + : typeof schema.default === "boolean" + ? schema.default + : false; + return html` + + `; + } + + // Number/Integer + if (type === "number" || type === "integer") { + return renderNumberInput(params); + } + + // String + if (type === "string") { + return renderTextInput({ ...params, inputType: "text" }); + } + + // Fallback + return html` +
      +
      ${label}
      +
      Unsupported type: ${type}. Use Raw mode.
      +
      + `; +} + +function renderTextInput(params: { + schema: JsonSchema; + value: unknown; + path: Array; + hints: ConfigUiHints; + disabled: boolean; + showLabel?: boolean; + inputType: "text" | "number"; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult { + const { schema, value, path, hints, disabled, onPatch, inputType } = params; + const showLabel = params.showLabel ?? true; + const hint = hintForPath(path, hints); + const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); + const help = hint?.help ?? schema.description; + const isSensitive = hint?.sensitive ?? isSensitivePath(path); + const placeholder = + hint?.placeholder ?? + (isSensitive ? "••••" : schema.default !== undefined ? `Default: ${schema.default}` : ""); + const displayValue = value ?? ""; + + return html` +
      + ${showLabel ? html`` : nothing} + ${help ? html`
      ${help}
      ` : nothing} +
      + { + const raw = (e.target as HTMLInputElement).value; + if (inputType === "number") { + if (raw.trim() === "") { + onPatch(path, undefined); + return; + } + const parsed = Number(raw); + onPatch(path, Number.isNaN(parsed) ? raw : parsed); + return; + } + onPatch(path, raw); + }} + @change=${(e: Event) => { + if (inputType === "number") return; + const raw = (e.target as HTMLInputElement).value; + onPatch(path, raw.trim()); + }} + /> + ${ + schema.default !== undefined + ? html` + + ` + : nothing + } +
      +
      + `; +} + +function renderNumberInput(params: { + schema: JsonSchema; + value: unknown; + path: Array; + hints: ConfigUiHints; + disabled: boolean; + showLabel?: boolean; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult { + const { schema, value, path, hints, disabled, onPatch } = params; + const showLabel = params.showLabel ?? true; + const hint = hintForPath(path, hints); + const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); + const help = hint?.help ?? schema.description; + const displayValue = value ?? schema.default ?? ""; + const numValue = typeof displayValue === "number" ? displayValue : 0; + + return html` +
      + ${showLabel ? html`` : nothing} + ${help ? html`
      ${help}
      ` : nothing} +
      + + { + const raw = (e.target as HTMLInputElement).value; + const parsed = raw === "" ? undefined : Number(raw); + onPatch(path, parsed); + }} + /> + +
      +
      + `; +} + +function renderSelect(params: { + schema: JsonSchema; + value: unknown; + path: Array; + hints: ConfigUiHints; + disabled: boolean; + showLabel?: boolean; + options: unknown[]; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult { + const { schema, value, path, hints, disabled, options, onPatch } = params; + const showLabel = params.showLabel ?? true; + const hint = hintForPath(path, hints); + const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); + const help = hint?.help ?? schema.description; + const resolvedValue = value ?? schema.default; + const currentIndex = options.findIndex( + (opt) => opt === resolvedValue || String(opt) === String(resolvedValue), + ); + const unset = "__unset__"; + + return html` +
      + ${showLabel ? html`` : nothing} + ${help ? html`
      ${help}
      ` : nothing} + +
      + `; +} + +function renderObject(params: { + schema: JsonSchema; + value: unknown; + path: Array; + hints: ConfigUiHints; + unsupported: Set; + disabled: boolean; + showLabel?: boolean; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult { + const { schema, value, path, hints, unsupported, disabled, onPatch } = params; + const showLabel = params.showLabel ?? true; + const hint = hintForPath(path, hints); + const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); + const help = hint?.help ?? schema.description; + + const fallback = value ?? schema.default; + const obj = + fallback && typeof fallback === "object" && !Array.isArray(fallback) + ? (fallback as Record) + : {}; + const props = schema.properties ?? {}; + const entries = Object.entries(props); + + // Sort by hint order + const sorted = entries.sort((a, b) => { + const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0; + const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0; + if (orderA !== orderB) return orderA - orderB; + return a[0].localeCompare(b[0]); + }); + + const reserved = new Set(Object.keys(props)); + const additional = schema.additionalProperties; + const allowExtra = Boolean(additional) && typeof additional === "object"; + + // For top-level, don't wrap in collapsible + if (path.length === 1) { + return html` +
      + ${sorted.map(([propKey, node]) => + renderNode({ + schema: node, + value: obj[propKey], + path: [...path, propKey], + hints, + unsupported, + disabled, + onPatch, + }), + )} + ${ + allowExtra + ? renderMapField({ + schema: additional as JsonSchema, + value: obj, + path, + hints, + unsupported, + disabled, + reservedKeys: reserved, + onPatch, + }) + : nothing + } +
      + `; + } + + // Nested objects get collapsible treatment + return html` +
      + + ${label} + ${icons.chevronDown} + + ${help ? html`
      ${help}
      ` : nothing} +
      + ${sorted.map(([propKey, node]) => + renderNode({ + schema: node, + value: obj[propKey], + path: [...path, propKey], + hints, + unsupported, + disabled, + onPatch, + }), + )} + ${ + allowExtra + ? renderMapField({ + schema: additional as JsonSchema, + value: obj, + path, + hints, + unsupported, + disabled, + reservedKeys: reserved, + onPatch, + }) + : nothing + } +
      +
      + `; +} + +function renderArray(params: { + schema: JsonSchema; + value: unknown; + path: Array; + hints: ConfigUiHints; + unsupported: Set; + disabled: boolean; + showLabel?: boolean; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult { + const { schema, value, path, hints, unsupported, disabled, onPatch } = params; + const showLabel = params.showLabel ?? true; + const hint = hintForPath(path, hints); + const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); + const help = hint?.help ?? schema.description; + + const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items; + if (!itemsSchema) { + return html` +
      +
      ${label}
      +
      Unsupported array schema. Use Raw mode.
      +
      + `; + } + + const arr = Array.isArray(value) ? value : Array.isArray(schema.default) ? schema.default : []; + + return html` +
      +
      + ${showLabel ? html`${label}` : nothing} + ${arr.length} item${arr.length !== 1 ? "s" : ""} + +
      + ${help ? html`
      ${help}
      ` : nothing} + + ${ + arr.length === 0 + ? html` +
      No items yet. Click "Add" to create one.
      + ` + : html` +
      + ${arr.map( + (item, idx) => html` +
      +
      + #${idx + 1} + +
      +
      + ${renderNode({ + schema: itemsSchema, + value: item, + path: [...path, idx], + hints, + unsupported, + disabled, + showLabel: false, + onPatch, + })} +
      +
      + `, + )} +
      + ` + } +
      + `; +} + +function renderMapField(params: { + schema: JsonSchema; + value: Record; + path: Array; + hints: ConfigUiHints; + unsupported: Set; + disabled: boolean; + reservedKeys: Set; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult { + const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } = params; + const anySchema = isAnySchema(schema); + const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key)); + + return html` +
      +
      + Custom entries + +
      + + ${ + entries.length === 0 + ? html` +
      No custom entries.
      + ` + : html` +
      + ${entries.map(([key, entryValue]) => { + const valuePath = [...path, key]; + const fallback = jsonValue(entryValue); + return html` +
      +
      + { + const nextKey = (e.target as HTMLInputElement).value.trim(); + if (!nextKey || nextKey === key) return; + const next = { ...(value ?? {}) }; + if (nextKey in next) return; + next[nextKey] = next[key]; + delete next[key]; + onPatch(path, next); + }} + /> +
      +
      + ${ + anySchema + ? html` + + ` + : renderNode({ + schema, + value: entryValue, + path: valuePath, + hints, + unsupported, + disabled, + showLabel: false, + onPatch, + }) + } +
      + +
      + `; + })} +
      + ` + } +
      + `; +} diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec6eb5dd47ebcf74f282b74c03f53909fa7198c3 --- /dev/null +++ b/ui/src/ui/views/config-form.render.ts @@ -0,0 +1,472 @@ +import { html, nothing } from "lit"; +import type { ConfigUiHints } from "../types"; +import { icons } from "../icons"; +import { renderNode } from "./config-form.node"; +import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared"; + +export type ConfigFormProps = { + schema: JsonSchema | null; + uiHints: ConfigUiHints; + value: Record | null; + disabled?: boolean; + unsupportedPaths?: string[]; + searchQuery?: string; + activeSection?: string | null; + activeSubsection?: string | null; + onPatch: (path: Array, value: unknown) => void; +}; + +// SVG Icons for section cards (Lucide-style) +const sectionIcons = { + env: html` + + + + + `, + update: html` + + + + + + `, + agents: html` + + + + + + `, + auth: html` + + + + + `, + channels: html` + + + + `, + messages: html` + + + + + `, + commands: html` + + + + + `, + hooks: html` + + + + + `, + skills: html` + + + + `, + tools: html` + + + + `, + gateway: html` + + + + + + `, + wizard: html` + + + + + + + + + + + + `, + // Additional sections + meta: html` + + + + + `, + logging: html` + + + + + + + + `, + browser: html` + + + + + + + + `, + ui: html` + + + + + + `, + models: html` + + + + + + `, + bindings: html` + + + + + + + `, + broadcast: html` + + + + + + + + `, + audio: html` + + + + + + `, + session: html` + + + + + + + `, + cron: html` + + + + + `, + web: html` + + + + + + `, + discovery: html` + + + + + `, + canvasHost: html` + + + + + + `, + talk: html` + + + + + + + `, + plugins: html` + + + + + + + + + + + `, + default: html` + + + + + `, +}; + +// Section metadata +export const SECTION_META: Record = { + env: { + label: "Environment Variables", + description: "Environment variables passed to the gateway process", + }, + update: { label: "Updates", description: "Auto-update settings and release channel" }, + agents: { label: "Agents", description: "Agent configurations, models, and identities" }, + auth: { label: "Authentication", description: "API keys and authentication profiles" }, + channels: { + label: "Channels", + description: "Messaging channels (Telegram, Discord, Slack, etc.)", + }, + messages: { label: "Messages", description: "Message handling and routing settings" }, + commands: { label: "Commands", description: "Custom slash commands" }, + hooks: { label: "Hooks", description: "Webhooks and event hooks" }, + skills: { label: "Skills", description: "Skill packs and capabilities" }, + tools: { label: "Tools", description: "Tool configurations (browser, search, etc.)" }, + gateway: { label: "Gateway", description: "Gateway server settings (port, auth, binding)" }, + wizard: { label: "Setup Wizard", description: "Setup wizard state and history" }, + // Additional sections + meta: { label: "Metadata", description: "Gateway metadata and version information" }, + logging: { label: "Logging", description: "Log levels and output configuration" }, + browser: { label: "Browser", description: "Browser automation settings" }, + ui: { label: "UI", description: "User interface preferences" }, + models: { label: "Models", description: "AI model configurations and providers" }, + bindings: { label: "Bindings", description: "Key bindings and shortcuts" }, + broadcast: { label: "Broadcast", description: "Broadcast and notification settings" }, + audio: { label: "Audio", description: "Audio input/output settings" }, + session: { label: "Session", description: "Session management and persistence" }, + cron: { label: "Cron", description: "Scheduled tasks and automation" }, + web: { label: "Web", description: "Web server and API settings" }, + discovery: { label: "Discovery", description: "Service discovery and networking" }, + canvasHost: { label: "Canvas Host", description: "Canvas rendering and display" }, + talk: { label: "Talk", description: "Voice and speech settings" }, + plugins: { label: "Plugins", description: "Plugin management and extensions" }, +}; + +function getSectionIcon(key: string) { + return sectionIcons[key as keyof typeof sectionIcons] ?? sectionIcons.default; +} + +function matchesSearch(key: string, schema: JsonSchema, query: string): boolean { + if (!query) return true; + const q = query.toLowerCase(); + const meta = SECTION_META[key]; + + // Check key name + if (key.toLowerCase().includes(q)) return true; + + // Check label and description + if (meta) { + if (meta.label.toLowerCase().includes(q)) return true; + if (meta.description.toLowerCase().includes(q)) return true; + } + + return schemaMatches(schema, q); +} + +function schemaMatches(schema: JsonSchema, query: string): boolean { + if (schema.title?.toLowerCase().includes(query)) return true; + if (schema.description?.toLowerCase().includes(query)) return true; + if (schema.enum?.some((value) => String(value).toLowerCase().includes(query))) return true; + + if (schema.properties) { + for (const [propKey, propSchema] of Object.entries(schema.properties)) { + if (propKey.toLowerCase().includes(query)) return true; + if (schemaMatches(propSchema, query)) return true; + } + } + + if (schema.items) { + const items = Array.isArray(schema.items) ? schema.items : [schema.items]; + for (const item of items) { + if (item && schemaMatches(item, query)) return true; + } + } + + if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + if (schemaMatches(schema.additionalProperties, query)) return true; + } + + const unions = schema.anyOf ?? schema.oneOf ?? schema.allOf; + if (unions) { + for (const entry of unions) { + if (entry && schemaMatches(entry, query)) return true; + } + } + + return false; +} + +export function renderConfigForm(props: ConfigFormProps) { + if (!props.schema) { + return html` +
      Schema unavailable.
      + `; + } + const schema = props.schema; + const value = props.value ?? {}; + if (schemaType(schema) !== "object" || !schema.properties) { + return html` +
      Unsupported schema. Use Raw.
      + `; + } + const unsupported = new Set(props.unsupportedPaths ?? []); + const properties = schema.properties; + const searchQuery = props.searchQuery ?? ""; + const activeSection = props.activeSection; + const activeSubsection = props.activeSubsection ?? null; + + const entries = Object.entries(properties).sort((a, b) => { + const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 50; + const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 50; + if (orderA !== orderB) return orderA - orderB; + return a[0].localeCompare(b[0]); + }); + + const filteredEntries = entries.filter(([key, node]) => { + if (activeSection && key !== activeSection) return false; + if (searchQuery && !matchesSearch(key, node, searchQuery)) return false; + return true; + }); + + let subsectionContext: { sectionKey: string; subsectionKey: string; schema: JsonSchema } | null = + null; + if (activeSection && activeSubsection && filteredEntries.length === 1) { + const sectionSchema = filteredEntries[0]?.[1]; + if ( + sectionSchema && + schemaType(sectionSchema) === "object" && + sectionSchema.properties && + sectionSchema.properties[activeSubsection] + ) { + subsectionContext = { + sectionKey: activeSection, + subsectionKey: activeSubsection, + schema: sectionSchema.properties[activeSubsection], + }; + } + } + + if (filteredEntries.length === 0) { + return html` +
      +
      ${icons.search}
      +
      + ${searchQuery ? `No settings match "${searchQuery}"` : "No settings in this section"} +
      +
      + `; + } + + return html` +
      + ${ + subsectionContext + ? (() => { + const { sectionKey, subsectionKey, schema: node } = subsectionContext; + const hint = hintForPath([sectionKey, subsectionKey], props.uiHints); + const label = hint?.label ?? node.title ?? humanize(subsectionKey); + const description = hint?.help ?? node.description ?? ""; + const sectionValue = (value as Record)[sectionKey]; + const scopedValue = + sectionValue && typeof sectionValue === "object" + ? (sectionValue as Record)[subsectionKey] + : undefined; + const id = `config-section-${sectionKey}-${subsectionKey}`; + return html` +
      +
      + ${getSectionIcon(sectionKey)} +
      +

      ${label}

      + ${ + description + ? html`

      ${description}

      ` + : nothing + } +
      +
      +
      + ${renderNode({ + schema: node, + value: scopedValue, + path: [sectionKey, subsectionKey], + hints: props.uiHints, + unsupported, + disabled: props.disabled ?? false, + showLabel: false, + onPatch: props.onPatch, + })} +
      +
      + `; + })() + : filteredEntries.map(([key, node]) => { + const meta = SECTION_META[key] ?? { + label: key.charAt(0).toUpperCase() + key.slice(1), + description: node.description ?? "", + }; + + return html` +
      +
      + ${getSectionIcon(key)} +
      +

      ${meta.label}

      + ${ + meta.description + ? html`

      ${meta.description}

      ` + : nothing + } +
      +
      +
      + ${renderNode({ + schema: node, + value: (value as Record)[key], + path: [key], + hints: props.uiHints, + unsupported, + disabled: props.disabled ?? false, + showLabel: false, + onPatch: props.onPatch, + })} +
      +
      + `; + }) + } +
      + `; +} diff --git a/ui/src/ui/views/config-form.shared.ts b/ui/src/ui/views/config-form.shared.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6a8e241608aa430567f70ba5dd24bc254f2c5e4 --- /dev/null +++ b/ui/src/ui/views/config-form.shared.ts @@ -0,0 +1,91 @@ +import type { ConfigUiHints } from "../types"; + +export type JsonSchema = { + type?: string | string[]; + title?: string; + description?: string; + properties?: Record; + items?: JsonSchema | JsonSchema[]; + additionalProperties?: JsonSchema | boolean; + enum?: unknown[]; + const?: unknown; + default?: unknown; + anyOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + allOf?: JsonSchema[]; + nullable?: boolean; +}; + +export function schemaType(schema: JsonSchema): string | undefined { + if (!schema) return undefined; + if (Array.isArray(schema.type)) { + const filtered = schema.type.filter((t) => t !== "null"); + return filtered[0] ?? schema.type[0]; + } + return schema.type; +} + +export function defaultValue(schema?: JsonSchema): unknown { + if (!schema) return ""; + if (schema.default !== undefined) return schema.default; + const type = schemaType(schema); + switch (type) { + case "object": + return {}; + case "array": + return []; + case "boolean": + return false; + case "number": + case "integer": + return 0; + case "string": + return ""; + default: + return ""; + } +} + +export function pathKey(path: Array): string { + return path.filter((segment) => typeof segment === "string").join("."); +} + +export function hintForPath(path: Array, hints: ConfigUiHints) { + const key = pathKey(path); + const direct = hints[key]; + if (direct) return direct; + const segments = key.split("."); + for (const [hintKey, hint] of Object.entries(hints)) { + if (!hintKey.includes("*")) continue; + const hintSegments = hintKey.split("."); + if (hintSegments.length !== segments.length) continue; + let match = true; + for (let i = 0; i < segments.length; i += 1) { + if (hintSegments[i] !== "*" && hintSegments[i] !== segments[i]) { + match = false; + break; + } + } + if (match) return hint; + } + return undefined; +} + +export function humanize(raw: string) { + return raw + .replace(/_/g, " ") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/\s+/g, " ") + .replace(/^./, (m) => m.toUpperCase()); +} + +export function isSensitivePath(path: Array): boolean { + const key = pathKey(path).toLowerCase(); + return ( + key.includes("token") || + key.includes("password") || + key.includes("secret") || + key.includes("apikey") || + key.endsWith("key") + ); +} diff --git a/ui/src/ui/views/config-form.ts b/ui/src/ui/views/config-form.ts new file mode 100644 index 0000000000000000000000000000000000000000..c762f83311d3821a1258acb8c3c2f3e6181ea20b --- /dev/null +++ b/ui/src/ui/views/config-form.ts @@ -0,0 +1,4 @@ +export { renderConfigForm, type ConfigFormProps, SECTION_META } from "./config-form.render"; +export { analyzeConfigSchema, type ConfigSchemaAnalysis } from "./config-form.analyze"; +export { renderNode } from "./config-form.node"; +export { schemaType, type JsonSchema } from "./config-form.shared"; diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f101661e62680a3f99b97b86abcf8fe5054c5fad --- /dev/null +++ b/ui/src/ui/views/config.browser.test.ts @@ -0,0 +1,199 @@ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderConfig } from "./config"; + +describe("config view", () => { + const baseProps = () => ({ + raw: "{\n}\n", + originalRaw: "{\n}\n", + valid: true, + issues: [], + loading: false, + saving: false, + applying: false, + updating: false, + connected: true, + schema: { + type: "object", + properties: {}, + }, + schemaLoading: false, + uiHints: {}, + formMode: "form" as const, + formValue: {}, + originalValue: {}, + searchQuery: "", + activeSection: null, + activeSubsection: null, + onRawChange: vi.fn(), + onFormModeChange: vi.fn(), + onFormPatch: vi.fn(), + onSearchChange: vi.fn(), + onSectionChange: vi.fn(), + onReload: vi.fn(), + onSave: vi.fn(), + onApply: vi.fn(), + onUpdate: vi.fn(), + onSubsectionChange: vi.fn(), + }); + + it("allows save when form is unsafe", () => { + const container = document.createElement("div"); + render( + renderConfig({ + ...baseProps(), + schema: { + type: "object", + properties: { + mixed: { + anyOf: [{ type: "string" }, { type: "object", properties: {} }], + }, + }, + }, + schemaLoading: false, + uiHints: {}, + formMode: "form", + formValue: { mixed: "x" }, + }), + container, + ); + + const saveButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Save", + ) as HTMLButtonElement | undefined; + expect(saveButton).not.toBeUndefined(); + expect(saveButton?.disabled).toBe(false); + }); + + it("disables save when schema is missing", () => { + const container = document.createElement("div"); + render( + renderConfig({ + ...baseProps(), + schema: null, + formMode: "form", + formValue: { gateway: { mode: "local" } }, + originalValue: {}, + }), + container, + ); + + const saveButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Save", + ) as HTMLButtonElement | undefined; + expect(saveButton).not.toBeUndefined(); + expect(saveButton?.disabled).toBe(true); + }); + + it("disables save and apply when raw is unchanged", () => { + const container = document.createElement("div"); + render( + renderConfig({ + ...baseProps(), + formMode: "raw", + raw: "{\n}\n", + originalRaw: "{\n}\n", + }), + container, + ); + + const saveButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Save", + ) as HTMLButtonElement | undefined; + const applyButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Apply", + ) as HTMLButtonElement | undefined; + expect(saveButton).not.toBeUndefined(); + expect(applyButton).not.toBeUndefined(); + expect(saveButton?.disabled).toBe(true); + expect(applyButton?.disabled).toBe(true); + }); + + it("enables save and apply when raw changes", () => { + const container = document.createElement("div"); + render( + renderConfig({ + ...baseProps(), + formMode: "raw", + raw: '{\n gateway: { mode: "local" }\n}\n', + originalRaw: "{\n}\n", + }), + container, + ); + + const saveButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Save", + ) as HTMLButtonElement | undefined; + const applyButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Apply", + ) as HTMLButtonElement | undefined; + expect(saveButton).not.toBeUndefined(); + expect(applyButton).not.toBeUndefined(); + expect(saveButton?.disabled).toBe(false); + expect(applyButton?.disabled).toBe(false); + }); + + it("switches mode via the sidebar toggle", () => { + const container = document.createElement("div"); + const onFormModeChange = vi.fn(); + render( + renderConfig({ + ...baseProps(), + onFormModeChange, + }), + container, + ); + + const btn = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Raw", + ) as HTMLButtonElement | undefined; + expect(btn).toBeTruthy(); + btn?.click(); + expect(onFormModeChange).toHaveBeenCalledWith("raw"); + }); + + it("switches sections from the sidebar", () => { + const container = document.createElement("div"); + const onSectionChange = vi.fn(); + render( + renderConfig({ + ...baseProps(), + onSectionChange, + schema: { + type: "object", + properties: { + gateway: { type: "object", properties: {} }, + agents: { type: "object", properties: {} }, + }, + }, + }), + container, + ); + + const btn = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Gateway", + ) as HTMLButtonElement | undefined; + expect(btn).toBeTruthy(); + btn?.click(); + expect(onSectionChange).toHaveBeenCalledWith("gateway"); + }); + + it("wires search input to onSearchChange", () => { + const container = document.createElement("div"); + const onSearchChange = vi.fn(); + render( + renderConfig({ + ...baseProps(), + onSearchChange, + }), + container, + ); + + const input = container.querySelector(".config-search__input") as HTMLInputElement | null; + expect(input).not.toBeNull(); + if (!input) return; + input.value = "gateway"; + input.dispatchEvent(new Event("input", { bubbles: true })); + expect(onSearchChange).toHaveBeenCalledWith("gateway"); + }); +}); diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..d415cf70afc815742dfb04ca45bb0afabe313354 --- /dev/null +++ b/ui/src/ui/views/config.ts @@ -0,0 +1,690 @@ +import { html, nothing } from "lit"; +import type { ConfigUiHints } from "../types"; +import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form"; +import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared"; + +export type ConfigProps = { + raw: string; + originalRaw: string; + valid: boolean | null; + issues: unknown[]; + loading: boolean; + saving: boolean; + applying: boolean; + updating: boolean; + connected: boolean; + schema: unknown | null; + schemaLoading: boolean; + uiHints: ConfigUiHints; + formMode: "form" | "raw"; + formValue: Record | null; + originalValue: Record | null; + searchQuery: string; + activeSection: string | null; + activeSubsection: string | null; + onRawChange: (next: string) => void; + onFormModeChange: (mode: "form" | "raw") => void; + onFormPatch: (path: Array, value: unknown) => void; + onSearchChange: (query: string) => void; + onSectionChange: (section: string | null) => void; + onSubsectionChange: (section: string | null) => void; + onReload: () => void; + onSave: () => void; + onApply: () => void; + onUpdate: () => void; +}; + +// SVG Icons for sidebar (Lucide-style) +const sidebarIcons = { + all: html` + + + + + + + `, + env: html` + + + + + `, + update: html` + + + + + + `, + agents: html` + + + + + + `, + auth: html` + + + + + `, + channels: html` + + + + `, + messages: html` + + + + + `, + commands: html` + + + + + `, + hooks: html` + + + + + `, + skills: html` + + + + `, + tools: html` + + + + `, + gateway: html` + + + + + + `, + wizard: html` + + + + + + + + + + + + `, + // Additional sections + meta: html` + + + + + `, + logging: html` + + + + + + + + `, + browser: html` + + + + + + + + `, + ui: html` + + + + + + `, + models: html` + + + + + + `, + bindings: html` + + + + + + + `, + broadcast: html` + + + + + + + + `, + audio: html` + + + + + + `, + session: html` + + + + + + + `, + cron: html` + + + + + `, + web: html` + + + + + + `, + discovery: html` + + + + + `, + canvasHost: html` + + + + + + `, + talk: html` + + + + + + + `, + plugins: html` + + + + + + + + + + + `, + default: html` + + + + + `, +}; + +// Section definitions +const SECTIONS: Array<{ key: string; label: string }> = [ + { key: "env", label: "Environment" }, + { key: "update", label: "Updates" }, + { key: "agents", label: "Agents" }, + { key: "auth", label: "Authentication" }, + { key: "channels", label: "Channels" }, + { key: "messages", label: "Messages" }, + { key: "commands", label: "Commands" }, + { key: "hooks", label: "Hooks" }, + { key: "skills", label: "Skills" }, + { key: "tools", label: "Tools" }, + { key: "gateway", label: "Gateway" }, + { key: "wizard", label: "Setup Wizard" }, +]; + +type SubsectionEntry = { + key: string; + label: string; + description?: string; + order: number; +}; + +const ALL_SUBSECTION = "__all__"; + +function getSectionIcon(key: string) { + return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default; +} + +function resolveSectionMeta( + key: string, + schema?: JsonSchema, +): { + label: string; + description?: string; +} { + const meta = SECTION_META[key]; + if (meta) return meta; + return { + label: schema?.title ?? humanize(key), + description: schema?.description ?? "", + }; +} + +function resolveSubsections(params: { + key: string; + schema: JsonSchema | undefined; + uiHints: ConfigUiHints; +}): SubsectionEntry[] { + const { key, schema, uiHints } = params; + if (!schema || schemaType(schema) !== "object" || !schema.properties) return []; + const entries = Object.entries(schema.properties).map(([subKey, node]) => { + const hint = hintForPath([key, subKey], uiHints); + const label = hint?.label ?? node.title ?? humanize(subKey); + const description = hint?.help ?? node.description ?? ""; + const order = hint?.order ?? 50; + return { key: subKey, label, description, order }; + }); + entries.sort((a, b) => (a.order !== b.order ? a.order - b.order : a.key.localeCompare(b.key))); + return entries; +} + +function computeDiff( + original: Record | null, + current: Record | null, +): Array<{ path: string; from: unknown; to: unknown }> { + if (!original || !current) return []; + const changes: Array<{ path: string; from: unknown; to: unknown }> = []; + + function compare(orig: unknown, curr: unknown, path: string) { + if (orig === curr) return; + if (typeof orig !== typeof curr) { + changes.push({ path, from: orig, to: curr }); + return; + } + if (typeof orig !== "object" || orig === null || curr === null) { + if (orig !== curr) { + changes.push({ path, from: orig, to: curr }); + } + return; + } + if (Array.isArray(orig) && Array.isArray(curr)) { + if (JSON.stringify(orig) !== JSON.stringify(curr)) { + changes.push({ path, from: orig, to: curr }); + } + return; + } + const origObj = orig as Record; + const currObj = curr as Record; + const allKeys = new Set([...Object.keys(origObj), ...Object.keys(currObj)]); + for (const key of allKeys) { + compare(origObj[key], currObj[key], path ? `${path}.${key}` : key); + } + } + + compare(original, current, ""); + return changes; +} + +function truncateValue(value: unknown, maxLen = 40): string { + let str: string; + try { + const json = JSON.stringify(value); + str = json ?? String(value); + } catch { + str = String(value); + } + if (str.length <= maxLen) return str; + return str.slice(0, maxLen - 3) + "..."; +} + +export function renderConfig(props: ConfigProps) { + const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; + const analysis = analyzeConfigSchema(props.schema); + const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false; + + // Get available sections from schema + const schemaProps = analysis.schema?.properties ?? {}; + const availableSections = SECTIONS.filter((s) => s.key in schemaProps); + + // Add any sections in schema but not in our list + const knownKeys = new Set(SECTIONS.map((s) => s.key)); + const extraSections = Object.keys(schemaProps) + .filter((k) => !knownKeys.has(k)) + .map((k) => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) })); + + const allSections = [...availableSections, ...extraSections]; + + const activeSectionSchema = + props.activeSection && analysis.schema && schemaType(analysis.schema) === "object" + ? (analysis.schema.properties?.[props.activeSection] as JsonSchema | undefined) + : undefined; + const activeSectionMeta = props.activeSection + ? resolveSectionMeta(props.activeSection, activeSectionSchema) + : null; + const subsections = props.activeSection + ? resolveSubsections({ + key: props.activeSection, + schema: activeSectionSchema, + uiHints: props.uiHints, + }) + : []; + const allowSubnav = + props.formMode === "form" && Boolean(props.activeSection) && subsections.length > 0; + const isAllSubsection = props.activeSubsection === ALL_SUBSECTION; + const effectiveSubsection = props.searchQuery + ? null + : isAllSubsection + ? null + : (props.activeSubsection ?? subsections[0]?.key ?? null); + + // Compute diff for showing changes (works for both form and raw modes) + const diff = props.formMode === "form" ? computeDiff(props.originalValue, props.formValue) : []; + const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw; + const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges; + + // Save/apply buttons require actual changes to be enabled. + // Note: formUnsafe warns about unsupported schema paths but shouldn't block saving. + const canSaveForm = Boolean(props.formValue) && !props.loading && Boolean(analysis.schema); + const canSave = + props.connected && + !props.saving && + hasChanges && + (props.formMode === "raw" ? true : canSaveForm); + const canApply = + props.connected && + !props.applying && + !props.updating && + hasChanges && + (props.formMode === "raw" ? true : canSaveForm); + const canUpdate = props.connected && !props.applying && !props.updating; + + return html` +
      + + + + +
      + +
      +
      + ${ + hasChanges + ? html` + ${props.formMode === "raw" ? "Unsaved changes" : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`} + ` + : html` + No changes + ` + } +
      +
      + + + + +
      +
      + + + ${ + hasChanges && props.formMode === "form" + ? html` +
      + + View ${diff.length} pending change${diff.length !== 1 ? "s" : ""} + + + + +
      + ${diff.map( + (change) => html` +
      +
      ${change.path}
      +
      + ${truncateValue(change.from)} + + ${truncateValue(change.to)} +
      +
      + `, + )} +
      +
      + ` + : nothing + } + + ${ + activeSectionMeta && props.formMode === "form" + ? html` +
      +
      ${getSectionIcon(props.activeSection ?? "")}
      +
      +
      ${activeSectionMeta.label}
      + ${ + activeSectionMeta.description + ? html`
      ${activeSectionMeta.description}
      ` + : nothing + } +
      +
      + ` + : nothing + } + + ${ + allowSubnav + ? html` +
      + + ${subsections.map( + (entry) => html` + + `, + )} +
      + ` + : nothing + } + + +
      + ${ + props.formMode === "form" + ? html` + ${ + props.schemaLoading + ? html` +
      +
      + Loading schema… +
      + ` + : renderConfigForm({ + schema: analysis.schema, + uiHints: props.uiHints, + value: props.formValue, + disabled: props.loading || !props.formValue, + unsupportedPaths: analysis.unsupportedPaths, + onPatch: props.onFormPatch, + searchQuery: props.searchQuery, + activeSection: props.activeSection, + activeSubsection: effectiveSubsection, + }) + } + ${ + formUnsafe + ? html` +
      + Form view can't safely edit some fields. Use Raw to avoid losing config entries. +
      + ` + : nothing + } + ` + : html` + + ` + } +
      + + ${ + props.issues.length > 0 + ? html`
      +
      ${JSON.stringify(props.issues, null, 2)}
      +
      ` + : nothing + } +
      +
      + `; +} diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..31a93b23bedc0f1597376bacf461dcc5682a3b78 --- /dev/null +++ b/ui/src/ui/views/cron.test.ts @@ -0,0 +1,100 @@ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import type { CronJob } from "../types"; +import { DEFAULT_CRON_FORM } from "../app-defaults"; +import { renderCron, type CronProps } from "./cron"; + +function createJob(id: string): CronJob { + return { + id, + name: "Daily ping", + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron", expr: "0 9 * * *" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }; +} + +function createProps(overrides: Partial = {}): CronProps { + return { + loading: false, + status: null, + jobs: [], + error: null, + busy: false, + form: { ...DEFAULT_CRON_FORM }, + channels: [], + channelLabels: {}, + runsJobId: null, + runs: [], + onFormChange: () => undefined, + onRefresh: () => undefined, + onAdd: () => undefined, + onToggle: () => undefined, + onRun: () => undefined, + onRemove: () => undefined, + onLoadRuns: () => undefined, + ...overrides, + }; +} + +describe("cron view", () => { + it("prompts to select a job before showing run history", () => { + const container = document.createElement("div"); + render(renderCron(createProps()), container); + + expect(container.textContent).toContain("Select a job to inspect run history."); + }); + + it("loads run history when clicking a job row", () => { + const container = document.createElement("div"); + const onLoadRuns = vi.fn(); + const job = createJob("job-1"); + render( + renderCron( + createProps({ + jobs: [job], + onLoadRuns, + }), + ), + container, + ); + + const row = container.querySelector(".list-item-clickable") as HTMLElement | null; + expect(row).not.toBeNull(); + row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onLoadRuns).toHaveBeenCalledWith("job-1"); + }); + + it("marks the selected job and keeps Runs button to a single call", () => { + const container = document.createElement("div"); + const onLoadRuns = vi.fn(); + const job = createJob("job-1"); + render( + renderCron( + createProps({ + jobs: [job], + runsJobId: "job-1", + onLoadRuns, + }), + ), + container, + ); + + const selected = container.querySelector(".list-item-selected"); + expect(selected).not.toBeNull(); + + const runsButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Runs", + ); + expect(runsButton).not.toBeUndefined(); + runsButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onLoadRuns).toHaveBeenCalledTimes(1); + expect(onLoadRuns).toHaveBeenCalledWith("job-1"); + }); +}); diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts new file mode 100644 index 0000000000000000000000000000000000000000..ede5fd0455e7bf84f53e55d4e03dbee4f12fde2c --- /dev/null +++ b/ui/src/ui/views/cron.ts @@ -0,0 +1,460 @@ +import { html, nothing } from "lit"; +import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types"; +import type { CronFormState } from "../ui-types"; +import { formatMs } from "../format"; +import { + formatCronPayload, + formatCronSchedule, + formatCronState, + formatNextRun, +} from "../presenter"; + +export type CronProps = { + loading: boolean; + status: CronStatus | null; + jobs: CronJob[]; + error: string | null; + busy: boolean; + form: CronFormState; + channels: string[]; + channelLabels?: Record; + channelMeta?: ChannelUiMetaEntry[]; + runsJobId: string | null; + runs: CronRunLogEntry[]; + onFormChange: (patch: Partial) => void; + onRefresh: () => void; + onAdd: () => void; + onToggle: (job: CronJob, enabled: boolean) => void; + onRun: (job: CronJob) => void; + onRemove: (job: CronJob) => void; + onLoadRuns: (jobId: string) => void; +}; + +function buildChannelOptions(props: CronProps): string[] { + const options = ["last", ...props.channels.filter(Boolean)]; + const current = props.form.channel?.trim(); + if (current && !options.includes(current)) { + options.push(current); + } + const seen = new Set(); + return options.filter((value) => { + if (seen.has(value)) return false; + seen.add(value); + return true; + }); +} + +function resolveChannelLabel(props: CronProps, channel: string): string { + if (channel === "last") return "last"; + const meta = props.channelMeta?.find((entry) => entry.id === channel); + if (meta?.label) return meta.label; + return props.channelLabels?.[channel] ?? channel; +} + +export function renderCron(props: CronProps) { + const channelOptions = buildChannelOptions(props); + return html` +
      +
      +
      Scheduler
      +
      Gateway-owned cron scheduler status.
      +
      +
      +
      Enabled
      +
      + ${props.status ? (props.status.enabled ? "Yes" : "No") : "n/a"} +
      +
      +
      +
      Jobs
      +
      ${props.status?.jobs ?? "n/a"}
      +
      +
      +
      Next wake
      +
      ${formatNextRun(props.status?.nextWakeAtMs ?? null)}
      +
      +
      +
      + + ${props.error ? html`${props.error}` : nothing} +
      +
      + +
      +
      New Job
      +
      Create a scheduled wakeup or agent run.
      +
      + + + + + +
      + ${renderScheduleFields(props)} +
      + + + +
      + + ${ + props.form.payloadKind === "agentTurn" + ? html` +
      + + + + + ${ + props.form.sessionTarget === "isolated" + ? html` + + ` + : nothing + } +
      + ` + : nothing + } +
      + +
      +
      +
      + +
      +
      Jobs
      +
      All scheduled jobs stored in the gateway.
      + ${ + props.jobs.length === 0 + ? html` +
      No jobs yet.
      + ` + : html` +
      + ${props.jobs.map((job) => renderJob(job, props))} +
      + ` + } +
      + +
      +
      Run history
      +
      Latest runs for ${props.runsJobId ?? "(select a job)"}.
      + ${ + props.runsJobId == null + ? html` +
      Select a job to inspect run history.
      + ` + : props.runs.length === 0 + ? html` +
      No runs yet.
      + ` + : html` +
      + ${props.runs.map((entry) => renderRun(entry))} +
      + ` + } +
      + `; +} + +function renderScheduleFields(props: CronProps) { + const form = props.form; + if (form.scheduleKind === "at") { + return html` + + `; + } + if (form.scheduleKind === "every") { + return html` +
      + + +
      + `; + } + return html` +
      + + +
      + `; +} + +function renderJob(job: CronJob, props: CronProps) { + const isSelected = props.runsJobId === job.id; + const itemClass = `list-item list-item-clickable${isSelected ? " list-item-selected" : ""}`; + return html` +
      props.onLoadRuns(job.id)}> +
      +
      ${job.name}
      +
      ${formatCronSchedule(job)}
      +
      ${formatCronPayload(job)}
      + ${job.agentId ? html`
      Agent: ${job.agentId}
      ` : nothing} +
      + ${job.enabled ? "enabled" : "disabled"} + ${job.sessionTarget} + ${job.wakeMode} +
      +
      +
      +
      ${formatCronState(job)}
      +
      + + + + +
      +
      +
      + `; +} + +function renderRun(entry: CronRunLogEntry) { + return html` +
      +
      +
      ${entry.status}
      +
      ${entry.summary ?? ""}
      +
      +
      +
      ${formatMs(entry.ts)}
      +
      ${entry.durationMs ?? 0}ms
      + ${entry.error ? html`
      ${entry.error}
      ` : nothing} +
      +
      + `; +} diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts new file mode 100644 index 0000000000000000000000000000000000000000..e37bb48d6ffdb16417c81b8c11865d4b4c1d9f14 --- /dev/null +++ b/ui/src/ui/views/debug.ts @@ -0,0 +1,149 @@ +import { html, nothing } from "lit"; +import type { EventLogEntry } from "../app-events"; +import { formatEventPayload } from "../presenter"; + +export type DebugProps = { + loading: boolean; + status: Record | null; + health: Record | null; + models: unknown[]; + heartbeat: unknown; + eventLog: EventLogEntry[]; + callMethod: string; + callParams: string; + callResult: string | null; + callError: string | null; + onCallMethodChange: (next: string) => void; + onCallParamsChange: (next: string) => void; + onRefresh: () => void; + onCall: () => void; +}; + +export function renderDebug(props: DebugProps) { + const securityAudit = + props.status && typeof props.status === "object" + ? (props.status as { securityAudit?: { summary?: Record } }).securityAudit + : null; + const securitySummary = securityAudit?.summary ?? null; + const critical = securitySummary?.critical ?? 0; + const warn = securitySummary?.warn ?? 0; + const info = securitySummary?.info ?? 0; + const securityTone = critical > 0 ? "danger" : warn > 0 ? "warn" : "success"; + const securityLabel = + critical > 0 ? `${critical} critical` : warn > 0 ? `${warn} warnings` : "No critical issues"; + + return html` +
      +
      +
      +
      +
      Snapshots
      +
      Status, health, and heartbeat data.
      +
      + +
      +
      +
      +
      Status
      + ${ + securitySummary + ? html`
      + Security audit: ${securityLabel}${info > 0 ? ` · ${info} info` : ""}. Run + openclaw security audit --deep for details. +
      ` + : nothing + } +
      ${JSON.stringify(props.status ?? {}, null, 2)}
      +
      +
      +
      Health
      +
      ${JSON.stringify(props.health ?? {}, null, 2)}
      +
      +
      +
      Last heartbeat
      +
      ${JSON.stringify(props.heartbeat ?? {}, null, 2)}
      +
      +
      +
      + +
      +
      Manual RPC
      +
      Send a raw gateway method with JSON params.
      +
      + + +
      +
      + +
      + ${ + props.callError + ? html`
      + ${props.callError} +
      ` + : nothing + } + ${ + props.callResult + ? html`
      ${props.callResult}
      ` + : nothing + } +
      +
      + +
      +
      Models
      +
      Catalog from models.list.
      +
      ${JSON.stringify(
      +        props.models ?? [],
      +        null,
      +        2,
      +      )}
      +
      + +
      +
      Event Log
      +
      Latest gateway events.
      + ${ + props.eventLog.length === 0 + ? html` +
      No events yet.
      + ` + : html` +
      + ${props.eventLog.map( + (evt) => html` +
      +
      +
      ${evt.event}
      +
      ${new Date(evt.ts).toLocaleTimeString()}
      +
      +
      +
      ${formatEventPayload(evt.payload)}
      +
      +
      + `, + )} +
      + ` + } +
      + `; +} diff --git a/ui/src/ui/views/exec-approval.ts b/ui/src/ui/views/exec-approval.ts new file mode 100644 index 0000000000000000000000000000000000000000..33efc947ffd9fe066cc7c726111ee0c4c0013130 --- /dev/null +++ b/ui/src/ui/views/exec-approval.ts @@ -0,0 +1,81 @@ +import { html, nothing } from "lit"; +import type { AppViewState } from "../app-view-state"; + +function formatRemaining(ms: number): string { + const remaining = Math.max(0, ms); + const totalSeconds = Math.floor(remaining / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h`; +} + +function renderMetaRow(label: string, value?: string | null) { + if (!value) return nothing; + return html`
      ${label}${value}
      `; +} + +export function renderExecApprovalPrompt(state: AppViewState) { + const active = state.execApprovalQueue[0]; + if (!active) return nothing; + const request = active.request; + const remainingMs = active.expiresAtMs - Date.now(); + const remaining = remainingMs > 0 ? `expires in ${formatRemaining(remainingMs)}` : "expired"; + const queueCount = state.execApprovalQueue.length; + return html` + + `; +} diff --git a/ui/src/ui/views/gateway-url-confirmation.ts b/ui/src/ui/views/gateway-url-confirmation.ts new file mode 100644 index 0000000000000000000000000000000000000000..39c6f9d0510c8e87f7b0d2a573e21598fdd41a3b --- /dev/null +++ b/ui/src/ui/views/gateway-url-confirmation.ts @@ -0,0 +1,38 @@ +import { html, nothing } from "lit"; +import type { AppViewState } from "../app-view-state"; + +export function renderGatewayUrlConfirmation(state: AppViewState) { + const { pendingGatewayUrl } = state; + if (!pendingGatewayUrl) return nothing; + + return html` + + `; +} diff --git a/ui/src/ui/views/instances.ts b/ui/src/ui/views/instances.ts new file mode 100644 index 0000000000000000000000000000000000000000..384251343012e7eba0ed25e3d5e8052e206c98b6 --- /dev/null +++ b/ui/src/ui/views/instances.ts @@ -0,0 +1,89 @@ +import { html, nothing } from "lit"; +import type { PresenceEntry } from "../types"; +import { formatPresenceAge, formatPresenceSummary } from "../presenter"; + +export type InstancesProps = { + loading: boolean; + entries: PresenceEntry[]; + lastError: string | null; + statusMessage: string | null; + onRefresh: () => void; +}; + +export function renderInstances(props: InstancesProps) { + return html` +
      +
      +
      +
      Connected Instances
      +
      Presence beacons from the gateway and clients.
      +
      + +
      + ${ + props.lastError + ? html`
      + ${props.lastError} +
      ` + : nothing + } + ${ + props.statusMessage + ? html`
      + ${props.statusMessage} +
      ` + : nothing + } +
      + ${ + props.entries.length === 0 + ? html` +
      No instances reported yet.
      + ` + : props.entries.map((entry) => renderEntry(entry)) + } +
      +
      + `; +} + +function renderEntry(entry: PresenceEntry) { + const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a"; + const mode = entry.mode ?? "unknown"; + const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : []; + const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : []; + const scopesLabel = + scopes.length > 0 + ? scopes.length > 3 + ? `${scopes.length} scopes` + : `scopes: ${scopes.join(", ")}` + : null; + return html` +
      +
      +
      ${entry.host ?? "unknown host"}
      +
      ${formatPresenceSummary(entry)}
      +
      + ${mode} + ${roles.map((role) => html`${role}`)} + ${scopesLabel ? html`${scopesLabel}` : nothing} + ${entry.platform ? html`${entry.platform}` : nothing} + ${entry.deviceFamily ? html`${entry.deviceFamily}` : nothing} + ${ + entry.modelIdentifier + ? html`${entry.modelIdentifier}` + : nothing + } + ${entry.version ? html`${entry.version}` : nothing} +
      +
      +
      +
      ${formatPresenceAge(entry)}
      +
      Last input ${lastInput}
      +
      Reason ${entry.reason ?? ""}
      +
      +
      + `; +} diff --git a/ui/src/ui/views/logs.ts b/ui/src/ui/views/logs.ts new file mode 100644 index 0000000000000000000000000000000000000000..7962c0a10279869267cb93ee612f7640a289017b --- /dev/null +++ b/ui/src/ui/views/logs.ts @@ -0,0 +1,147 @@ +import { html, nothing } from "lit"; +import type { LogEntry, LogLevel } from "../types"; + +const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"]; + +export type LogsProps = { + loading: boolean; + error: string | null; + file: string | null; + entries: LogEntry[]; + filterText: string; + levelFilters: Record; + autoFollow: boolean; + truncated: boolean; + onFilterTextChange: (next: string) => void; + onLevelToggle: (level: LogLevel, enabled: boolean) => void; + onToggleAutoFollow: (next: boolean) => void; + onRefresh: () => void; + onExport: (lines: string[], label: string) => void; + onScroll: (event: Event) => void; +}; + +function formatTime(value?: string | null) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleTimeString(); +} + +function matchesFilter(entry: LogEntry, needle: string) { + if (!needle) return true; + const haystack = [entry.message, entry.subsystem, entry.raw] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(needle); +} + +export function renderLogs(props: LogsProps) { + const needle = props.filterText.trim().toLowerCase(); + const levelFiltered = LEVELS.some((level) => !props.levelFilters[level]); + const filtered = props.entries.filter((entry) => { + if (entry.level && !props.levelFilters[entry.level]) return false; + return matchesFilter(entry, needle); + }); + const exportLabel = needle || levelFiltered ? "filtered" : "visible"; + + return html` +
      +
      +
      +
      Logs
      +
      Gateway file logs (JSONL).
      +
      +
      + + +
      +
      + +
      + + +
      + +
      + ${LEVELS.map( + (level) => html` + + `, + )} +
      + + ${ + props.file + ? html`
      File: ${props.file}
      ` + : nothing + } + ${ + props.truncated + ? html` +
      Log output truncated; showing latest chunk.
      + ` + : nothing + } + ${ + props.error + ? html`
      ${props.error}
      ` + : nothing + } + +
      + ${ + filtered.length === 0 + ? html` +
      No log entries.
      + ` + : filtered.map( + (entry) => html` +
      +
      ${formatTime(entry.time)}
      +
      ${entry.level ?? ""}
      +
      ${entry.subsystem ?? ""}
      +
      ${entry.message ?? entry.raw}
      +
      + `, + ) + } +
      +
      + `; +} diff --git a/ui/src/ui/views/markdown-sidebar.ts b/ui/src/ui/views/markdown-sidebar.ts new file mode 100644 index 0000000000000000000000000000000000000000..285e2bf11501208424015e49604d3b0da8777693 --- /dev/null +++ b/ui/src/ui/views/markdown-sidebar.ts @@ -0,0 +1,40 @@ +import { html, nothing } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { icons } from "../icons"; +import { toSanitizedMarkdownHtml } from "../markdown"; + +export type MarkdownSidebarProps = { + content: string | null; + error: string | null; + onClose: () => void; + onViewRawText: () => void; +}; + +export function renderMarkdownSidebar(props: MarkdownSidebarProps) { + return html` + + `; +} diff --git a/ui/src/ui/views/nodes.ts b/ui/src/ui/views/nodes.ts new file mode 100644 index 0000000000000000000000000000000000000000..59228103640985143cd6d3059e1408a75129c29f --- /dev/null +++ b/ui/src/ui/views/nodes.ts @@ -0,0 +1,1136 @@ +import { html, nothing } from "lit"; +import type { + DevicePairingList, + DeviceTokenSummary, + PairedDevice, + PendingDevice, +} from "../controllers/devices"; +import type { + ExecApprovalsAllowlistEntry, + ExecApprovalsFile, + ExecApprovalsSnapshot, +} from "../controllers/exec-approvals"; +import { clampText, formatAgo, formatList } from "../format"; + +export type NodesProps = { + loading: boolean; + nodes: Array>; + devicesLoading: boolean; + devicesError: string | null; + devicesList: DevicePairingList | null; + configForm: Record | null; + configLoading: boolean; + configSaving: boolean; + configDirty: boolean; + configFormMode: "form" | "raw"; + execApprovalsLoading: boolean; + execApprovalsSaving: boolean; + execApprovalsDirty: boolean; + execApprovalsSnapshot: ExecApprovalsSnapshot | null; + execApprovalsForm: ExecApprovalsFile | null; + execApprovalsSelectedAgent: string | null; + execApprovalsTarget: "gateway" | "node"; + execApprovalsTargetNodeId: string | null; + onRefresh: () => void; + onDevicesRefresh: () => void; + onDeviceApprove: (requestId: string) => void; + onDeviceReject: (requestId: string) => void; + onDeviceRotate: (deviceId: string, role: string, scopes?: string[]) => void; + onDeviceRevoke: (deviceId: string, role: string) => void; + onLoadConfig: () => void; + onLoadExecApprovals: () => void; + onBindDefault: (nodeId: string | null) => void; + onBindAgent: (agentIndex: number, nodeId: string | null) => void; + onSaveBindings: () => void; + onExecApprovalsTargetChange: (kind: "gateway" | "node", nodeId: string | null) => void; + onExecApprovalsSelectAgent: (agentId: string) => void; + onExecApprovalsPatch: (path: Array, value: unknown) => void; + onExecApprovalsRemove: (path: Array) => void; + onSaveExecApprovals: () => void; +}; + +export function renderNodes(props: NodesProps) { + const bindingState = resolveBindingsState(props); + const approvalsState = resolveExecApprovalsState(props); + return html` + ${renderExecApprovals(approvalsState)} + ${renderBindings(bindingState)} + ${renderDevices(props)} +
      +
      +
      +
      Nodes
      +
      Paired devices and live links.
      +
      + +
      +
      + ${ + props.nodes.length === 0 + ? html` +
      No nodes found.
      + ` + : props.nodes.map((n) => renderNode(n)) + } +
      +
      + `; +} + +function renderDevices(props: NodesProps) { + const list = props.devicesList ?? { pending: [], paired: [] }; + const pending = Array.isArray(list.pending) ? list.pending : []; + const paired = Array.isArray(list.paired) ? list.paired : []; + return html` +
      +
      +
      +
      Devices
      +
      Pairing requests + role tokens.
      +
      + +
      + ${ + props.devicesError + ? html`
      ${props.devicesError}
      ` + : nothing + } +
      + ${ + pending.length > 0 + ? html` +
      Pending
      + ${pending.map((req) => renderPendingDevice(req, props))} + ` + : nothing + } + ${ + paired.length > 0 + ? html` +
      Paired
      + ${paired.map((device) => renderPairedDevice(device, props))} + ` + : nothing + } + ${ + pending.length === 0 && paired.length === 0 + ? html` +
      No paired devices.
      + ` + : nothing + } +
      +
      + `; +} + +function renderPendingDevice(req: PendingDevice, props: NodesProps) { + const name = req.displayName?.trim() || req.deviceId; + const age = typeof req.ts === "number" ? formatAgo(req.ts) : "n/a"; + const role = req.role?.trim() ? `role: ${req.role}` : "role: -"; + const repair = req.isRepair ? " · repair" : ""; + const ip = req.remoteIp ? ` · ${req.remoteIp}` : ""; + return html` +
      +
      +
      ${name}
      +
      ${req.deviceId}${ip}
      +
      + ${role} · requested ${age}${repair} +
      +
      +
      +
      + + +
      +
      +
      + `; +} + +function renderPairedDevice(device: PairedDevice, props: NodesProps) { + const name = device.displayName?.trim() || device.deviceId; + const ip = device.remoteIp ? ` · ${device.remoteIp}` : ""; + const roles = `roles: ${formatList(device.roles)}`; + const scopes = `scopes: ${formatList(device.scopes)}`; + const tokens = Array.isArray(device.tokens) ? device.tokens : []; + return html` +
      +
      +
      ${name}
      +
      ${device.deviceId}${ip}
      +
      ${roles} · ${scopes}
      + ${ + tokens.length === 0 + ? html` +
      Tokens: none
      + ` + : html` +
      Tokens
      +
      + ${tokens.map((token) => renderTokenRow(device.deviceId, token, props))} +
      + ` + } +
      +
      + `; +} + +function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) { + const status = token.revokedAtMs ? "revoked" : "active"; + const scopes = `scopes: ${formatList(token.scopes)}`; + const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null); + return html` +
      +
      ${token.role} · ${status} · ${scopes} · ${when}
      +
      + + ${ + token.revokedAtMs + ? nothing + : html` + + ` + } +
      +
      + `; +} + +type BindingAgent = { + id: string; + name?: string; + index: number; + isDefault: boolean; + binding?: string | null; +}; + +type BindingNode = { + id: string; + label: string; +}; + +type BindingState = { + ready: boolean; + disabled: boolean; + configDirty: boolean; + configLoading: boolean; + configSaving: boolean; + defaultBinding?: string | null; + agents: BindingAgent[]; + nodes: BindingNode[]; + onBindDefault: (nodeId: string | null) => void; + onBindAgent: (agentIndex: number, nodeId: string | null) => void; + onSave: () => void; + onLoadConfig: () => void; + formMode: "form" | "raw"; +}; + +type ExecSecurity = "deny" | "allowlist" | "full"; +type ExecAsk = "off" | "on-miss" | "always"; + +type ExecApprovalsResolvedDefaults = { + security: ExecSecurity; + ask: ExecAsk; + askFallback: ExecSecurity; + autoAllowSkills: boolean; +}; + +type ExecApprovalsAgentOption = { + id: string; + name?: string; + isDefault?: boolean; +}; + +type ExecApprovalsTargetNode = { + id: string; + label: string; +}; + +type ExecApprovalsState = { + ready: boolean; + disabled: boolean; + dirty: boolean; + loading: boolean; + saving: boolean; + form: ExecApprovalsFile | null; + defaults: ExecApprovalsResolvedDefaults; + selectedScope: string; + selectedAgent: Record | null; + agents: ExecApprovalsAgentOption[]; + allowlist: ExecApprovalsAllowlistEntry[]; + target: "gateway" | "node"; + targetNodeId: string | null; + targetNodes: ExecApprovalsTargetNode[]; + onSelectScope: (agentId: string) => void; + onSelectTarget: (kind: "gateway" | "node", nodeId: string | null) => void; + onPatch: (path: Array, value: unknown) => void; + onRemove: (path: Array) => void; + onLoad: () => void; + onSave: () => void; +}; + +const EXEC_APPROVALS_DEFAULT_SCOPE = "__defaults__"; + +const SECURITY_OPTIONS: Array<{ value: ExecSecurity; label: string }> = [ + { value: "deny", label: "Deny" }, + { value: "allowlist", label: "Allowlist" }, + { value: "full", label: "Full" }, +]; + +const ASK_OPTIONS: Array<{ value: ExecAsk; label: string }> = [ + { value: "off", label: "Off" }, + { value: "on-miss", label: "On miss" }, + { value: "always", label: "Always" }, +]; + +function resolveBindingsState(props: NodesProps): BindingState { + const config = props.configForm; + const nodes = resolveExecNodes(props.nodes); + const { defaultBinding, agents } = resolveAgentBindings(config); + const ready = Boolean(config); + const disabled = props.configSaving || props.configFormMode === "raw"; + return { + ready, + disabled, + configDirty: props.configDirty, + configLoading: props.configLoading, + configSaving: props.configSaving, + defaultBinding, + agents, + nodes, + onBindDefault: props.onBindDefault, + onBindAgent: props.onBindAgent, + onSave: props.onSaveBindings, + onLoadConfig: props.onLoadConfig, + formMode: props.configFormMode, + }; +} + +function normalizeSecurity(value?: string): ExecSecurity { + if (value === "allowlist" || value === "full" || value === "deny") return value; + return "deny"; +} + +function normalizeAsk(value?: string): ExecAsk { + if (value === "always" || value === "off" || value === "on-miss") return value; + return "on-miss"; +} + +function resolveExecApprovalsDefaults( + form: ExecApprovalsFile | null, +): ExecApprovalsResolvedDefaults { + const defaults = form?.defaults ?? {}; + return { + security: normalizeSecurity(defaults.security), + ask: normalizeAsk(defaults.ask), + askFallback: normalizeSecurity(defaults.askFallback ?? "deny"), + autoAllowSkills: Boolean(defaults.autoAllowSkills ?? false), + }; +} + +function resolveConfigAgents(config: Record | null): ExecApprovalsAgentOption[] { + const agentsNode = (config?.agents ?? {}) as Record; + const list = Array.isArray(agentsNode.list) ? agentsNode.list : []; + const agents: ExecApprovalsAgentOption[] = []; + list.forEach((entry) => { + if (!entry || typeof entry !== "object") return; + const record = entry as Record; + const id = typeof record.id === "string" ? record.id.trim() : ""; + if (!id) return; + const name = typeof record.name === "string" ? record.name.trim() : undefined; + const isDefault = record.default === true; + agents.push({ id, name: name || undefined, isDefault }); + }); + return agents; +} + +function resolveExecApprovalsAgents( + config: Record | null, + form: ExecApprovalsFile | null, +): ExecApprovalsAgentOption[] { + const configAgents = resolveConfigAgents(config); + const approvalsAgents = Object.keys(form?.agents ?? {}); + const merged = new Map(); + configAgents.forEach((agent) => merged.set(agent.id, agent)); + approvalsAgents.forEach((id) => { + if (merged.has(id)) return; + merged.set(id, { id }); + }); + const agents = Array.from(merged.values()); + if (agents.length === 0) { + agents.push({ id: "main", isDefault: true }); + } + agents.sort((a, b) => { + if (a.isDefault && !b.isDefault) return -1; + if (!a.isDefault && b.isDefault) return 1; + const aLabel = a.name?.trim() ? a.name : a.id; + const bLabel = b.name?.trim() ? b.name : b.id; + return aLabel.localeCompare(bLabel); + }); + return agents; +} + +function resolveExecApprovalsScope( + selected: string | null, + agents: ExecApprovalsAgentOption[], +): string { + if (selected === EXEC_APPROVALS_DEFAULT_SCOPE) return EXEC_APPROVALS_DEFAULT_SCOPE; + if (selected && agents.some((agent) => agent.id === selected)) return selected; + return EXEC_APPROVALS_DEFAULT_SCOPE; +} + +function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState { + const form = props.execApprovalsForm ?? props.execApprovalsSnapshot?.file ?? null; + const ready = Boolean(form); + const defaults = resolveExecApprovalsDefaults(form); + const agents = resolveExecApprovalsAgents(props.configForm, form); + const targetNodes = resolveExecApprovalsNodes(props.nodes); + const target = props.execApprovalsTarget; + let targetNodeId = + target === "node" && props.execApprovalsTargetNodeId ? props.execApprovalsTargetNodeId : null; + if (target === "node" && targetNodeId && !targetNodes.some((node) => node.id === targetNodeId)) { + targetNodeId = null; + } + const selectedScope = resolveExecApprovalsScope(props.execApprovalsSelectedAgent, agents); + const selectedAgent = + selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE + ? (((form?.agents ?? {})[selectedScope] as Record | undefined) ?? null) + : null; + const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist) + ? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ?? []) + : []; + return { + ready, + disabled: props.execApprovalsSaving || props.execApprovalsLoading, + dirty: props.execApprovalsDirty, + loading: props.execApprovalsLoading, + saving: props.execApprovalsSaving, + form, + defaults, + selectedScope, + selectedAgent, + agents, + allowlist, + target, + targetNodeId, + targetNodes, + onSelectScope: props.onExecApprovalsSelectAgent, + onSelectTarget: props.onExecApprovalsTargetChange, + onPatch: props.onExecApprovalsPatch, + onRemove: props.onExecApprovalsRemove, + onLoad: props.onLoadExecApprovals, + onSave: props.onSaveExecApprovals, + }; +} + +function renderBindings(state: BindingState) { + const supportsBinding = state.nodes.length > 0; + const defaultValue = state.defaultBinding ?? ""; + return html` +
      +
      +
      +
      Exec node binding
      +
      + Pin agents to a specific node when using exec host=node. +
      +
      + +
      + + ${ + state.formMode === "raw" + ? html` +
      + Switch the Config tab to Form mode to edit bindings here. +
      + ` + : nothing + } + + ${ + !state.ready + ? html`
      +
      Load config to edit bindings.
      + +
      ` + : html` +
      +
      +
      +
      Default binding
      +
      Used when agents do not override a node binding.
      +
      +
      + + ${ + !supportsBinding + ? html` +
      No nodes with system.run available.
      + ` + : nothing + } +
      +
      + + ${ + state.agents.length === 0 + ? html` +
      No agents found.
      + ` + : state.agents.map((agent) => renderAgentBinding(agent, state)) + } +
      + ` + } +
      + `; +} + +function renderExecApprovals(state: ExecApprovalsState) { + const ready = state.ready; + const targetReady = state.target !== "node" || Boolean(state.targetNodeId); + return html` +
      +
      +
      +
      Exec approvals
      +
      + Allowlist and approval policy for exec host=gateway/node. +
      +
      + +
      + + ${renderExecApprovalsTarget(state)} + + ${ + !ready + ? html`
      +
      Load exec approvals to edit allowlists.
      + +
      ` + : html` + ${renderExecApprovalsTabs(state)} + ${renderExecApprovalsPolicy(state)} + ${ + state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE + ? nothing + : renderExecApprovalsAllowlist(state) + } + ` + } +
      + `; +} + +function renderExecApprovalsTarget(state: ExecApprovalsState) { + const hasNodes = state.targetNodes.length > 0; + const nodeValue = state.targetNodeId ?? ""; + return html` +
      +
      +
      +
      Target
      +
      + Gateway edits local approvals; node edits the selected node. +
      +
      +
      + + ${ + state.target === "node" + ? html` + + ` + : nothing + } +
      +
      + ${ + state.target === "node" && !hasNodes + ? html` +
      No nodes advertise exec approvals yet.
      + ` + : nothing + } +
      + `; +} + +function renderExecApprovalsTabs(state: ExecApprovalsState) { + return html` +
      + Scope +
      + + ${state.agents.map((agent) => { + const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id; + return html` + + `; + })} +
      +
      + `; +} + +function renderExecApprovalsPolicy(state: ExecApprovalsState) { + const isDefaults = state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE; + const defaults = state.defaults; + const agent = state.selectedAgent ?? {}; + const basePath = isDefaults ? ["defaults"] : ["agents", state.selectedScope]; + const agentSecurity = typeof agent.security === "string" ? agent.security : undefined; + const agentAsk = typeof agent.ask === "string" ? agent.ask : undefined; + const agentAskFallback = typeof agent.askFallback === "string" ? agent.askFallback : undefined; + const securityValue = isDefaults ? defaults.security : (agentSecurity ?? "__default__"); + const askValue = isDefaults ? defaults.ask : (agentAsk ?? "__default__"); + const askFallbackValue = isDefaults ? defaults.askFallback : (agentAskFallback ?? "__default__"); + const autoOverride = + typeof agent.autoAllowSkills === "boolean" ? agent.autoAllowSkills : undefined; + const autoEffective = autoOverride ?? defaults.autoAllowSkills; + const autoIsDefault = autoOverride == null; + + return html` +
      +
      +
      +
      Security
      +
      + ${isDefaults ? "Default security mode." : `Default: ${defaults.security}.`} +
      +
      +
      + +
      +
      + +
      +
      +
      Ask
      +
      + ${isDefaults ? "Default prompt policy." : `Default: ${defaults.ask}.`} +
      +
      +
      + +
      +
      + +
      +
      +
      Ask fallback
      +
      + ${ + isDefaults + ? "Applied when the UI prompt is unavailable." + : `Default: ${defaults.askFallback}.` + } +
      +
      +
      + +
      +
      + +
      +
      +
      Auto-allow skill CLIs
      +
      + ${ + isDefaults + ? "Allow skill executables listed by the Gateway." + : autoIsDefault + ? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).` + : `Override (${autoEffective ? "on" : "off"}).` + } +
      +
      +
      + + ${ + !isDefaults && !autoIsDefault + ? html`` + : nothing + } +
      +
      +
      + `; +} + +function renderExecApprovalsAllowlist(state: ExecApprovalsState) { + const allowlistPath = ["agents", state.selectedScope, "allowlist"]; + const entries = state.allowlist; + return html` +
      +
      +
      Allowlist
      +
      Case-insensitive glob patterns.
      +
      + +
      +
      + ${ + entries.length === 0 + ? html` +
      No allowlist entries yet.
      + ` + : entries.map((entry, index) => renderAllowlistEntry(state, entry, index)) + } +
      + `; +} + +function renderAllowlistEntry( + state: ExecApprovalsState, + entry: ExecApprovalsAllowlistEntry, + index: number, +) { + const lastUsed = entry.lastUsedAt ? formatAgo(entry.lastUsedAt) : "never"; + const lastCommand = entry.lastUsedCommand ? clampText(entry.lastUsedCommand, 120) : null; + const lastPath = entry.lastResolvedPath ? clampText(entry.lastResolvedPath, 120) : null; + return html` +
      +
      +
      ${entry.pattern?.trim() ? entry.pattern : "New pattern"}
      +
      Last used: ${lastUsed}
      + ${lastCommand ? html`
      ${lastCommand}
      ` : nothing} + ${lastPath ? html`
      ${lastPath}
      ` : nothing} +
      +
      + + +
      +
      + `; +} + +function renderAgentBinding(agent: BindingAgent, state: BindingState) { + const bindingValue = agent.binding ?? "__default__"; + const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id; + const supportsBinding = state.nodes.length > 0; + return html` +
      +
      +
      ${label}
      +
      + ${agent.isDefault ? "default agent" : "agent"} · + ${ + bindingValue === "__default__" + ? `uses default (${state.defaultBinding ?? "any"})` + : `override: ${agent.binding}` + } +
      +
      +
      + +
      +
      + `; +} + +function resolveExecNodes(nodes: Array>): BindingNode[] { + const list: BindingNode[] = []; + for (const node of nodes) { + const commands = Array.isArray(node.commands) ? node.commands : []; + const supports = commands.some((cmd) => String(cmd) === "system.run"); + if (!supports) continue; + const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : ""; + if (!nodeId) continue; + const displayName = + typeof node.displayName === "string" && node.displayName.trim() + ? node.displayName.trim() + : nodeId; + list.push({ + id: nodeId, + label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`, + }); + } + list.sort((a, b) => a.label.localeCompare(b.label)); + return list; +} + +function resolveExecApprovalsNodes( + nodes: Array>, +): ExecApprovalsTargetNode[] { + const list: ExecApprovalsTargetNode[] = []; + for (const node of nodes) { + const commands = Array.isArray(node.commands) ? node.commands : []; + const supports = commands.some( + (cmd) => + String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set", + ); + if (!supports) continue; + const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : ""; + if (!nodeId) continue; + const displayName = + typeof node.displayName === "string" && node.displayName.trim() + ? node.displayName.trim() + : nodeId; + list.push({ + id: nodeId, + label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`, + }); + } + list.sort((a, b) => a.label.localeCompare(b.label)); + return list; +} + +function resolveAgentBindings(config: Record | null): { + defaultBinding?: string | null; + agents: BindingAgent[]; +} { + const fallbackAgent: BindingAgent = { + id: "main", + name: undefined, + index: 0, + isDefault: true, + binding: null, + }; + if (!config || typeof config !== "object") { + return { defaultBinding: null, agents: [fallbackAgent] }; + } + const tools = (config.tools ?? {}) as Record; + const exec = (tools.exec ?? {}) as Record; + const defaultBinding = + typeof exec.node === "string" && exec.node.trim() ? exec.node.trim() : null; + + const agentsNode = (config.agents ?? {}) as Record; + const list = Array.isArray(agentsNode.list) ? agentsNode.list : []; + if (list.length === 0) { + return { defaultBinding, agents: [fallbackAgent] }; + } + + const agents: BindingAgent[] = []; + list.forEach((entry, index) => { + if (!entry || typeof entry !== "object") return; + const record = entry as Record; + const id = typeof record.id === "string" ? record.id.trim() : ""; + if (!id) return; + const name = typeof record.name === "string" ? record.name.trim() : undefined; + const isDefault = record.default === true; + const toolsEntry = (record.tools ?? {}) as Record; + const execEntry = (toolsEntry.exec ?? {}) as Record; + const binding = + typeof execEntry.node === "string" && execEntry.node.trim() ? execEntry.node.trim() : null; + agents.push({ + id, + name: name || undefined, + index, + isDefault, + binding, + }); + }); + + if (agents.length === 0) { + agents.push(fallbackAgent); + } + + return { defaultBinding, agents }; +} + +function renderNode(node: Record) { + const connected = Boolean(node.connected); + const paired = Boolean(node.paired); + const title = + (typeof node.displayName === "string" && node.displayName.trim()) || + (typeof node.nodeId === "string" ? node.nodeId : "unknown"); + const caps = Array.isArray(node.caps) ? (node.caps as unknown[]) : []; + const commands = Array.isArray(node.commands) ? (node.commands as unknown[]) : []; + return html` +
      +
      +
      ${title}
      +
      + ${typeof node.nodeId === "string" ? node.nodeId : ""} + ${typeof node.remoteIp === "string" ? ` · ${node.remoteIp}` : ""} + ${typeof node.version === "string" ? ` · ${node.version}` : ""} +
      +
      + ${paired ? "paired" : "unpaired"} + + ${connected ? "connected" : "offline"} + + ${caps.slice(0, 12).map((c) => html`${String(c)}`)} + ${commands.slice(0, 8).map((c) => html`${String(c)}`)} +
      +
      +
      + `; +} diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e0d1da410eebe1b5069a8bb6fd520d260be5834 --- /dev/null +++ b/ui/src/ui/views/overview.ts @@ -0,0 +1,255 @@ +import { html } from "lit"; +import type { GatewayHelloOk } from "../gateway"; +import type { UiSettings } from "../storage"; +import { formatAgo, formatDurationMs } from "../format"; +import { formatNextRun } from "../presenter"; + +export type OverviewProps = { + connected: boolean; + hello: GatewayHelloOk | null; + settings: UiSettings; + password: string; + lastError: string | null; + presenceCount: number; + sessionsCount: number | null; + cronEnabled: boolean | null; + cronNext: number | null; + lastChannelsRefresh: number | null; + onSettingsChange: (next: UiSettings) => void; + onPasswordChange: (next: string) => void; + onSessionKeyChange: (next: string) => void; + onConnect: () => void; + onRefresh: () => void; +}; + +export function renderOverview(props: OverviewProps) { + const snapshot = props.hello?.snapshot as + | { uptimeMs?: number; policy?: { tickIntervalMs?: number } } + | undefined; + const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : "n/a"; + const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a"; + const authHint = (() => { + if (props.connected || !props.lastError) return null; + const lower = props.lastError.toLowerCase(); + const authFailed = lower.includes("unauthorized") || lower.includes("connect failed"); + if (!authFailed) return null; + const hasToken = Boolean(props.settings.token.trim()); + const hasPassword = Boolean(props.password.trim()); + if (!hasToken && !hasPassword) { + return html` +
      + This gateway requires auth. Add a token or password, then click Connect. +
      + openclaw dashboard --no-open → tokenized URL
      + openclaw doctor --generate-gateway-token → set token +
      + +
      + `; + } + return html` +
      + Auth failed. Re-copy a tokenized URL with + openclaw dashboard --no-open, or update the token, then click Connect. + +
      + `; + })(); + const insecureContextHint = (() => { + if (props.connected || !props.lastError) return null; + const isSecureContext = typeof window !== "undefined" ? window.isSecureContext : true; + if (isSecureContext !== false) return null; + const lower = props.lastError.toLowerCase(); + if (!lower.includes("secure context") && !lower.includes("device identity required")) { + return null; + } + return html` +
      + This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open + http://127.0.0.1:18789 on the gateway host. +
      + If you must stay on HTTP, set + gateway.controlUi.allowInsecureAuth: true (token-only). +
      + +
      + `; + })(); + + return html` +
      +
      +
      Gateway Access
      +
      Where the dashboard connects and how it authenticates.
      +
      + + + + +
      +
      + + + Click Connect to apply connection changes. +
      +
      + +
      +
      Snapshot
      +
      Latest gateway handshake information.
      +
      +
      +
      Status
      +
      + ${props.connected ? "Connected" : "Disconnected"} +
      +
      +
      +
      Uptime
      +
      ${uptime}
      +
      +
      +
      Tick Interval
      +
      ${tick}
      +
      +
      +
      Last Channels Refresh
      +
      + ${props.lastChannelsRefresh ? formatAgo(props.lastChannelsRefresh) : "n/a"} +
      +
      +
      + ${ + props.lastError + ? html`
      +
      ${props.lastError}
      + ${authHint ?? ""} + ${insecureContextHint ?? ""} +
      ` + : html` +
      + Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage. +
      + ` + } +
      +
      + +
      +
      +
      Instances
      +
      ${props.presenceCount}
      +
      Presence beacons in the last 5 minutes.
      +
      +
      +
      Sessions
      +
      ${props.sessionsCount ?? "n/a"}
      +
      Recent session keys tracked by the gateway.
      +
      +
      +
      Cron
      +
      + ${props.cronEnabled == null ? "n/a" : props.cronEnabled ? "Enabled" : "Disabled"} +
      +
      Next wake ${formatNextRun(props.cronNext)}
      +
      +
      + +
      +
      Notes
      +
      Quick reminders for remote control setups.
      +
      +
      +
      Tailscale serve
      +
      + Prefer serve mode to keep the gateway on loopback with tailnet auth. +
      +
      +
      +
      Session hygiene
      +
      Use /new or sessions.patch to reset context.
      +
      +
      +
      Cron reminders
      +
      Use isolated sessions for recurring runs.
      +
      +
      +
      + `; +} diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc6863a38c59b7af806492f6fc03d189712cf37c --- /dev/null +++ b/ui/src/ui/views/sessions.ts @@ -0,0 +1,267 @@ +import { html, nothing } from "lit"; +import type { GatewaySessionRow, SessionsListResult } from "../types"; +import { formatAgo } from "../format"; +import { pathForTab } from "../navigation"; +import { formatSessionTokens } from "../presenter"; + +export type SessionsProps = { + loading: boolean; + result: SessionsListResult | null; + error: string | null; + activeMinutes: string; + limit: string; + includeGlobal: boolean; + includeUnknown: boolean; + basePath: string; + onFiltersChange: (next: { + activeMinutes: string; + limit: string; + includeGlobal: boolean; + includeUnknown: boolean; + }) => void; + onRefresh: () => void; + onPatch: ( + key: string, + patch: { + label?: string | null; + thinkingLevel?: string | null; + verboseLevel?: string | null; + reasoningLevel?: string | null; + }, + ) => void; + onDelete: (key: string) => void; +}; + +const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high"] as const; +const BINARY_THINK_LEVELS = ["", "off", "on"] as const; +const VERBOSE_LEVELS = [ + { value: "", label: "inherit" }, + { value: "off", label: "off (explicit)" }, + { value: "on", label: "on" }, +] as const; +const REASONING_LEVELS = ["", "off", "on", "stream"] as const; + +function normalizeProviderId(provider?: string | null): string { + if (!provider) return ""; + const normalized = provider.trim().toLowerCase(); + if (normalized === "z.ai" || normalized === "z-ai") return "zai"; + return normalized; +} + +function isBinaryThinkingProvider(provider?: string | null): boolean { + return normalizeProviderId(provider) === "zai"; +} + +function resolveThinkLevelOptions(provider?: string | null): readonly string[] { + return isBinaryThinkingProvider(provider) ? BINARY_THINK_LEVELS : THINK_LEVELS; +} + +function resolveThinkLevelDisplay(value: string, isBinary: boolean): string { + if (!isBinary) return value; + if (!value || value === "off") return value; + return "on"; +} + +function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string | null { + if (!value) return null; + if (!isBinary) return value; + if (value === "on") return "low"; + return value; +} + +export function renderSessions(props: SessionsProps) { + const rows = props.result?.sessions ?? []; + return html` +
      +
      +
      +
      Sessions
      +
      Active session keys and per-session overrides.
      +
      + +
      + +
      + + + + +
      + + ${ + props.error + ? html`
      ${props.error}
      ` + : nothing + } + +
      + ${props.result ? `Store: ${props.result.path}` : ""} +
      + +
      +
      +
      Key
      +
      Label
      +
      Kind
      +
      Updated
      +
      Tokens
      +
      Thinking
      +
      Verbose
      +
      Reasoning
      +
      Actions
      +
      + ${ + rows.length === 0 + ? html` +
      No sessions found.
      + ` + : rows.map((row) => + renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading), + ) + } +
      +
      + `; +} + +function renderRow( + row: GatewaySessionRow, + basePath: string, + onPatch: SessionsProps["onPatch"], + onDelete: SessionsProps["onDelete"], + disabled: boolean, +) { + const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a"; + const rawThinking = row.thinkingLevel ?? ""; + const isBinaryThinking = isBinaryThinkingProvider(row.modelProvider); + const thinking = resolveThinkLevelDisplay(rawThinking, isBinaryThinking); + const thinkLevels = resolveThinkLevelOptions(row.modelProvider); + const verbose = row.verboseLevel ?? ""; + const reasoning = row.reasoningLevel ?? ""; + const displayName = row.displayName ?? row.key; + const canLink = row.kind !== "global"; + const chatUrl = canLink + ? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}` + : null; + + return html` +
      +
      ${ + canLink ? html`${displayName}` : displayName + }
      +
      + { + const value = (e.target as HTMLInputElement).value.trim(); + onPatch(row.key, { label: value || null }); + }} + /> +
      +
      ${row.kind}
      +
      ${updated}
      +
      ${formatSessionTokens(row)}
      +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
      + `; +} diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts new file mode 100644 index 0000000000000000000000000000000000000000..b799518a78bae80ec4fe04a67e2542ff25342699 --- /dev/null +++ b/ui/src/ui/views/skills.ts @@ -0,0 +1,190 @@ +import { html, nothing } from "lit"; +import type { SkillMessageMap } from "../controllers/skills"; +import type { SkillStatusEntry, SkillStatusReport } from "../types"; +import { clampText } from "../format"; + +export type SkillsProps = { + loading: boolean; + report: SkillStatusReport | null; + error: string | null; + filter: string; + edits: Record; + busyKey: string | null; + messages: SkillMessageMap; + onFilterChange: (next: string) => void; + onRefresh: () => void; + onToggle: (skillKey: string, enabled: boolean) => void; + onEdit: (skillKey: string, value: string) => void; + onSaveKey: (skillKey: string) => void; + onInstall: (skillKey: string, name: string, installId: string) => void; +}; + +export function renderSkills(props: SkillsProps) { + const skills = props.report?.skills ?? []; + const filter = props.filter.trim().toLowerCase(); + const filtered = filter + ? skills.filter((skill) => + [skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter), + ) + : skills; + + return html` +
      +
      +
      +
      Skills
      +
      Bundled, managed, and workspace skills.
      +
      + +
      + +
      + +
      ${filtered.length} shown
      +
      + + ${ + props.error + ? html`
      ${props.error}
      ` + : nothing + } + + ${ + filtered.length === 0 + ? html` +
      No skills found.
      + ` + : html` +
      + ${filtered.map((skill) => renderSkill(skill, props))} +
      + ` + } +
      + `; +} + +function renderSkill(skill: SkillStatusEntry, props: SkillsProps) { + const busy = props.busyKey === skill.skillKey; + const apiKey = props.edits[skill.skillKey] ?? ""; + const message = props.messages[skill.skillKey] ?? null; + const canInstall = skill.install.length > 0 && skill.missing.bins.length > 0; + const missing = [ + ...skill.missing.bins.map((b) => `bin:${b}`), + ...skill.missing.env.map((e) => `env:${e}`), + ...skill.missing.config.map((c) => `config:${c}`), + ...skill.missing.os.map((o) => `os:${o}`), + ]; + const reasons: string[] = []; + if (skill.disabled) reasons.push("disabled"); + if (skill.blockedByAllowlist) reasons.push("blocked by allowlist"); + return html` +
      +
      +
      + ${skill.emoji ? `${skill.emoji} ` : ""}${skill.name} +
      +
      ${clampText(skill.description, 140)}
      +
      + ${skill.source} + + ${skill.eligible ? "eligible" : "blocked"} + + ${ + skill.disabled + ? html` + disabled + ` + : nothing + } +
      + ${ + missing.length > 0 + ? html` +
      + Missing: ${missing.join(", ")} +
      + ` + : nothing + } + ${ + reasons.length > 0 + ? html` +
      + Reason: ${reasons.join(", ")} +
      + ` + : nothing + } +
      +
      +
      + + ${ + canInstall + ? html`` + : nothing + } +
      + ${ + message + ? html`
      + ${message.message} +
      ` + : nothing + } + ${ + skill.primaryEnv + ? html` +
      + API key + + props.onEdit(skill.skillKey, (e.target as HTMLInputElement).value)} + /> +
      + + ` + : nothing + } +
      +
      + `; +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..85d70e937c6bcce4ff7779647880192ffeccce59 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "experimentalDecorators": true, + "skipLibCheck": true, + "types": ["vite/client"], + "useDefineForClassFields": false + }, + "include": ["src"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..39ffdd111436d20e34aebeb1dd174a5c5b005757 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,35 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +function normalizeBase(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return "/"; + if (trimmed === "./") return "./"; + if (trimmed.endsWith("/")) return trimmed; + return `${trimmed}/`; +} + +export default defineConfig(({ command }) => { + const envBase = process.env.OPENCLAW_CONTROL_UI_BASE_PATH?.trim(); + const base = envBase ? normalizeBase(envBase) : "./"; + return { + base, + publicDir: path.resolve(here, "public"), + optimizeDeps: { + include: ["lit/directives/repeat.js"], + }, + build: { + outDir: path.resolve(here, "../dist/control-ui"), + emptyOutDir: true, + sourcemap: true, + }, + server: { + host: true, + port: 5173, + strictPort: true, + }, + }; +}); diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..38d7342ff216ab57d698d62212e5785abc2eef14 --- /dev/null +++ b/ui/vitest.config.ts @@ -0,0 +1,15 @@ +import { playwright } from "@vitest/browser-playwright"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: "chromium", name: "chromium" }], + headless: true, + ui: false, + }, + }, +});