chore: save working state before bind-mount setup (pre tiger write access)

This commit is contained in:
Manohar Gupta 2026-04-25 20:05:51 +00:00
parent 03ae4072d9
commit 76620da6b2
36 changed files with 3476 additions and 680 deletions

View file

@ -21,7 +21,7 @@ if (!fs.existsSync(DATA_DIR)) {
} }
const DB_PATH = path.join(DATA_DIR, "tiger.db"); const DB_PATH = path.join(DATA_DIR, "tiger.db");
const db = new Database(DB_PATH); const db: Database.Database = new Database(DB_PATH);
// Enable WAL mode for better concurrency // Enable WAL mode for better concurrency
db.pragma("journal_mode = WAL"); db.pragma("journal_mode = WAL");

View file

@ -30,11 +30,13 @@ import statusRouter from "./routes/status.js";
import logsRouter from "./routes/logs.js"; import logsRouter from "./routes/logs.js";
import execRouter from "./routes/exec.js"; import execRouter from "./routes/exec.js";
import configRouter from "./routes/config.js"; import configRouter from "./routes/config.js";
import modelsRouter from "./routes/models.js";
import restartRouter from "./routes/restart.js"; import restartRouter from "./routes/restart.js";
import filesRouter from "./routes/files.js"; import filesRouter from "./routes/files.js";
import projectsRouter from "./routes/projects.js"; import projectsRouter from "./routes/projects.js";
import tasksRouter from "./routes/tasks.js"; import tasksRouter from "./routes/tasks.js";
import dispatchRouter from "./routes/dispatch.js"; import dispatchRouter from "./routes/dispatch.js";
import agentsRouter from "./routes/agents.js";
import { initWatcher } from "./watcher.js"; import { initWatcher } from "./watcher.js";
// Import db to ensure it's initialized // Import db to ensure it's initialized
@ -77,6 +79,7 @@ app.use("/tiger/status", statusRouter);
app.use("/tiger/logs", logsRouter); // SSE stream app.use("/tiger/logs", logsRouter); // SSE stream
app.use("/tiger/exec", execRouter); app.use("/tiger/exec", execRouter);
app.use("/tiger/config", configRouter); app.use("/tiger/config", configRouter);
app.use("/tiger/config/models", modelsRouter);
app.use("/tiger/restart", restartRouter); app.use("/tiger/restart", restartRouter);
app.use("/tiger/workspace", filesRouter); app.use("/tiger/workspace", filesRouter);
app.use("/tiger/files", filesRouter); // Same router handles both /workspace and /files/:path app.use("/tiger/files", filesRouter); // Same router handles both /workspace and /files/:path
@ -85,6 +88,7 @@ app.use("/tiger/files", filesRouter); // Same router handles both /workspace an
app.use("/tiger/projects", projectsRouter); app.use("/tiger/projects", projectsRouter);
app.use("/tiger/tasks", tasksRouter); app.use("/tiger/tasks", tasksRouter);
app.use("/tiger/dispatch", dispatchRouter); app.use("/tiger/dispatch", dispatchRouter);
app.use("/tiger/agents", agentsRouter);
app.use("/tiger/chat", (await import("./routes/chat.js")).default); app.use("/tiger/chat", (await import("./routes/chat.js")).default);
// Gateway proxy — forwards to gateway inside Tiger container // Gateway proxy — forwards to gateway inside Tiger container

181
bridge/src/routes/agents.ts Normal file
View file

@ -0,0 +1,181 @@
/**
* agents.ts Per-agent workspace file browser + activity feed
*/
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
const AGENTS = [
{ id: "main", name: "Tiger", emoji: "🐯", role: "orchestrator", basePath: "/home/node/.openclaw/workspace" },
{ id: "coder", name: "Cody", emoji: "👷", role: "Coder", basePath: "/home/node/.openclaw/agents/coder" },
{ id: "researcher", name: "Ethan", emoji: "🔍", role: "Researcher", basePath: "/home/node/.openclaw/agents/researcher" },
{ id: "writer", name: "Cathy", emoji: "✍️", role: "Writer", basePath: "/home/node/.openclaw/agents/writer" },
{ id: "pm", name: "Elon", emoji: "✅", role: "PM", basePath: "/home/node/.openclaw/agents/pm" },
];
function getAgent(id: string) {
return AGENTS.find((a) => a.id === id) ?? null;
}
function isSafePath(p: string): boolean {
return !p.includes("..") && !p.startsWith("/");
}
// GET /tiger/agents
router.get("/", async (_req: Request, res: Response) => {
try {
const results = await Promise.all(
AGENTS.map(async (agent) => {
const { stdout } = await execInSandbox(
`find ${agent.basePath} -type f -printf '%T@\n' 2>/dev/null | sort -rn`
);
const mtimes = stdout.split("\n").filter(Boolean).map(Number);
return {
id: agent.id, name: agent.name, emoji: agent.emoji, role: agent.role,
fileCount: mtimes.length,
lastActivity: mtimes.length > 0 ? Math.floor(mtimes[0] * 1000) : 0,
};
})
);
res.json({ ok: true, agents: results });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /tiger/agents/:id/files?path=deliverables
router.get("/:id/files", async (req: Request, res: Response) => {
const agent = getAgent(req.params.id);
if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" });
const relPath = (req.query.path as string) || "";
if (relPath && !isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" });
const targetDir = relPath ? `${agent.basePath}/${relPath}` : agent.basePath;
const dirName = targetDir.split("/").pop() ?? "";
try {
const { stdout } = await execInSandbox(
`find ${targetDir} -maxdepth 1 -printf '%y|%s|%T@|%f\n' 2>/dev/null | sort`
);
const items = stdout
.split("\n")
.filter(Boolean)
.map((line) => {
const [typeChar, sizeStr, mtimeStr, ...rest] = line.split("|");
const name = rest.join("|");
return {
name,
type: typeChar === "d" ? "dir" as const : "file" as const,
size: parseInt(sizeStr) || 0,
modifiedAt: Math.floor(parseFloat(mtimeStr) * 1000),
};
})
.filter((f) => f.name !== "." && f.name !== dirName);
res.json({ ok: true, items });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /tiger/agents/:id/file?path=deliverables/ev-dashboard.html
router.get("/:id/file", async (req: Request, res: Response) => {
const agent = getAgent(req.params.id);
if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" });
const relPath = req.query.path as string;
if (!relPath) return res.status(400).json({ ok: false, error: "Missing path param" });
if (!isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" });
const fullPath = `${agent.basePath}/${relPath}`;
try {
const { stdout: sizeOut } = await execInSandbox(`stat -c%s ${fullPath} 2>/dev/null || echo 0`);
const size = parseInt(sizeOut.trim()) || 0;
if (size > 5 * 1024 * 1024) return res.status(413).json({ ok: false, error: "File too large (> 5MB)" });
const { stdout: mimeOut } = await execInSandbox(`file --mime-type -b ${fullPath} 2>/dev/null`);
const mime = mimeOut.trim();
const isText = mime.startsWith("text/") || mime.includes("json") || mime.includes("xml") || mime.includes("javascript");
if (!isText && size > 0) {
const { stdout: b64 } = await execInSandbox(`base64 -w0 ${fullPath} 2>/dev/null`);
return res.json({ ok: true, path: relPath, content: b64, encoding: "base64", size, mime });
}
const { stdout: content, exitCode } = await execInSandbox(`cat ${fullPath} 2>/dev/null`);
if (exitCode !== 0) return res.status(404).json({ ok: false, error: "File not found" });
res.json({ ok: true, path: relPath, content, encoding: "utf8", size, mime });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /tiger/agents/activity?limit=50
router.get("/activity", async (req: Request, res: Response) => {
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
try {
const agentPaths = AGENTS.map((a) => a.basePath).join(" ");
const { stdout } = await execInSandbox(
`find ${agentPaths} -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -${limit}`
);
const events = stdout
.split("\n")
.filter(Boolean)
.map((line) => {
const spaceIdx = line.indexOf(" ");
const ts = Math.floor(parseFloat(line.slice(0, spaceIdx)) * 1000);
const fullPath = line.slice(spaceIdx + 1);
const agent = AGENTS.find((a) => fullPath.startsWith(a.basePath)) ?? null;
if (!agent) return null;
const relPath = fullPath.slice(agent.basePath.length + 1);
return { agentId: agent.id, agentName: agent.name, agentEmoji: agent.emoji, path: relPath, action: "modified", ts };
})
.filter(Boolean);
res.json({ ok: true, events });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// PUT /tiger/agents/:id/file?path=... — write file contents back into container
// Body: { content: string }
router.put("/:id/file", async (req: Request, res: Response) => {
const agent = getAgent(req.params.id);
if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" });
const relPath = req.query.path as string;
if (!relPath) return res.status(400).json({ ok: false, error: "Missing path param" });
if (!isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" });
const { content } = req.body as { content?: string };
if (typeof content !== "string") {
return res.status(400).json({ ok: false, error: "Body must be { content: string }" });
}
const fullPath = `${agent.basePath}/${relPath}`;
try {
// Write via stdin to avoid shell quoting issues with special characters.
// We base64-encode the content on the Node side, pipe it in, and decode inside the container.
const b64 = Buffer.from(content, "utf-8").toString("base64");
const { exitCode, stderr } = await execInSandbox(
`echo '${b64}' | base64 -d > ${fullPath}`
);
if (exitCode !== 0) {
return res.status(500).json({ ok: false, error: "Write failed", details: stderr });
}
res.json({ ok: true, path: relPath });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;

View file

@ -19,7 +19,7 @@ import db from "../db.js";
// The main Tiger session — matches the hardcoded session in chat.send below. // The main Tiger session — matches the hardcoded session in chat.send below.
// Keep this constant in sync with the --session-id used by openclaw agent. // Keep this constant in sync with the --session-id used by openclaw agent.
const DEFAULT_SESSION_ID = "c1e6a067-7ca5-423b-9506-105db0702997"; const DEFAULT_SESSION_ID = "agent:main:main";
const insertMessage = db.prepare(` const insertMessage = db.prepare(`
INSERT INTO chat_messages (session_id, role, content, meta) INSERT INTO chat_messages (session_id, role, content, meta)
@ -97,12 +97,12 @@ router.post("/", async (req, res) => {
const escapedMessage = message.replace(/'/g, "'\\''"); const escapedMessage = message.replace(/'/g, "'\\''");
// Use openclaw agent to send a message to the main session // Use openclaw agent to send a message to the main session
// Session ID: c1e6a067-7ca5-423b-9506-105db0702997 (agent:main:main) // Session ID: agent:main:main (agent:main:main)
// In TIGER_REMOTE mode, prefix with ssh so docker runs on the VPS. // In TIGER_REMOTE mode, prefix with ssh so docker runs on the VPS.
const sshPrefix = process.env.TIGER_REMOTE === "true" const sshPrefix = process.env.TIGER_REMOTE === "true"
? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} ` ? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} `
: ""; : "";
const cmd = `${sshPrefix}docker exec tiger-openclaw openclaw agent --session-id c1e6a067-7ca5-423b-9506-105db0702997 -m '${escapedMessage}' --json --timeout 120`; const cmd = `${sshPrefix}docker exec tiger-openclaw openclaw agent --session-id agent:main:main -m '${escapedMessage}' --json --timeout 120`;
const tBeforeSpawn = Date.now(); const tBeforeSpawn = Date.now();
tSpawn = tBeforeSpawn - tStart; tSpawn = tBeforeSpawn - tStart;
@ -167,4 +167,31 @@ router.post("/", async (req, res) => {
} }
}); });
// ─── POST /tiger/chat/persist ─────────────────────────────────────────────
// Write-only endpoint used by the new WS-based dashboard chat route.
// The dashboard streams events directly from the OpenClaw gateway (no docker exec),
// but we still want chat history to land in our sqlite so the dashboard's
// history UI keeps working. Dashboard calls this AFTER its stream completes.
//
// Body: { role: "user"|"agent", content: string, meta?: object, sessionId?: string }
router.post("/persist", (req, res) => {
const { role, content, meta, sessionId } = req.body || {};
if (role !== "user" && role !== "agent") {
return res.status(400).json({ ok: false, error: "role must be 'user' or 'agent'" });
}
if (typeof content !== "string" || !content) {
return res.status(400).json({ ok: false, error: "content is required" });
}
try {
const sid = (typeof sessionId === "string" && sessionId) || DEFAULT_SESSION_ID;
const metaJson = meta && typeof meta === "object" ? JSON.stringify(meta) : "{}";
const info = insertMessage.run(sid, role, content, metaJson);
res.json({ ok: true, id: String(info.lastInsertRowid), sessionId: sid });
} catch (e: any) {
console.warn("[chat.persist] failed:", e.message);
res.status(500).json({ ok: false, error: e.message });
}
});
export default router; export default router;

View file

@ -0,0 +1,197 @@
/**
* routes/chat.ts — Chat via OpenClaw CLI + persistence
*
* POST /tiger/chat — send a message; response includes reply
* GET /tiger/chat/history — ?sessionId=X&limit=50 → past messages
* DELETE /tiger/chat/history — ?sessionId=X → clear history for a session
*
* Persistence rationale (see phase1b-patches.py):
* Chat history is duplicated into our SQLite so it survives:
* - browser hard refresh
* - close/reopen tab
* - use from a different device
* - OpenClaw restarts (session state may or may not persist internally)
* We own the read path; OpenClaw owns the reasoning context.
*/
import { Router } from "express";
import db from "../db.js";
// The main Tiger session — matches the hardcoded session in chat.send below.
// Keep this constant in sync with the --session-id used by openclaw agent.
const DEFAULT_SESSION_ID = "c1e6a067-7ca5-423b-9506-105db0702997";
const insertMessage = db.prepare(`
INSERT INTO chat_messages (session_id, role, content, meta)
VALUES (?, ?, ?, ?)
`);
const getHistory = db.prepare(`
SELECT id, role, content, meta, created_at
FROM chat_messages
WHERE session_id = ?
ORDER BY created_at ASC, id ASC
LIMIT ?
`);
const deleteHistory = db.prepare(`
DELETE FROM chat_messages WHERE session_id = ?
`);
const router = Router();
// ─── GET /tiger/chat/history ─────────────────────────────────────────────
router.get("/history", (req, res) => {
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID;
const limit = Math.min(parseInt(req.query.limit as string) || 200, 500);
const rows = getHistory.all(sessionId, limit) as any[];
res.json({
ok: true,
sessionId,
count: rows.length,
messages: rows.map((r) => ({
id: String(r.id),
role: r.role,
content: r.content,
timestamp: new Date(r.created_at + "Z").getTime(),
meta: r.meta ? JSON.parse(r.meta) : {},
})),
});
});
// ─── DELETE /tiger/chat/history ──────────────────────────────────────────
router.delete("/history", (req, res) => {
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID;
const result = deleteHistory.run(sessionId);
res.json({ ok: true, deleted: result.changes });
});
// ─── POST /tiger/chat ────────────────────────────────────────────────────
router.post("/", async (req, res) => {
const { message } = req.body;
if (!message) {
return res.status(400).json({ ok: false, error: "message is required" });
}
// Persist the user's message BEFORE calling the LLM so history is intact
// even if the LLM call fails.
try {
insertMessage.run(DEFAULT_SESSION_ID, "user", message, "{}");
} catch (e: any) {
console.warn("[chat] failed to persist user message:", e.message);
}
// ── Timing instrumentation ──────────────────────────────────────
// Label each phase so we can see where latency goes. Format in logs:
// [chat.timing] spawn=120ms exec=2834ms parse=3ms total=2957ms
const tStart = Date.now();
let tSpawn = 0;
let tExec = 0;
let tParse = 0;
try {
const { exec } = await import("child_process");
const { promisify } = await import("util");
const execAsync = promisify(exec);
// Escape the message for shell
const escapedMessage = message.replace(/'/g, "'\\''");
// Use openclaw agent to send a message to the main session
// Session ID: c1e6a067-7ca5-423b-9506-105db0702997 (agent:main:main)
// In TIGER_REMOTE mode, prefix with ssh so docker runs on the VPS.
const sshPrefix = process.env.TIGER_REMOTE === "true"
? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} `
: "";
const cmd = `${sshPrefix}docker exec tiger-openclaw openclaw agent --session-id c1e6a067-7ca5-423b-9506-105db0702997 -m '${escapedMessage}' --json --timeout 120`;
const tBeforeSpawn = Date.now();
tSpawn = tBeforeSpawn - tStart;
console.log("[chat] Executing:", cmd.substring(0, 100) + "...");
const { stdout, stderr } = await execAsync(cmd, {
timeout: 130000,
maxBuffer: 10 * 1024 * 1024,
});
tExec = Date.now() - tBeforeSpawn;
console.log("[chat] Response:", stdout.substring(0, 500));
// Parse the JSON response
const tBeforeParse = Date.now();
let result;
try {
result = JSON.parse(stdout);
} catch {
result = { output: stdout, error: stderr };
}
tParse = Date.now() - tBeforeParse;
const tTotal = Date.now() - tStart;
console.log(
`[chat.timing] spawn=${tSpawn}ms exec=${tExec}ms parse=${tParse}ms total=${tTotal}ms`
);
// Persist the agent's reply. Extract text using the same fallback chain
// as the dashboard so we store whatever the user actually sees.
try {
const agentText =
result?.result?.payloads?.[0]?.text ||
result?.payloads?.[0]?.text ||
result?.summary ||
result?.text ||
"";
if (agentText) {
const meta = {
runId: result?.runId,
model: result?.result?.meta?.agentMeta?.model || result?.meta?.agentMeta?.model,
durationMs: tTotal,
};
insertMessage.run(DEFAULT_SESSION_ID, "agent", agentText, JSON.stringify(meta));
}
} catch (e: any) {
console.warn("[chat] failed to persist agent reply:", e.message);
}
res.json({
ok: true,
timing: { spawn: tSpawn, exec: tExec, parse: tParse, total: tTotal },
response: result,
});
} catch (err: any) {
const tTotal = Date.now() - tStart;
console.error(`[chat] Error after ${tTotal}ms:`, err.message);
res.status(500).json({
ok: false,
error: err.message || "Failed to send chat message",
});
}
});
// ─── POST /tiger/chat/persist ─────────────────────────────────────────────
// Write-only endpoint used by the new WS-based dashboard chat route.
// The dashboard streams events directly from the OpenClaw gateway (no docker exec),
// but we still want chat history to land in our sqlite so the dashboard's
// history UI keeps working. Dashboard calls this AFTER its stream completes.
//
// Body: { role: "user"|"agent", content: string, meta?: object, sessionId?: string }
router.post("/persist", (req, res) => {
const { role, content, meta, sessionId } = req.body || {};
if (role !== "user" && role !== "agent") {
return res.status(400).json({ ok: false, error: "role must be 'user' or 'agent'" });
}
if (typeof content !== "string" || !content) {
return res.status(400).json({ ok: false, error: "content is required" });
}
try {
const sid = (typeof sessionId === "string" && sessionId) || DEFAULT_SESSION_ID;
const metaJson = meta && typeof meta === "object" ? JSON.stringify(meta) : "{}";
const info = insertMessage.run(sid, role, content, metaJson);
res.json({ ok: true, id: String(info.lastInsertRowid), sessionId: sid });
} catch (e: any) {
console.warn("[chat.persist] failed:", e.message);
res.status(500).json({ ok: false, error: e.message });
}
});
export default router;

View file

@ -89,8 +89,8 @@ router.get("/status/:taskId", async (req, res) => {
const taskPath = `/sandbox/.openclaw-data/workspace/tasks/${dir}/task_${taskId}.json`; const taskPath = `/sandbox/.openclaw-data/workspace/tasks/${dir}/task_${taskId}.json`;
try { try {
const content = await execInSandbox(`cat ${taskPath} 2>/dev/null || true`); const content = await execInSandbox(`cat ${taskPath} 2>/dev/null || true`);
if (content && content.trim()) { if (content && content.stdout && content.stdout.trim()) {
const taskData = JSON.parse(content); const taskData = JSON.parse(content.stdout);
return res.json({ return res.json({
ok: true, ok: true,
status: dir, status: dir,

View file

@ -0,0 +1,19 @@
/**
* GET /tiger/config/models list all models Tiger knows about
* Response: { ok: true, models: [{ id, name, provider, reasoning, contextWindow }] }
*/
import { Router, Request, Response } from "express";
import { readModels } from "../tiger.js";
const router = Router();
router.get("/", async (_req: Request, res: Response) => {
try {
const models = await readModels();
res.json({ ok: true, models });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;

View file

@ -17,7 +17,9 @@ const execAsync = promisify(exec);
const DOCKER_CONTAINER = "tiger-openclaw"; const DOCKER_CONTAINER = "tiger-openclaw";
const K8S_NAMESPACE = "openshell"; const K8S_NAMESPACE = "openshell";
const POD_NAME = "tiger"; const POD_NAME = "tiger";
const OPENCLAW_CONFIG_HOST = "/root/.openclaw/openclaw.json"; // Real config lives in the Docker named volume, NOT on the host root path
const OPENCLAW_CONFIG_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json";
const OPENCLAW_MODELS_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/agents/main/agent/models.json";
const CONFIG_HASH_PATH_SANDBOX = "/sandbox/.openclaw/.config-hash"; const CONFIG_HASH_PATH_SANDBOX = "/sandbox/.openclaw/.config-hash";
const WORKSPACE_SYMLINK = "/var/lib/docker/volumes/tiger_tiger-workspace/_data"; const WORKSPACE_SYMLINK = "/var/lib/docker/volumes/tiger_tiger-workspace/_data";
const GATEWAY_WATCHDOG = "/root/gateway-watchdog.sh"; const GATEWAY_WATCHDOG = "/root/gateway-watchdog.sh";
@ -158,25 +160,30 @@ export async function getTigerStatus() {
let fallbackModels: string[] = []; let fallbackModels: string[] = [];
let availableModels: string[] = []; let availableModels: string[] = [];
try { try {
const configRaw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8"); // Read config from INSIDE the container — the host copy at
const config = JSON.parse(configRaw); // OPENCLAW_CONFIG_HOST can be stale if Tiger has updated its config live.
const { stdout: configRaw, exitCode } = await execInSandbox(
"cat /home/node/.openclaw/openclaw.json 2>/dev/null"
);
if (exitCode === 0 && configRaw) {
const config = JSON.parse(configRaw);
const agentDefaults = config?.agents?.defaults?.model; const agentDefaults = config?.agents?.defaults?.model;
if (typeof agentDefaults === "string") { if (typeof agentDefaults === "string") {
currentModel = agentDefaults; currentModel = agentDefaults;
} else if (agentDefaults && typeof agentDefaults === "object") { } else if (agentDefaults && typeof agentDefaults === "object") {
currentModel = agentDefaults.primary || "unknown"; currentModel = agentDefaults.primary || "unknown";
fallbackModels = Array.isArray(agentDefaults.fallbacks) ? agentDefaults.fallbacks : []; fallbackModels = Array.isArray(agentDefaults.fallbacks) ? agentDefaults.fallbacks : [];
} }
// Also surface all configured provider/model IDs so the UI shows what's // Surface available models from models.providers section
// available, not just what's selected. Format: "provider/model-id" const providers = config?.models?.providers || config?.providers || {};
const providers = config?.providers || {}; for (const [provName, provCfg] of Object.entries<any>(providers)) {
for (const [provName, provCfg] of Object.entries<any>(providers)) { const models = (provCfg as any)?.models;
const models = provCfg?.models; if (Array.isArray(models)) {
if (Array.isArray(models)) { for (const m of models) {
for (const m of models) { if (m?.id) availableModels.push(`${provName}/${m.id}`);
if (m?.id) availableModels.push(`${provName}/${m.id}`); }
} }
} }
} }
@ -224,23 +231,55 @@ export async function getConfig(): Promise<Record<string, any>> {
* Previously this was a manual step that caused repeated failures. * Previously this was a manual step that caused repeated failures.
*/ */
export async function updateConfig(patch: Record<string, any>): Promise<void> { export async function updateConfig(patch: Record<string, any>): Promise<void> {
// 1. Read current config // 1. Read current config from the Docker volume (the real runtime config)
const current = await getConfig(); const current = await getConfig();
// 2. Deep merge the patch (shallow for now, can enhance later) // 2. Deep-merge the patch
const merged = deepMerge(current, patch); const merged = deepMerge(current, patch);
const configStr = JSON.stringify(merged, null, 2); const configStr = JSON.stringify(merged, null, 2);
// 3. Backup current config before writing // 3. Backup before writing (in the volume directory)
const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} /root/.openclaw/backups/openclaw-${timestamp}.json`); const backupPath = OPENCLAW_CONFIG_HOST.replace("openclaw.json", `openclaw-${timestamp}.bak.json`);
await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} ${backupPath} 2>/dev/null || true`);
// 4. Write updated config // 4. Write back to the volume file — no hash regeneration needed in OpenClaw v2026
await writeFile(OPENCLAW_CONFIG_HOST, configStr, "utf-8"); await writeFile(OPENCLAW_CONFIG_HOST, configStr, "utf-8");
}
// 5. Regenerate config hash — the step that was always forgotten!
const hash = createHash("sha256").update(configStr).digest("hex"); /**
await execInSandbox(`echo '${hash}' > ${CONFIG_HASH_PATH_SANDBOX}`); * Read the available models list from the agent models registry.
* Returns an array of { id, name, provider, reasoning, contextWindow } objects.
*/
export async function readModels(): Promise<{
id: string; name: string; provider: string;
reasoning: boolean; contextWindow: number; cost?: { input: number; output: number }
}[]> {
try {
const raw = await readFile(OPENCLAW_MODELS_HOST, "utf-8");
const data = JSON.parse(raw);
const results: any[] = [];
const providers: Record<string, any> = data?.providers ?? {};
for (const [provName, provCfg] of Object.entries<any>(providers)) {
for (const model of (provCfg.models ?? [])) {
const rawId = model.id as string;
// Normalise to "provider/id" form
const id = rawId.includes("/") ? rawId : `${provName}/${rawId}`;
results.push({
id,
name: model.name ?? rawId,
provider: provName,
reasoning: model.reasoning ?? false,
contextWindow: model.contextWindow ?? 0,
cost: model.cost,
});
}
}
return results;
} catch {
return [];
}
} }
/** Deep merge helper — second object wins on conflicts */ /** Deep merge helper — second object wins on conflicts */

42
dashboard/lib_smoke.ts Normal file
View file

@ -0,0 +1,42 @@
/**
* Smoke test for the new openclaw-ws.ts library.
* Tests:
* 1. callGateway with sessions.list verify non-streaming RPC works
* 2. streamAgentRun on agent:main:main verify chunks arrive in real time
* 3. streamAgentRun on a NEW sessionKey verify isolation
*/
import { callGateway, streamAgentRun, newSessionKey } from "./src/lib/openclaw-ws.js";
async function main() {
process.env.OPENCLAW_GATEWAY_TOKEN = "c5996580041c8f117532462877c34996d5563ef7a571a2b42913ee53d8fdfa6d";
console.log("=== TEST 1: sessions.list ===");
const r = await callGateway("sessions.list", {});
console.log("ok:", r.ok, "count:", (r.payload as any)?.sessions?.length);
((r.payload as any)?.sessions || []).forEach((s: any) => console.log(" -", s.key, "|", s.displayName));
console.log("\n=== TEST 2: streamAgentRun on agent:main:main ===");
const t0 = Date.now();
let chunks = 0;
for await (const ev of streamAgentRun({
sessionKey: "agent:main:main",
message: "Reply with exactly the word PONG. Nothing else.",
})) {
if (ev.kind === "chunk") chunks++;
console.log(` +${Date.now()-t0}ms ${ev.kind}: ${ev.content.slice(0,60)}`);
}
console.log(` total chunks: ${chunks}`);
console.log("\n=== TEST 3: streamAgentRun on NEW sessionKey ===");
const newKey = newSessionKey();
console.log("new sessionKey:", newKey);
for await (const ev of streamAgentRun({
sessionKey: newKey,
message: "What is your name? Reply briefly.",
})) {
if (ev.kind !== "status") console.log(` ${ev.kind}: ${ev.content.slice(0,80)}`);
}
console.log("DONE");
}
main().catch(e => { console.error("FAIL", e); process.exit(1); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 B

View file

@ -1,137 +1,120 @@
/** /**
* API route: POST /api/chat * /api/chat chat send endpoint, now with real WS-based streaming.
* Sends chat messages via Tiger Bridge -> OpenClaw CLI *
* Replaces the previous bridge docker exec fake-typing chain.
*
* Request: POST { message: string, sessionKey?: string }
* Response: SSE stream of `data: { type, content }` events
* types: status | chunk | done | error (matches existing client parser)
*
* Persistence: user message stored BEFORE LLM call (so it's not lost on failure);
* agent reply stored after final token via bridge /tiger/chat/persist.
*/ */
import { NextRequest, NextResponse } from "next/server"; import { NextRequest } from "next/server";
import { streamAgentRun, DEFAULT_SESSION_KEY } from "@/lib/openclaw-ws";
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"; const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""; const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
export const maxDuration = 120; export const maxDuration = 180;
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
const { message } = await request.json();
if (!message) {
return NextResponse.json({ error: "message is required" }, { status: 400 });
}
// End-to-end timing: measure the full /api/chat call so we can compare
// against the bridge's own timing (data.timing) to find overhead.
const t0 = Date.now();
async function persistMessage(role: "user" | "agent", content: string, sessionKey: string, meta?: any) {
// Best-effort persistence; never block the chat response on this.
try { try {
// Call the bridge await fetch(`${BRIDGE_URL}/tiger/chat/persist`, {
const response = await fetch(`${BRIDGE_URL}/tiger/chat`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${BRIDGE_TOKEN}`, Authorization: `Bearer ${BRIDGE_TOKEN}`,
}, },
body: JSON.stringify({ message }), body: JSON.stringify({ role, content, sessionId: sessionKey, meta: meta || {} }),
}); });
} catch (err) {
console.warn("[chat] persist failed:", role, (err as Error).message);
}
}
const tBridgeDone = Date.now(); export async function POST(request: NextRequest) {
const data = await response.json(); const body = await request.json().catch(() => ({}));
const message: string = body?.message;
const sessionKey: string = body?.sessionKey || DEFAULT_SESSION_KEY;
if (data?.timing) { if (!message || typeof message !== "string") {
console.log( return new Response(JSON.stringify({ error: "message is required" }), {
`[chat.timing] bridge: ${JSON.stringify(data.timing)} | dashboard: bridge_call=${tBridgeDone - t0}ms` status: 400, headers: { "Content-Type": "application/json" },
); });
} }
console.log("[chat] Bridge response:", JSON.stringify(data).substring(0, 500)); // Persist user message NOW, before the LLM call. If the call fails, the
// history still records what the user said.
await persistMessage("user", message, sessionKey);
if (!response.ok) { const t0 = Date.now();
return NextResponse.json( const encoder = new TextEncoder();
{ error: data.error || "Chat failed" },
{ status: response.status }
);
}
// Extract the text response - OpenClaw returns in several possible formats /**
let text = ""; * Build the SSE stream.
* The wire format `data: {"type":"chunk","content":"..."}\n\n` matches what
* chat-interface.tsx already parses. Types we emit:
* status (the "thinking" indicator on accept ack)
* chunk (each assistant delta from the gateway)
* done (terminal full text + meta, persists the agent reply)
* error (anything goes wrong, including handshake failures)
*/
const stream = new ReadableStream({
async start(controller) {
const sse = (obj: { type: string; content?: string; meta?: any }) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
};
if (data.response?.result?.payloads?.[0]?.text) { try {
text = data.response.result.payloads[0].text; let fullText = "";
} else if (data.response?.payloads?.[0]?.text) { let meta: any = undefined;
text = data.response.payloads[0].text;
} else if (data.response?.summary) {
text = data.response.summary;
} else if (data.response?.text) {
text = data.response.text;
} else if (data.text) {
text = data.text;
} else {
// Fallback: stringify the whole response for debugging
text = JSON.stringify(data);
}
console.log("[chat] Extracted text:", text.substring(0, 200)); for await (const ev of streamAgentRun({ message, sessionKey })) {
if (ev.kind === "status") {
// Return as SSE with word-by-word streaming. sse({ type: "status", content: "" });
// } else if (ev.kind === "chunk") {
// WHY SIMULATE STREAMING? fullText += ev.content;
// The bridge gives us the entire reply in one shot (LLM call completes sse({ type: "chunk", content: ev.content });
// before the process returns). That means without this code the whole } else if (ev.kind === "done") {
// answer pops in at once — feels sluggish even though the infra is fine. // Prefer the gateway's authoritative final text over our delta accumulation.
// Splitting on whitespace and drip-feeding gives the UI a "typing" feel fullText = ev.content || fullText;
// without changing the backend. Total time until done is identical. meta = ev.meta;
// sse({ type: "done", content: fullText });
// When true token-level streaming is wired in the bridge (Phase 3), we } else if (ev.kind === "error") {
// can swap this out for real chunks from openclaw's event stream. sse({ type: "error", content: ev.content });
const encoder = new TextEncoder();
const words = text.split(/(\s+)/); // keep whitespace tokens → smooth flow
// ~60 words-per-second cadence ≈ 16ms per word. Tune to taste.
const WORD_DELAY_MS = 25; // 40 wps — smooth typing feel with frame headroom
const stream = new ReadableStream({
async start(controller) {
// Send status marker first so UI can show the thinking indicator.
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: "status", content: "" })}\n\n`
)
);
// Drip-feed word tokens. Each is a "chunk" that appends to the
// streaming message bubble on the client.
for (const word of words) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: "chunk", content: word })}\n\n`
)
);
if (WORD_DELAY_MS > 0) {
await new Promise((resolve) => setTimeout(resolve, WORD_DELAY_MS));
} }
} }
// Final done event carries the full text as a safety fallback const dt = Date.now() - t0;
// (see the Bug D fix in chat-interface.tsx). console.log(`[chat] sessionKey=${sessionKey} duration=${dt}ms chars=${fullText.length}`);
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: "done", content: text })}\n\n`
)
);
// Persist the agent reply AFTER streaming is complete.
if (fullText) {
await persistMessage("agent", fullText, sessionKey, {
...meta,
durationMs: dt,
});
}
} catch (err: any) {
console.error("[chat] stream error:", err);
sse({ type: "error", content: err?.message || "stream failed" });
} finally {
controller.close(); controller.close();
}, }
}); },
});
return new Response(stream, { return new Response(stream, {
headers: { headers: {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache", "Cache-Control": "no-cache, no-transform",
Connection: "keep-alive", Connection: "keep-alive",
}, // Disable nginx-style buffering when behind a proxy.
}); "X-Accel-Buffering": "no",
} catch (err: any) { },
console.error("[chat] Error:", err.message); });
return NextResponse.json( }
{ error: "Failed to communicate with Tiger Bridge" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,137 @@
/**
* API route: POST /api/chat
* Sends chat messages via Tiger Bridge -> OpenClaw CLI
*/
import { NextRequest, NextResponse } from "next/server";
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
export const maxDuration = 120;
export async function POST(request: NextRequest) {
const { message } = await request.json();
if (!message) {
return NextResponse.json({ error: "message is required" }, { status: 400 });
}
// End-to-end timing: measure the full /api/chat call so we can compare
// against the bridge's own timing (data.timing) to find overhead.
const t0 = Date.now();
try {
// Call the bridge
const response = await fetch(`${BRIDGE_URL}/tiger/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${BRIDGE_TOKEN}`,
},
body: JSON.stringify({ message }),
});
const tBridgeDone = Date.now();
const data = await response.json();
if (data?.timing) {
console.log(
`[chat.timing] bridge: ${JSON.stringify(data.timing)} | dashboard: bridge_call=${tBridgeDone - t0}ms`
);
}
console.log("[chat] Bridge response:", JSON.stringify(data).substring(0, 500));
if (!response.ok) {
return NextResponse.json(
{ error: data.error || "Chat failed" },
{ status: response.status }
);
}
// Extract the text response - OpenClaw returns in several possible formats
let text = "";
if (data.response?.result?.payloads?.[0]?.text) {
text = data.response.result.payloads[0].text;
} else if (data.response?.payloads?.[0]?.text) {
text = data.response.payloads[0].text;
} else if (data.response?.summary) {
text = data.response.summary;
} else if (data.response?.text) {
text = data.response.text;
} else if (data.text) {
text = data.text;
} else {
// Fallback: stringify the whole response for debugging
text = JSON.stringify(data);
}
console.log("[chat] Extracted text:", text.substring(0, 200));
// Return as SSE with word-by-word streaming.
//
// WHY SIMULATE STREAMING?
// The bridge gives us the entire reply in one shot (LLM call completes
// before the process returns). That means without this code the whole
// answer pops in at once — feels sluggish even though the infra is fine.
// Splitting on whitespace and drip-feeding gives the UI a "typing" feel
// without changing the backend. Total time until done is identical.
//
// When true token-level streaming is wired in the bridge (Phase 3), we
// can swap this out for real chunks from openclaw's event stream.
const encoder = new TextEncoder();
const words = text.split(/(\s+)/); // keep whitespace tokens → smooth flow
// ~60 words-per-second cadence ≈ 16ms per word. Tune to taste.
const WORD_DELAY_MS = 25; // 40 wps — smooth typing feel with frame headroom
const stream = new ReadableStream({
async start(controller) {
// Send status marker first so UI can show the thinking indicator.
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: "status", content: "" })}\n\n`
)
);
// Drip-feed word tokens. Each is a "chunk" that appends to the
// streaming message bubble on the client.
for (const word of words) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: "chunk", content: word })}\n\n`
)
);
if (WORD_DELAY_MS > 0) {
await new Promise((resolve) => setTimeout(resolve, WORD_DELAY_MS));
}
}
// Final done event carries the full text as a safety fallback
// (see the Bug D fix in chat-interface.tsx).
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: "done", content: text })}\n\n`
)
);
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (err: any) {
console.error("[chat] Error:", err.message);
return NextResponse.json(
{ error: "Failed to communicate with Tiger Bridge" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,123 @@
/**
* /api/chat/sessions list, create, delete chat sessions.
*
* GET list webchat-eligible sessions (Main + any "agent:main:webchat-*")
* via gateway sessions.list. Returns simplified shape for the UI.
* POST mint a new session key. The session is auto-created in the
* gateway on first message (so we don't need to call anything
* here just return the key for the UI to start using).
* DELETE ?key=agent:main:webchat-xyz remove from gateway + clear sqlite history.
* The default "agent:main:main" session can never be deleted.
*/
import { NextRequest, NextResponse } from "next/server";
import { callGateway, newSessionKey, DEFAULT_SESSION_KEY } from "@/lib/openclaw-ws";
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
/** Whitelist: only "agent:main:main" + "agent:main:webchat-*" sessions are dashboard-visible. */
function isWebchatSession(key: string): boolean {
return key === DEFAULT_SESSION_KEY || key.startsWith("agent:main:webchat-");
}
/** Pretty label for the dropdown. */
function deriveLabel(key: string, displayName?: string): string {
if (key === DEFAULT_SESSION_KEY) return "Main";
if (displayName && displayName !== "undefined") return displayName;
// For "agent:main:webchat-abc12345" → "Chat abc12345"
const m = key.match(/^agent:main:webchat-(.+)$/);
if (m) return `Chat ${m[1].slice(0, 8)}`;
return key;
}
export async function GET() {
try {
// Ask gateway for ALL sessions, then filter to webchat-visible ones.
const r = await callGateway("sessions.list", {});
if (!r.ok) {
return NextResponse.json(
{ ok: false, error: "gateway sessions.list failed", details: r.error },
{ status: 502 }
);
}
const all = (r.payload as any)?.sessions || [];
const webchat = all
.filter((s: any) => isWebchatSession(s.key))
.map((s: any) => ({
key: s.key,
label: deriveLabel(s.key, s.displayName),
updatedAt: s.updatedAt || null,
messageCount: s.messageCount || 0,
isDefault: s.key === DEFAULT_SESSION_KEY,
}))
// Default first, then most-recently-updated
.sort((a: any, b: any) => {
if (a.isDefault) return -1;
if (b.isDefault) return 1;
return (b.updatedAt || 0) - (a.updatedAt || 0);
});
// Always ensure "Main" is in the list, even if gateway hasn't seen it yet
if (!webchat.find((s: any) => s.key === DEFAULT_SESSION_KEY)) {
webchat.unshift({ key: DEFAULT_SESSION_KEY, label: "Main", updatedAt: null, messageCount: 0, isDefault: true });
}
return NextResponse.json({ ok: true, sessions: webchat });
} catch (err: any) {
return NextResponse.json(
{ ok: false, error: "sessions list failed", details: err.message },
{ status: 502 }
);
}
}
export async function POST() {
// Mint a new key. Actual gateway session is created lazily on first message.
const key = newSessionKey();
return NextResponse.json({
ok: true,
session: { key, label: deriveLabel(key), updatedAt: null, messageCount: 0, isDefault: false },
});
}
export async function DELETE(request: NextRequest) {
const key = request.nextUrl.searchParams.get("key") || "";
if (!key) {
return NextResponse.json({ ok: false, error: "key query param required" }, { status: 400 });
}
if (key === DEFAULT_SESSION_KEY) {
return NextResponse.json({ ok: false, error: "the Main session cannot be deleted" }, { status: 400 });
}
if (!isWebchatSession(key)) {
return NextResponse.json({ ok: false, error: "only webchat sessions can be deleted from here" }, { status: 400 });
}
// 1. Best-effort: ask gateway to delete its session record.
// If the session has never been used (no first message yet) the gateway
// won't know about it — that's fine, we still want to clean sqlite.
let gatewayResult: { ok: boolean; error?: any } = { ok: true };
try {
gatewayResult = await callGateway("sessions.delete", { key });
} catch (err: any) {
gatewayResult = { ok: false, error: err.message };
}
// 2. Clear our sqlite history for this session via the bridge.
let bridgeResult = { ok: true } as any;
try {
const r = await fetch(
`${BRIDGE_URL}/tiger/chat/history?sessionId=${encodeURIComponent(key)}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` } }
);
bridgeResult = await r.json();
} catch (err: any) {
bridgeResult = { ok: false, error: err.message };
}
return NextResponse.json({
ok: bridgeResult.ok,
gateway: gatewayResult,
bridge: bridgeResult,
});
}

View file

@ -0,0 +1,15 @@
// GET /api/tiger/activity?limit=50 — proxy to bridge
import { NextResponse } from "next/server";
import { bridgeGet } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const limit = searchParams.get("limit") ?? "50";
try {
const result = await bridgeGet("/tiger/agents/activity", { limit });
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

View file

@ -0,0 +1,37 @@
// GET + PUT /api/tiger/agents/[id]/file?path=...
import { NextResponse } from "next/server";
import { bridgeGet, bridgePut } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const { searchParams } = new URL(request.url);
const path = searchParams.get("path") ?? "";
if (!path) return NextResponse.json({ ok: false, error: "Missing path" }, { status: 400 });
try {
const result = await bridgeGet(`/tiger/agents/${id}/file`, { path });
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const { searchParams } = new URL(request.url);
const path = searchParams.get("path") ?? "";
if (!path) return NextResponse.json({ ok: false, error: "Missing path" }, { status: 400 });
try {
const body = await request.json();
const result = await bridgePut(`/tiger/agents/${id}/file`, { path }, body as Record<string, unknown>);
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

View file

@ -0,0 +1,21 @@
// GET /api/tiger/agents/[id]/files?path=... — proxy to bridge
import { NextResponse } from "next/server";
import { bridgeGet } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const { searchParams } = new URL(request.url);
const path = searchParams.get("path") ?? "";
try {
const query: Record<string, string> = {};
if (path) query.path = path;
const result = await bridgeGet(`/tiger/agents/${id}/files`, query);
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

View file

@ -0,0 +1,13 @@
// GET /api/tiger/agents — proxy to bridge
import { NextResponse } from "next/server";
import { bridgeGet } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const result = await bridgeGet("/tiger/agents");
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

View file

@ -0,0 +1,13 @@
// GET /api/tiger/config/models — proxy to bridge
import { NextResponse } from "next/server";
import { bridgeGet } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const result = await bridgeGet("/tiger/config/models");
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="tg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f97316"/>
<stop offset="100%" stop-color="#ea580c"/>
</linearGradient>
</defs>
<!-- Dark background with rounded corners -->
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
<!-- Bold "T" with orange gradient -->
<text x="16" y="23" font-family="Arial Black, sans-serif" font-size="20" font-weight="900" fill="url(#tg)" text-anchor="middle">T</text>
<!-- Tiger stripes — left side, fading down -->
<rect x="4" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
<rect x="4" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
<rect x="4" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
<!-- Tiger stripes — right side, fading down -->
<rect x="23" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
<rect x="23" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
<rect x="23" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -22,6 +22,14 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Tiger Command Center", title: "Tiger Command Center",
description: "Tiger Agent Management Dashboard", description: "Tiger Agent Management Dashboard",
icons: {
icon: [
{ url: "/icon.svg", type: "image/svg+xml" },
{ url: "/favicon.ico", sizes: "any" },
],
shortcut: "/icon.svg",
apple: "/icon.svg",
},
}; };
export default function RootLayout({ export default function RootLayout({
@ -49,7 +57,26 @@ export default function RootLayout({
<div className="p-4 flex items-center justify-between border-b border-border bg-sidebar/50 backdrop-blur-sm sticky top-0 z-10"> <div className="p-4 flex items-center justify-between border-b border-border bg-sidebar/50 backdrop-blur-sm sticky top-0 z-10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SidebarTrigger /> <SidebarTrigger />
<h1 className="text-sm font-medium text-muted-foreground">Tiger Dashboard</h1> <div className="flex items-center gap-2">
{/* Tiger Command icon — same SVG as favicon */}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className="h-5 w-5 shrink-0" aria-hidden="true">
<defs>
<linearGradient id="hdr-tg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#f97316"/>
<stop offset="100%" stopColor="#ea580c"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
<text x="16" y="23" fontFamily="Arial Black, sans-serif" fontSize="20" fontWeight="900" fill="url(#hdr-tg)" textAnchor="middle">T</text>
<rect x="4" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
<rect x="4" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
<rect x="4" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
<rect x="23" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
<rect x="23" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
<rect x="23" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
</svg>
<span className="text-sm font-medium text-muted-foreground">Tiger Dashboard</span>
</div>
</div> </div>
<ModeToggle /> <ModeToggle />
</div> </div>

View file

@ -69,6 +69,14 @@ export default function DashboardPage() {
refreshInterval: 5000, refreshInterval: 5000,
revalidateOnFocus: true, revalidateOnFocus: true,
}) })
// Agent activity — used for "Last Activity" row in health card
const { data: agentsData } = useSWR<{ ok: boolean; agents: { lastActivity: number }[] }>(
'/api/tiger/agents', fetcher, { refreshInterval: 30000 }
)
const lastActivity = React.useMemo(() => {
const ts = agentsData?.agents?.map(a => a.lastActivity).filter(Boolean) ?? []
return ts.length > 0 ? Math.max(...ts) : 0
}, [agentsData])
const { request } = useBridgeRequest() const { request } = useBridgeRequest()
const [restarting, setRestarting] = React.useState(false) const [restarting, setRestarting] = React.useState(false)
const [restartSuccess, setRestartSuccess] = React.useState(false) const [restartSuccess, setRestartSuccess] = React.useState(false)
@ -100,7 +108,7 @@ export default function DashboardPage() {
<AlertCircle className="h-5 w-5 shrink-0" /> <AlertCircle className="h-5 w-5 shrink-0" />
<div> <div>
<span className="font-semibold">Tiger Crashed</span> <span className="font-semibold">Tiger Crashed</span>
<span className="text-sm text-red-400/80 ml-2">(exit code 255 MiniMax API unreachable)</span> <span className="text-sm text-red-400/80 ml-2">{`(exit code 255 — ${status?.agent?.currentModel ?? "API"} unreachable)`}</span>
</div> </div>
</div> </div>
<Button <Button
@ -278,6 +286,24 @@ export default function DashboardPage() {
<span>{formatUptime(status?.container?.startedAt || "")}</span> <span>{formatUptime(status?.container?.startedAt || "")}</span>
</div> </div>
{/* Last Activity — most recent agent file write */}
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
<span className="text-muted-foreground">Last Activity</span>
<span className="text-xs tabular-nums">
{lastActivity > 0
? (() => {
const diff = Date.now() - lastActivity
const m = Math.floor(diff / 60000)
if (m < 1) return "just now"
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ${m % 60}m ago`
return `${Math.floor(h / 24)}d ago`
})()
: "—"}
</span>
</div>
{/* Restart Button */} {/* Restart Button */}
<div className="pt-2 flex justify-end"> <div className="pt-2 flex justify-end">
<Button <Button
@ -314,8 +340,21 @@ export default function DashboardPage() {
<div className="p-4 rounded-lg border border-primary/30 bg-primary/5"> <div className="p-4 rounded-lg border border-primary/30 bg-primary/5">
<div className="text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wider">Primary Model</div> <div className="text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wider">Primary Model</div>
<div className="font-semibold text-base"> <div className="font-semibold text-base">
{isLoading ? "..." : status?.agent?.currentModel || "Not configured"} {isLoading ? "..." : (() => {
const m = status?.agent?.currentModel || ""
if (!m) return "Not configured"
// openrouter/provider/model-name:tag → "model-name" + badge
const parts = m.split("/")
const modelSlug = parts[parts.length - 1].replace(/:.*$/, "")
const via = parts.length >= 2 ? parts[0] : null
return modelSlug || m
})()}
</div> </div>
{!isLoading && status?.agent?.currentModel && (
<div className="text-xs text-muted-foreground mt-1 font-mono truncate">
{status.agent.currentModel}
</div>
)}
</div> </div>
{/* Fallback Models */} {/* Fallback Models */}

View file

@ -1,276 +1,472 @@
"use client" "use client"
/**
* settings/page.tsx Tiger configuration
*
* Sections:
* 1. Model primary model dropdown + fallback models
* 2. Session dmScope, compaction mode
* 3. Telegram enabled toggle, streaming mode
* 4. Commands native commands, ownerDisplay
*/
import * as React from "react" import * as React from "react"
import { Settings2, Save, Loader2, RefreshCw, Eye, EyeOff } from "lucide-react" import useSWR from "swr"
import {
Settings2, Save, Loader2, RefreshCw,
Bot, MessageSquare, Terminal, Cpu, AlertCircle, Check,
} from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useBridgeRequest } from "@/hooks/use-bridge"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue } // ─── Types ────────────────────────────────────────────────────────────────────
interface ConfigField { interface ModelInfo {
path: string id: string
label: string name: string
type: "text" | "number" | "boolean" | "password" provider: string
value: ConfigValue reasoning: boolean
original: ConfigValue contextWindow: number
cost?: { input: number; output: number }
} }
// Tiger config sections - matches the structure from /tiger/config interface OpenClawConfig {
const CONFIG_SECTIONS = [ agents?: { defaults?: { model?: { primary?: string; fallbacks?: string[] }; compaction?: { mode?: string } } }
{ session?: { dmScope?: string }
key: "agent", channels?: { telegram?: { enabled?: boolean; streaming?: string } }
label: "Agent", commands?: { native?: string; ownerDisplay?: string; restart?: boolean }
description: "AI agent model configuration",
paths: [
{ path: "model", label: "Primary Model", type: "text" },
{ path: "fallbackModels", label: "Fallback Models", type: "text" },
],
},
{
key: "execution",
label: "Execution",
description: "Command execution settings",
paths: [
{ path: "maxDuration", label: "Max Duration (seconds)", type: "number" },
{ path: "maxRetries", label: "Max Retries", type: "number" },
],
},
] as const
interface ConfigSection {
key: string
label: string
description: string
fields: ConfigField[]
} }
export default function SettingsPage() { // ─── Helpers ──────────────────────────────────────────────────────────────────
const { request } = useBridgeRequest()
const [sections, setSections] = React.useState<ConfigSection[]>([])
const [loading, setLoading] = React.useState(true)
const [saving, setSaving] = React.useState(false)
const [saved, setSaved] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [showPasswords, setShowPasswords] = React.useState<Record<string, boolean>>({})
// Load config from Tiger Bridge const fetcher = (url: string) => fetch(url).then(r => r.json())
const loadConfig = React.useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await request("/api/tiger/config") as Record<string, ConfigValue>
const loadedSections: ConfigSection[] = CONFIG_SECTIONS.map(section => ({ function get(obj: any, path: string, fallback: any = ""): any {
key: section.key, return path.split(".").reduce((o, k) => (o != null ? o[k] : undefined), obj) ?? fallback
label: section.label, }
description: section.description,
fields: section.paths.map(p => {
const value = getNestedValue(data, p.path)
return {
path: p.path,
label: p.label,
type: p.type,
value: value ?? "",
original: value ?? "",
}
}),
}))
setSections(loadedSections) function set(obj: any, path: string, value: any): any {
} catch { const keys = path.split(".")
setError("Failed to load configuration. Is the Tiger Bridge running?") const result = JSON.parse(JSON.stringify(obj))
} finally { let cur = result
setLoading(false) for (let i = 0; i < keys.length - 1; i++) {
} if (cur[keys[i]] == null) cur[keys[i]] = {}
}, [request]) cur = cur[keys[i]]
React.useEffect(() => {
loadConfig()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleFieldChange = (sectionKey: string, fieldPath: string, newValue: ConfigValue) => {
setSections(prev =>
prev.map(s =>
s.key === sectionKey
? {
...s,
fields: s.fields.map(f =>
f.path === fieldPath ? { ...f, value: newValue } : f
),
}
: s
)
)
} }
cur[keys[keys.length - 1]] = value
return result
}
const handleSave = async () => { // ─── Sub-components ───────────────────────────────────────────────────────────
setSaving(true)
setError(null)
setSaved(false)
try { function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
// Build patch object from all changed fields return (
const patch: Record<string, ConfigValue> = {} <div className="flex items-start gap-4 py-3 border-b border-border/50 last:border-0">
<div className="w-52 shrink-0">
for (const section of sections) { <div className="text-sm font-medium">{label}</div>
for (const field of section.fields) { {hint && <div className="text-xs text-muted-foreground mt-0.5">{hint}</div>}
if (JSON.stringify(field.value) !== JSON.stringify(field.original)) { </div>
let val = field.value <div className="flex-1">{children}</div>
if (field.type === "number") val = Number(val) </div>
if (field.type === "boolean") val = val === true || val === "true"
patch[field.path] = val
}
}
}
if (Object.keys(patch).length > 0) {
await request("/api/tiger/config", "POST", { patch })
// Update originals
setSections(prev =>
prev.map(s => ({
...s,
fields: s.fields.map(f => ({ ...f, original: f.value })),
}))
)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
} catch {
setError("Failed to save configuration.")
} finally {
setSaving(false)
}
}
const hasChanges = sections.some(s =>
s.fields.some(f => JSON.stringify(f.value) !== JSON.stringify(f.original))
) )
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
checked ? "bg-primary" : "bg-muted"
)}
>
<span className={cn(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-background shadow transition-transform",
checked ? "translate-x-5" : "translate-x-0"
)} />
</button>
)
}
function SelectInput({ value, options, onChange }: {
value: string
options: { value: string; label: string }[]
onChange: (v: string) => void
}) {
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-xs"
>
{options.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)
}
// ─── Model dropdown component ─────────────────────────────────────────────────
function ModelSelect({ value, models, onChange }: {
value: string
models: ModelInfo[]
onChange: (v: string) => void
}) {
// Group by provider
const grouped = models.reduce<Record<string, ModelInfo[]>>((acc, m) => {
if (!acc[m.provider]) acc[m.provider] = []
acc[m.provider].push(m)
return acc
}, {})
const providerLabels: Record<string, string> = {
minimax: "MiniMax",
"minimax-portal": "MiniMax Portal",
openrouter: "OpenRouter",
}
const current = models.find(m => m.id === value)
return ( return (
<div className="flex flex-col gap-6 p-6 max-w-4xl"> <div className="space-y-2">
<div className="flex items-center justify-between"> <select
<div> value={value}
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2"> onChange={e => onChange(e.target.value)}
<Settings2 className="h-6 w-6" /> className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm"
Settings >
</h1> {Object.entries(grouped).map(([prov, mods]) => (
<p className="text-muted-foreground">Tiger agent configuration. Changes are applied live.</p> <optgroup key={prov} label={providerLabels[prov] ?? prov}>
</div> {mods.map(m => (
<div className="flex gap-2"> <option key={m.id} value={m.id}>{m.name}</option>
<Button variant="outline" size="sm" onClick={loadConfig} disabled={loading}> ))}
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} /> </optgroup>
Reload ))}
</Button> </select>
<Button size="sm" onClick={handleSave} disabled={!hasChanges || saving}>
{saving ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Save className="h-4 w-4 mr-1" />
)}
{saved ? "Saved!" : "Save Changes"}
</Button>
</div>
</div>
{error && ( {/* Model detail badge row */}
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div> {current && (
)} <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
{loading ? ( {current.id}
<div className="flex items-center justify-center py-20"> </span>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> {current.reasoning && (
<span className="text-xs bg-purple-500/15 text-purple-400 px-2 py-0.5 rounded font-medium">
reasoning
</span>
)}
{current.contextWindow > 0 && (
<span className="text-xs text-muted-foreground">
{current.contextWindow >= 1000000
? `${(current.contextWindow / 1000000).toFixed(1)}M ctx`
: `${Math.round(current.contextWindow / 1000)}K ctx`}
</span>
)}
{current.cost && (
<span className="text-xs text-muted-foreground">
${current.cost.input}/M in · ${current.cost.output}/M out
</span>
)}
</div> </div>
) : (
sections.map(section => (
<Card key={section.key}>
<CardHeader>
<CardTitle className="text-lg">{section.label}</CardTitle>
<CardDescription>{section.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{section.fields.map(field => (
<div key={field.path} className="flex items-center gap-4">
<label className="w-[200px] text-sm font-medium text-muted-foreground shrink-0">
{field.label}
</label>
{field.type === "boolean" ? (
<button
type="button"
onClick={() =>
handleFieldChange(section.key, field.path, !field.value)
}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
field.value ? "bg-primary" : "bg-muted"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-background shadow ring-0 transition-transform",
field.value ? "translate-x-5" : "translate-x-0"
)}
/>
</button>
) : field.type === "password" ? (
<div className="flex-1 flex gap-2">
<Input
type={showPasswords[field.path] ? "text" : "password"}
value={String(field.value)}
onChange={e =>
handleFieldChange(section.key, field.path, e.target.value)
}
className="flex-1 font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() =>
setShowPasswords(p => ({ ...p, [field.path]: !p[field.path] }))
}
>
{showPasswords[field.path] ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
) : (
<Input
type={field.type}
value={String(field.value)}
onChange={e =>
handleFieldChange(
section.key,
field.path,
field.type === "number" ? e.target.value : e.target.value
)
}
className="flex-1 font-mono text-sm"
/>
)}
</div>
))}
</CardContent>
</Card>
))
)} )}
</div> </div>
) )
} }
function getNestedValue(obj: Record<string, ConfigValue>, path: string): ConfigValue { // ─── Section card wrapper ─────────────────────────────────────────────────────
const keys = path.split(".")
let current: ConfigValue = obj function SectionCard({ icon: Icon, title, description, children, dirty }: {
for (const key of keys) { icon: React.ElementType
if (current == null || typeof current !== "object" || Array.isArray(current)) return null title: string
current = (current as Record<string, ConfigValue>)[key] description: string
children: React.ReactNode
dirty?: boolean
}) {
return (
<Card className={cn("transition-colors", dirty && "border-primary/40")}>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
{title}
{dirty && (
<span className="text-xs font-normal text-primary ml-1">unsaved changes</span>
)}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function SettingsPage() {
// Raw config from bridge
const { data: configData, mutate: mutateConfig, isLoading: configLoading } =
useSWR<{ ok: boolean; config: OpenClawConfig }>("/api/tiger/config", fetcher)
// Available models
const { data: modelsData, isLoading: modelsLoading } =
useSWR<{ ok: boolean; models: ModelInfo[] }>("/api/tiger/config/models", fetcher)
const remoteConfig = configData?.config ?? {}
const models = modelsData?.models ?? []
// ── Local draft — tracks unsaved edits ─────────────────────────────────────
const [draft, setDraft] = React.useState<OpenClawConfig>({})
const [initialized, setInitialized] = React.useState(false)
React.useEffect(() => {
if (configData?.ok && !initialized) {
setDraft(JSON.parse(JSON.stringify(configData.config)))
setInitialized(true)
}
}, [configData, initialized])
const update = (path: string, value: any) => {
setDraft(prev => set(prev, path, value))
} }
return current ?? null
} const g = (path: string, fallback: any = "") => get(draft, path, fallback)
const r = (path: string, fallback: any = "") => get(remoteConfig, path, fallback)
// Dirty check — compare draft to remote at path level
const isDirty = (path: string) => JSON.stringify(g(path)) !== JSON.stringify(r(path))
const anyDirty = JSON.stringify(draft) !== JSON.stringify(remoteConfig)
// ── Save ────────────────────────────────────────────────────────────────────
const [saving, setSaving] = React.useState(false)
const [saveState, setSaveState] = React.useState<"idle" | "ok" | "err">("idle")
const [saveError, setSaveError] = React.useState("")
const handleSave = async () => {
setSaving(true)
setSaveState("idle")
try {
const res = await fetch("/api/tiger/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ patch: draft }),
})
const data = await res.json()
if (!data.ok) throw new Error(data.error ?? "Save failed")
setSaveState("ok")
await mutateConfig()
setInitialized(false) // re-sync draft from fresh server data
setTimeout(() => setSaveState("idle"), 3000)
} catch (err: any) {
setSaveError(err.message)
setSaveState("err")
} finally {
setSaving(false)
}
}
const handleReset = () => {
setDraft(JSON.parse(JSON.stringify(remoteConfig)))
setSaveState("idle")
}
const loading = configLoading || modelsLoading || !initialized
// ── Render ──────────────────────────────────────────────────────────────────
return (
<div className="flex flex-col gap-6 p-6 max-w-3xl">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Settings2 className="h-6 w-6" /> Settings
</h1>
<p className="text-sm text-muted-foreground mt-1">
Live Tiger configuration writes directly to openclaw.json inside the container.
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleReset} disabled={!anyDirty || saving}>
<RefreshCw className="h-4 w-4 mr-1" /> Reset
</Button>
<Button size="sm" onClick={handleSave} disabled={!anyDirty || saving}>
{saving
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
: saveState === "ok"
? <Check className="h-4 w-4 mr-1" />
: <Save className="h-4 w-4 mr-1" />}
{saving ? "Saving…" : saveState === "ok" ? "Saved!" : "Save Changes"}
</Button>
</div>
</div>
{/* Save error */}
{saveState === "err" && (
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
<AlertCircle className="h-4 w-4 shrink-0" />
{saveError}
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-24">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-6">
{/* ── 1. Model ───────────────────────────────────────────────────── */}
<SectionCard
icon={Cpu}
title="Model"
description="Which AI model Tiger and sub-agents use. Changes take effect on the next conversation."
dirty={isDirty("agents.defaults.model.primary") || isDirty("agents.defaults.model.fallbacks")}
>
<SettingRow
label="Primary model"
hint="Active model for all agents"
>
<ModelSelect
value={g("agents.defaults.model.primary", "")}
models={models}
onChange={v => update("agents.defaults.model.primary", v)}
/>
</SettingRow>
<SettingRow
label="Fallback models"
hint="Comma-separated, tried in order if primary fails"
>
<input
type="text"
value={(g("agents.defaults.model.fallbacks", []) as string[]).join(", ")}
onChange={e => update(
"agents.defaults.model.fallbacks",
e.target.value.split(",").map(s => s.trim()).filter(Boolean)
)}
placeholder="e.g. openrouter/auto"
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm"
/>
</SettingRow>
<SettingRow
label="Compaction mode"
hint="How Tiger handles context window limits"
>
<SelectInput
value={g("agents.defaults.compaction.mode", "safeguard")}
options={[
{ value: "safeguard", label: "Safeguard — compress when near limit" },
{ value: "auto", label: "Auto — compress aggressively" },
{ value: "off", label: "Off — never compress" },
]}
onChange={v => update("agents.defaults.compaction.mode", v)}
/>
</SettingRow>
</SectionCard>
{/* ── 2. Session ─────────────────────────────────────────────────── */}
<SectionCard
icon={Bot}
title="Session"
description="How Tiger manages conversation sessions and identity scoping."
dirty={isDirty("session.dmScope")}
>
<SettingRow
label="DM scope"
hint="How Tiger isolates context between different Telegram chats"
>
<SelectInput
value={g("session.dmScope", "per-channel-peer")}
options={[
{ value: "per-channel-peer", label: "Per-channel-peer (recommended)" },
{ value: "per-channel", label: "Per-channel" },
{ value: "global", label: "Global — single shared context" },
]}
onChange={v => update("session.dmScope", v)}
/>
</SettingRow>
</SectionCard>
{/* ── 3. Telegram ────────────────────────────────────────────────── */}
<SectionCard
icon={MessageSquare}
title="Telegram"
description="Telegram bot channel settings."
dirty={isDirty("channels.telegram.enabled") || isDirty("channels.telegram.streaming")}
>
<SettingRow label="Enabled" hint="Whether the Telegram bot is active">
<Toggle
checked={g("channels.telegram.enabled", true)}
onChange={v => update("channels.telegram.enabled", v)}
/>
</SettingRow>
<SettingRow
label="Streaming"
hint="How Tiger sends message updates while generating"
>
<SelectInput
value={g("channels.telegram.streaming", "partial")}
options={[
{ value: "partial", label: "Partial — stream as it types" },
{ value: "full", label: "Full — send only on completion" },
{ value: "off", label: "Off — no streaming" },
]}
onChange={v => update("channels.telegram.streaming", v)}
/>
</SettingRow>
</SectionCard>
{/* ── 4. Commands ────────────────────────────────────────────────── */}
<SectionCard
icon={Terminal}
title="Commands"
description="How Tiger handles native and system commands."
dirty={isDirty("commands.native") || isDirty("commands.ownerDisplay") || isDirty("commands.restart")}
>
<SettingRow
label="Native commands"
hint="Whether Tiger can run shell commands on the host"
>
<SelectInput
value={g("commands.native", "auto")}
options={[
{ value: "auto", label: "Auto — enable when available" },
{ value: "on", label: "On — always enabled" },
{ value: "off", label: "Off — disabled" },
]}
onChange={v => update("commands.native", v)}
/>
</SettingRow>
<SettingRow
label="Owner display"
hint="How Manohar's name appears to Tiger in context"
>
<SelectInput
value={g("commands.ownerDisplay", "raw")}
options={[
{ value: "raw", label: "Raw — show as-is" },
{ value: "formatted", label: "Formatted — display name" },
]}
onChange={v => update("commands.ownerDisplay", v)}
/>
</SettingRow>
<SettingRow
label="Allow restart"
hint="Tiger can restart itself when needed"
>
<Toggle
checked={g("commands.restart", true)}
onChange={v => update("commands.restart", v)}
/>
</SettingRow>
</SectionCard>
</div>
)}
</div>
)
}

View file

@ -1,234 +1,223 @@
/** /**
* Workspace Page File browser for Tiger agent's workspace * workspace/page.tsx Per-agent file browser with preview
* *
* Lists files from the Tiger Bridge's workspace API and provides * Layout:
* a file viewer for reading file contents. * Agent chip row (top)
* Tabs: [Files] [Activity]
* Files: split-pane file tree (left) + file preview (right)
* Activity: recent cross-agent changes feed
*/ */
"use client" "use client"
import * as React from "react" import * as React from "react"
import { Folder, FileText, ChevronRight, Home, ArrowLeft, Loader2 } from "lucide-react" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { ScrollArea } from "@/components/ui/scroll-area"
import { Button } from "@/components/ui/button" import { AgentChipRow, AgentInfo } from "@/components/workspace/agent-chip-row"
import { useBridgeRequest } from "@/hooks/use-bridge" import { FileTree, FileItem } from "@/components/workspace/file-tree"
import { cn } from "@/lib/utils" import { FilePreview } from "@/components/workspace/file-preview"
import { ActivityFeed, ActivityEvent } from "@/components/workspace/activity-feed"
interface WorkspaceFile { // ─── Types ────────────────────────────────────────────────────────────────────
name: string
type: "file" | "directory"
size?: number
modified?: string
}
interface FileContent { interface FileContent {
ok: boolean ok: boolean
path: string path: string
content: string content: string
encoding: "utf8" | "base64"
size: number size: number
mime: string
} }
function formatSize(bytes?: number): string { // ─── Helpers ─────────────────────────────────────────────────────────────────
if (!bytes) return "—"
if (bytes < 1024) return `${bytes}B` async function apiFetch<T>(url: string): Promise<T> {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` const res = await fetch(url, { cache: "no-store" })
return `${(bytes / (1024 * 1024)).toFixed(1)}MB` if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<T>
} }
function getFileIcon(filename: string) { // ─── Page ─────────────────────────────────────────────────────────────────────
const ext = filename.split(".").pop()?.toLowerCase()
const codeExts = ["ts", "tsx", "js", "jsx", "py", "sh", "json", "md", "yaml", "yml", "toml"]
if (ext && codeExts.includes(ext)) return "code"
if (ext === "md") return "markdown"
return "text"
}
export default function WorkspacePage() { export default function WorkspacePage() {
const { request, loading } = useBridgeRequest() // Agent list
const [agents, setAgents] = React.useState<AgentInfo[]>([])
const [agentsLoading, setAgentsLoading] = React.useState(true)
// Active agent selection (null = "All" → show Tiger/main)
const [activeAgentId, setActiveAgentId] = React.useState<string | null>(null)
// File tree state
const [treeItems, setTreeItems] = React.useState<FileItem[]>([])
const [currentPath, setCurrentPath] = React.useState("") const [currentPath, setCurrentPath] = React.useState("")
const [files, setFiles] = React.useState<WorkspaceFile[]>([]) const [treeLoading, setTreeLoading] = React.useState(false)
// File preview state
const [selectedFile, setSelectedFile] = React.useState<string | null>(null) const [selectedFile, setSelectedFile] = React.useState<string | null>(null)
const [fileContent, setFileContent] = React.useState<FileContent | null>(null) const [fileContent, setFileContent] = React.useState<FileContent | null>(null)
const [loadingFiles, setLoadingFiles] = React.useState(false) const [previewLoading, setPreviewLoading] = React.useState(false)
const [loadingContent, setLoadingContent] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
// Load directory contents // Activity feed
const loadDirectory = React.useCallback(async (path: string) => { const [activityEvents, setActivityEvents] = React.useState<ActivityEvent[]>([])
setLoadingFiles(true) const [activityLoading, setActivityLoading] = React.useState(false)
setError(null)
try {
const url = path ? `/api/tiger/workspace?path=${encodeURIComponent(path)}` : "/api/tiger/workspace"
const data = await request(url) as { ok: boolean; files?: WorkspaceFile[] }
if (data.ok && data.files) {
setFiles(data.files)
setCurrentPath(path)
} else {
setError("Failed to load directory")
}
} catch (e: unknown) {
setError("Failed to load workspace")
} finally {
setLoadingFiles(false)
}
}, [request])
// Load file content // ── Load agents on mount ──────────────────────────────────────────────────
const loadFile = React.useCallback(async (filename: string) => {
setLoadingContent(true)
setSelectedFile(filename)
try {
// Need full path including current directory
const fullPath = currentPath ? `${currentPath}/${filename}` : filename
const url = `/api/tiger/workspace?path=${encodeURIComponent(fullPath)}&read=true`
const data = await request(url) as FileContent
setFileContent(data)
} catch (e: unknown) {
setFileContent({ ok: false, path: filename, content: "Failed to load file", size: 0 })
} finally {
setLoadingContent(false)
}
}, [request, currentPath])
// Initial load
React.useEffect(() => { React.useEffect(() => {
loadDirectory("") setAgentsLoading(true)
}, [loadDirectory]) apiFetch<{ ok: boolean; agents: AgentInfo[] }>("/api/tiger/agents")
.then((data) => { if (data.ok) setAgents(data.agents) })
.catch(console.error)
.finally(() => setAgentsLoading(false))
}, [])
const navigateTo = (path: string) => { // Derived: the "effective" agent to browse
// "All" defaults to showing the orchestrator (main/Tiger)
const effectiveAgentId = activeAgentId ?? "main"
// ── Load file tree when agent or path changes ─────────────────────────────
const loadTree = React.useCallback((agentId: string, path: string) => {
setTreeLoading(true)
setSelectedFile(null)
setFileContent(null)
const url = path
? `/api/tiger/agents/${agentId}/files?path=${encodeURIComponent(path)}`
: `/api/tiger/agents/${agentId}/files`
apiFetch<{ ok: boolean; items: FileItem[] }>(url)
.then((data) => { if (data.ok) setTreeItems(data.items) })
.catch(console.error)
.finally(() => setTreeLoading(false))
}, [])
React.useEffect(() => {
loadTree(effectiveAgentId, currentPath)
}, [effectiveAgentId, currentPath, loadTree])
// Reset path when agent changes
const handleAgentChange = (id: string | null) => {
setActiveAgentId(id)
setCurrentPath("")
setSelectedFile(null) setSelectedFile(null)
setFileContent(null) setFileContent(null)
loadDirectory(path)
} }
// Build breadcrumb path // ── Navigate into a directory ─────────────────────────────────────────────
const breadcrumbs = currentPath ? currentPath.split("/").filter(Boolean) : [] const handleNavigate = (path: string) => {
setCurrentPath(path)
}
// ── Load file content on selection ───────────────────────────────────────
const handleSelectFile = React.useCallback((filePath: string) => {
setSelectedFile(filePath)
setPreviewLoading(true)
const url = `/api/tiger/agents/${effectiveAgentId}/file?path=${encodeURIComponent(filePath)}`
apiFetch<FileContent>(url)
.then((data) => setFileContent(data))
.catch(console.error)
.finally(() => setPreviewLoading(false))
}, [effectiveAgentId])
// ── Load activity feed ────────────────────────────────────────────────────
const loadActivity = React.useCallback(() => {
setActivityLoading(true)
apiFetch<{ ok: boolean; events: ActivityEvent[] }>("/api/tiger/activity?limit=50")
.then((data) => { if (data.ok) setActivityEvents(data.events) })
.catch(console.error)
.finally(() => setActivityLoading(false))
}, [])
// Recently active agent ids (activity within last hour) for badge highlighting
const recentIds = React.useMemo(() => {
const cutoff = Date.now() - 60 * 60 * 1000
return new Set(activityEvents.filter((e) => e.ts > cutoff).map((e) => e.agentId))
}, [activityEvents])
// ─── Render ────────────────────────────────────────────────────────────────
return ( return (
<div className="h-[calc(100vh-4rem)] flex flex-col gap-4 p-4"> <div className="flex flex-col h-[calc(100vh-4rem)] gap-3 p-4">
{/* Header */} {/* Page title */}
<div className="flex items-center justify-between"> <div>
<div> <h1 className="text-2xl font-bold tracking-tight">Workspace</h1>
<h1 className="text-2xl font-bold tracking-tight">Workspace</h1> <p className="text-sm text-muted-foreground">Browse agent files and recent activity</p>
<p className="text-sm text-muted-foreground">
Browse files in Tiger's workspace
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigateTo("")}
disabled={!currentPath}
>
<Home className="h-4 w-4 mr-2" />
Root
</Button>
</div> </div>
{/* Error */} {/* Agent chip row */}
{error && ( <AgentChipRow
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div> agents={agents}
)} activeId={activeAgentId}
onChange={handleAgentChange}
recentIds={recentIds}
/>
<div className="flex-1 grid grid-cols-3 gap-4 min-h-0"> {/* Tabs: Files | Activity */}
{/* File List */} <Tabs
<Card className="col-span-1 bg-card/40 flex flex-col min-h-0"> defaultValue="files"
<CardHeader className="pb-2"> className="flex-1 flex flex-col min-h-0"
<CardTitle className="text-sm">Files</CardTitle> onValueChange={(v) => { if (v === "activity") loadActivity() }}
{/* Breadcrumb */} >
{breadcrumbs.length > 0 && ( <TabsList className="self-start">
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1"> <TabsTrigger value="files">Files</TabsTrigger>
<button onClick={() => navigateTo("")} className="hover:text-foreground">root</button> <TabsTrigger value="activity">Activity</TabsTrigger>
{breadcrumbs.map((crumb, i) => ( </TabsList>
<React.Fragment key={i}>
<ChevronRight className="h-3 w-3" />
<button onClick={() => navigateTo(breadcrumbs.slice(0, i + 1).join("/"))} className="hover:text-foreground">
{crumb}
</button>
</React.Fragment>
))}
</div>
)}
</CardHeader>
<CardContent className="flex-1 overflow-y-auto">
{loadingFiles ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : files.length === 0 ? (
<div className="text-sm text-muted-foreground py-8 text-center">No files</div>
) : (
<div className="space-y-1">
{/* Parent directory */}
{currentPath && (
<button
onClick={() => navigateTo(breadcrumbs.slice(0, -1).join("/"))}
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-muted text-left"
>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">..</span>
</button>
)}
{files.map((file) => (
<button
key={file.name}
onClick={() => file.type === "directory" ? navigateTo(currentPath ? `${currentPath}/${file.name}` : file.name) : loadFile(file.name)}
className={cn(
"w-full flex items-center gap-2 p-2 rounded-md hover:bg-muted text-left",
selectedFile === file.name && "bg-muted"
)}
>
{file.type === "directory" ? (
<Folder className="h-4 w-4 text-amber-400" />
) : (
<FileText className={cn(
"h-4 w-4",
getFileIcon(file.name) === "code" ? "text-blue-400" :
getFileIcon(file.name) === "markdown" ? "text-purple-400" :
"text-muted-foreground"
)} />
)}
<span className="text-sm truncate flex-1">{file.name}</span>
{file.size && <span className="text-xs text-muted-foreground">{formatSize(file.size)}</span>}
</button>
))}
</div>
)}
</CardContent>
</Card>
{/* File Viewer */} {/* ── Files tab ─────────────────────────────────────────────────── */}
<Card className="col-span-2 bg-card/40 flex flex-col min-h-0"> <TabsContent value="files" className="flex-1 flex min-h-0 mt-2">
<CardHeader className="pb-2"> <div className="flex-1 grid grid-cols-1 md:grid-cols-[280px_1fr] gap-3 min-h-0">
<CardTitle className="text-sm"> {/* File tree panel */}
{selectedFile || "Select a file to view"} <div className="border rounded-lg bg-card/40 flex flex-col min-h-0 overflow-hidden">
</CardTitle> <div className="px-3 py-2 border-b shrink-0">
{fileContent && ( <span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
<div className="text-xs text-muted-foreground"> {agents.find((a) => a.id === effectiveAgentId)?.emoji}{" "}
{formatSize(fileContent.size)} {agents.find((a) => a.id === effectiveAgentId)?.name ?? "Tiger"}
</span>
</div> </div>
)} <ScrollArea className="flex-1 p-2">
</CardHeader> <FileTree
<CardContent className="flex-1 overflow-y-auto"> items={treeItems}
{loadingContent ? ( currentPath={currentPath}
<div className="flex items-center justify-center py-8"> selectedFile={selectedFile}
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> onNavigate={handleNavigate}
onSelectFile={handleSelectFile}
loading={treeLoading}
/>
</ScrollArea>
</div>
{/* Preview panel */}
<div className="border rounded-lg bg-card/40 flex flex-col min-h-0 overflow-hidden">
<FilePreview
path={selectedFile}
content={fileContent?.content ?? null}
encoding={fileContent?.encoding ?? null}
mime={fileContent?.mime ?? null}
size={fileContent?.size ?? 0}
loading={previewLoading}
agentId={effectiveAgentId}
onSaved={(_p, newContent) => {
// Update cached content so the view reflects the save immediately
setFileContent((prev) => prev ? { ...prev, content: newContent } : prev)
}}
/>
</div>
</div>
</TabsContent>
{/* ── Activity tab ──────────────────────────────────────────────── */}
<TabsContent value="activity" className="flex-1 min-h-0 mt-2">
<div className="border rounded-lg bg-card/40 h-full overflow-hidden">
<div className="px-3 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Recent changes all agents
</span>
</div>
<ScrollArea className="h-[calc(100%-2.5rem)]">
<div className="p-2">
<ActivityFeed events={activityEvents} loading={activityLoading} />
</div> </div>
) : !selectedFile ? ( </ScrollArea>
<div className="text-sm text-muted-foreground py-8 text-center"> </div>
Click a file to view its contents </TabsContent>
</div> </Tabs>
) : fileContent ? (
<pre className={cn(
"text-xs font-mono whitespace-pre-wrap break-all p-3 rounded-md border",
fileContent.ok ? "bg-background/50" : "bg-destructive/10 border-destructive"
)}>
{fileContent.content}
</pre>
) : null}
</CardContent>
</Card>
</div>
</div> </div>
) )
} }

View file

@ -1,7 +1,10 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { Send, Square, Bot, User, AlertCircle, Loader2, Eraser } from "lucide-react" import {
Send, Square, Bot, User, AlertCircle, Loader2, Eraser,
Plus, ChevronDown, Trash2,
} from "lucide-react"
import ReactMarkdown from "react-markdown" import ReactMarkdown from "react-markdown"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -12,13 +15,16 @@ import { useChatContext } from "@/contexts/chat-context"
export function ChatInterface({ className, ...props }: React.ComponentProps<typeof Card>) { export function ChatInterface({ className, ...props }: React.ComponentProps<typeof Card>) {
const [input, setInput] = React.useState("") const [input, setInput] = React.useState("")
// Persistent chat state — survives navigation between routes. const {
// See contexts/chat-context.tsx for the rationale. messages, setMessages, clearChat,
const { messages, setMessages, clearChat } = useChatContext() currentSessionKey, sessions, selectSession, newSession, deleteSession, refreshSessions,
} = useChatContext()
const [sending, setSending] = React.useState(false) const [sending, setSending] = React.useState(false)
const [dropdownOpen, setDropdownOpen] = React.useState(false)
const scrollRef = React.useRef<HTMLDivElement>(null) const scrollRef = React.useRef<HTMLDivElement>(null)
const abortRef = React.useRef<AbortController | null>(null) const abortRef = React.useRef<AbortController | null>(null)
const streamingRef = React.useRef("") const streamingRef = React.useRef("")
const dropdownRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => { React.useEffect(() => {
if (scrollRef.current) { if (scrollRef.current) {
@ -26,6 +32,21 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
} }
}, [messages]) }, [messages])
// Close dropdown on outside click
React.useEffect(() => {
if (!dropdownOpen) return
const onClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false)
}
}
document.addEventListener("mousedown", onClick)
return () => document.removeEventListener("mousedown", onClick)
}, [dropdownOpen])
const currentLabel = sessions.find(s => s.key === currentSessionKey)?.label
|| (currentSessionKey === "agent:main:main" ? "Main" : "Chat")
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!input.trim() || sending) return if (!input.trim() || sending) return
@ -35,7 +56,6 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
setSending(true) setSending(true)
streamingRef.current = "" streamingRef.current = ""
// Add user message
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
id: `user-${Date.now()}`, id: `user-${Date.now()}`,
role: "user", role: "user",
@ -50,7 +70,7 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
const res = await fetch("/api/chat", { const res = await fetch("/api/chat", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }), body: JSON.stringify({ message: text, sessionKey: currentSessionKey }),
signal: controller.signal, signal: controller.signal,
}) })
@ -58,22 +78,13 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
const reader = res.body.getReader() const reader = res.body.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
// Buffer across reads — a single SSE event ("data: ...\n\n") may be
// split across TCP chunks. Accumulate, then split on the SSE delimiter.
let buffer = "" let buffer = ""
const streamId = `streaming-${Date.now()}` const streamId = `streaming-${Date.now()}`
while (true) { while (true) {
const { done: readerDone, value } = await reader.read() const { done: readerDone, value } = await reader.read()
if (readerDone) break if (readerDone) break
// {stream: true} preserves decoder state for multi-byte UTF-8 chars
// (e.g. emoji) that happen to land across chunk boundaries.
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
// SSE events end with a blank line (\n\n). Anything after the last
// \n\n is a partial event — keep it in `buffer` for the next read.
const events = buffer.split("\n\n") const events = buffer.split("\n\n")
buffer = events.pop() || "" buffer = events.pop() || ""
@ -85,17 +96,12 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
try { try {
data = JSON.parse(dataLine.slice(6)) data = JSON.parse(dataLine.slice(6))
} catch (err) { } catch (err) {
// Don't swallow silently — log so real parse bugs are visible.
console.warn("[chat] SSE parse error:", err, "line:", dataLine) console.warn("[chat] SSE parse error:", err, "line:", dataLine)
continue continue
} }
console.log("[chat] event:", data.type, "content:", data.content?.substring(0, 50))
if (data.type === "status") { if (data.type === "status") {
// Transient 'Tiger is thinking...' indicator. Do NOT append to // Show streaming placeholder so the typing indicator appears.
// the message content — that was Bug A. Just ensure a streaming
// placeholder exists so the UI shows activity.
setMessages(prev => { setMessages(prev => {
if (prev.some(m => m.streaming)) return prev if (prev.some(m => m.streaming)) return prev
return [...prev, { return [...prev, {
@ -123,20 +129,7 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
timestamp: Date.now(), timestamp: Date.now(),
}] }]
}) })
} else if (data.type === "message") {
// Non-streaming full message
setMessages(prev => {
const filtered = prev.filter(m => !m.streaming)
return [...filtered, {
id: `agent-${Date.now()}`,
role: "agent",
content: data.content || "",
timestamp: Date.now(),
}]
})
} else if (data.type === "done") { } else if (data.type === "done") {
// Fall back to data.content if the chunk event somehow didn't
// land — Bug D. This is a belt-and-suspenders safety.
const finalContent = streamingRef.current || data.content || "" const finalContent = streamingRef.current || data.content || ""
setMessages(prev => { setMessages(prev => {
const filtered = prev.filter(m => !m.streaming) const filtered = prev.filter(m => !m.streaming)
@ -150,6 +143,8 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
}) })
streamingRef.current = "" streamingRef.current = ""
setSending(false) setSending(false)
// Refresh session list so updatedAt and messageCount reflect this turn.
refreshSessions()
} else if (data.type === "error") { } else if (data.type === "error") {
setMessages(prev => [...prev.filter(m => !m.streaming), { setMessages(prev => [...prev.filter(m => !m.streaming), {
id: `err-${Date.now()}`, id: `err-${Date.now()}`,
@ -172,7 +167,6 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
} }
setSending(false) setSending(false)
} }
abortRef.current = null abortRef.current = null
} }
@ -186,24 +180,84 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
return ( return (
<Card className={cn("w-full flex flex-col", className)} {...props}> <Card className={cn("w-full flex flex-col", className)} {...props}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base min-w-0">
<Bot className="h-5 w-5" /> <Bot className="h-5 w-5 flex-shrink-0" />
Chat with Tiger <span className="truncate">Chat with Tiger</span>
</CardTitle> </CardTitle>
<Button <div className="flex items-center gap-1 flex-shrink-0">
type="button" {/* Sessions dropdown — flavor C: button + dropdown */}
variant="ghost" <div ref={dropdownRef} className="relative">
size="sm" <Button
onClick={clearChat} type="button"
className="text-xs text-muted-foreground h-7" variant="outline"
title="Clear conversation" size="sm"
> onClick={() => setDropdownOpen(o => !o)}
<Eraser className="h-3 w-3 mr-1" /> className="h-7 text-xs gap-1 px-2"
Clear title="Switch chat session"
</Button> >
<span className="max-w-[100px] truncate">{currentLabel}</span>
<ChevronDown className="h-3 w-3" />
</Button>
{dropdownOpen && (
<div className="absolute right-0 top-8 z-20 min-w-[220px] bg-popover border rounded-md shadow-lg overflow-hidden">
<div className="py-1 max-h-[280px] overflow-y-auto">
{sessions.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">No sessions yet</div>
)}
{sessions.map(s => (
<div
key={s.key}
className={cn(
"flex items-center justify-between gap-2 px-3 py-2 text-xs hover:bg-accent cursor-pointer",
s.key === currentSessionKey && "bg-accent"
)}
onClick={() => { selectSession(s.key); setDropdownOpen(false) }}
>
<span className="truncate">{s.label}</span>
{!s.isDefault && (
<button
type="button"
className="text-muted-foreground hover:text-destructive flex-shrink-0"
onClick={(e) => {
e.stopPropagation()
if (confirm(`Delete session "${s.label}"?`)) deleteSession(s.key)
}}
title="Delete session"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => newSession()}
className="h-7 px-2"
title="Start a new chat session"
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={clearChat}
className="h-7 px-2 text-muted-foreground"
title="Clear current conversation"
>
<Eraser className="h-3.5 w-3.5" />
</Button>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 p-0 min-h-0"> <CardContent className="flex-1 p-0 min-h-0">
<ScrollArea className="h-[500px] px-4" ref={scrollRef}> <ScrollArea className="h-[500px] px-4" ref={scrollRef}>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
@ -239,9 +293,6 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
)}> )}>
{message.role === "system" && <AlertCircle className="h-3 w-3" />} {message.role === "system" && <AlertCircle className="h-3 w-3" />}
{message.role === "agent" ? ( {message.role === "agent" ? (
// While streaming: render raw text (cheap, one DOM node update per token).
// After streaming completes: render full ReactMarkdown (expensive but
// only happens once). This is what makes the typing feel actually show up.
message.streaming ? ( message.streaming ? (
<div className="whitespace-pre-wrap">{message.content}</div> <div className="whitespace-pre-wrap">{message.content}</div>
) : ( ) : (

View file

@ -0,0 +1,297 @@
"use client"
import * as React from "react"
import { Send, Square, Bot, User, AlertCircle, Loader2, Eraser } from "lucide-react"
import ReactMarkdown from "react-markdown"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useChatContext } from "@/contexts/chat-context"
export function ChatInterface({ className, ...props }: React.ComponentProps<typeof Card>) {
const [input, setInput] = React.useState("")
// Persistent chat state — survives navigation between routes.
// See contexts/chat-context.tsx for the rationale.
const { messages, setMessages, clearChat } = useChatContext()
const [sending, setSending] = React.useState(false)
const scrollRef = React.useRef<HTMLDivElement>(null)
const abortRef = React.useRef<AbortController | null>(null)
const streamingRef = React.useRef("")
React.useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!input.trim() || sending) return
const text = input.trim()
setInput("")
setSending(true)
streamingRef.current = ""
// Add user message
setMessages(prev => [...prev, {
id: `user-${Date.now()}`,
role: "user",
content: text,
timestamp: Date.now(),
}])
try {
const controller = new AbortController()
abortRef.current = controller
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
signal: controller.signal,
})
if (!res.ok || !res.body) throw new Error("Failed to connect")
const reader = res.body.getReader()
const decoder = new TextDecoder()
// Buffer across reads — a single SSE event ("data: ...\n\n") may be
// split across TCP chunks. Accumulate, then split on the SSE delimiter.
let buffer = ""
const streamId = `streaming-${Date.now()}`
while (true) {
const { done: readerDone, value } = await reader.read()
if (readerDone) break
// {stream: true} preserves decoder state for multi-byte UTF-8 chars
// (e.g. emoji) that happen to land across chunk boundaries.
buffer += decoder.decode(value, { stream: true })
// SSE events end with a blank line (\n\n). Anything after the last
// \n\n is a partial event — keep it in `buffer` for the next read.
const events = buffer.split("\n\n")
buffer = events.pop() || ""
for (const eventBlock of events) {
const dataLine = eventBlock.split("\n").find(l => l.startsWith("data: "))
if (!dataLine) continue
let data: { type: string; content?: string }
try {
data = JSON.parse(dataLine.slice(6))
} catch (err) {
// Don't swallow silently — log so real parse bugs are visible.
console.warn("[chat] SSE parse error:", err, "line:", dataLine)
continue
}
console.log("[chat] event:", data.type, "content:", data.content?.substring(0, 50))
if (data.type === "status") {
// Transient 'Tiger is thinking...' indicator. Do NOT append to
// the message content — that was Bug A. Just ensure a streaming
// placeholder exists so the UI shows activity.
setMessages(prev => {
if (prev.some(m => m.streaming)) return prev
return [...prev, {
id: streamId,
role: "agent",
content: "",
streaming: true,
timestamp: Date.now(),
}]
})
} else if (data.type === "chunk") {
streamingRef.current += data.content || ""
setMessages(prev => {
const existing = prev.find(m => m.streaming)
if (existing) {
return prev.map(m =>
m.streaming ? { ...m, content: streamingRef.current } : m
)
}
return [...prev, {
id: streamId,
role: "agent",
content: streamingRef.current,
streaming: true,
timestamp: Date.now(),
}]
})
} else if (data.type === "message") {
// Non-streaming full message
setMessages(prev => {
const filtered = prev.filter(m => !m.streaming)
return [...filtered, {
id: `agent-${Date.now()}`,
role: "agent",
content: data.content || "",
timestamp: Date.now(),
}]
})
} else if (data.type === "done") {
// Fall back to data.content if the chunk event somehow didn't
// land — Bug D. This is a belt-and-suspenders safety.
const finalContent = streamingRef.current || data.content || ""
setMessages(prev => {
const filtered = prev.filter(m => !m.streaming)
if (!finalContent) return filtered
return [...filtered, {
id: `agent-${Date.now()}`,
role: "agent",
content: finalContent,
timestamp: Date.now(),
}]
})
streamingRef.current = ""
setSending(false)
} else if (data.type === "error") {
setMessages(prev => [...prev.filter(m => !m.streaming), {
id: `err-${Date.now()}`,
role: "system",
content: data.content || "Something went wrong",
timestamp: Date.now(),
}])
setSending(false)
}
}
}
} catch (err: any) {
if (err.name !== "AbortError") {
setMessages(prev => [...prev.filter(m => !m.streaming), {
id: `err-${Date.now()}`,
role: "system",
content: "Failed to send message. Is Tiger running?",
timestamp: Date.now(),
}])
}
setSending(false)
}
abortRef.current = null
}
const handleAbort = () => {
abortRef.current?.abort()
setSending(false)
streamingRef.current = ""
setMessages(prev => prev.filter(m => !m.streaming))
}
return (
<Card className={cn("w-full flex flex-col", className)} {...props}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Bot className="h-5 w-5" />
Chat with Tiger
</CardTitle>
<Button
type="button"
variant="ghost"
size="sm"
onClick={clearChat}
className="text-xs text-muted-foreground h-7"
title="Clear conversation"
>
<Eraser className="h-3 w-3 mr-1" />
Clear
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 p-0 min-h-0">
<ScrollArea className="h-[500px] px-4" ref={scrollRef}>
<div className="space-y-4 py-2">
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-2 max-w-[85%]",
message.role === "user" ? "ml-auto flex-row-reverse" : "",
message.role === "system" ? "mx-auto max-w-full" : ""
)}
>
{message.role !== "system" && (
<div className={cn(
"flex-shrink-0 h-7 w-7 rounded-full flex items-center justify-center",
message.role === "user" ? "bg-primary" : "bg-muted"
)}>
{message.role === "user" ? (
<User className="h-4 w-4 text-primary-foreground" />
) : (
<Bot className="h-4 w-4" />
)}
</div>
)}
<div className={cn(
"rounded-lg px-3 py-2 text-sm",
message.role === "user"
? "bg-primary text-primary-foreground"
: message.role === "system"
? "bg-destructive/10 text-destructive flex items-center gap-2 w-full justify-center"
: "bg-muted",
message.streaming ? "border border-primary/30" : ""
)}>
{message.role === "system" && <AlertCircle className="h-3 w-3" />}
{message.role === "agent" ? (
// While streaming: render raw text (cheap, one DOM node update per token).
// After streaming completes: render full ReactMarkdown (expensive but
// only happens once). This is what makes the typing feel actually show up.
message.streaming ? (
<div className="whitespace-pre-wrap">{message.content}</div>
) : (
<div className="prose prose-sm prose-invert max-w-none [&>p]:m-0">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
)
) : (
message.content
)}
{message.streaming && (
<span className="inline-block w-1.5 h-4 bg-primary/70 animate-pulse ml-0.5" />
)}
</div>
</div>
))}
{sending && !messages.some(m => m.streaming) && (
<div className="flex gap-2">
<div className="h-7 w-7 rounded-full bg-muted flex items-center justify-center">
<Bot className="h-4 w-4" />
</div>
<div className="bg-muted rounded-lg px-3 py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
</div>
)}
</div>
</ScrollArea>
</CardContent>
<CardFooter className="pt-3">
<form onSubmit={handleSubmit} className="flex w-full items-center space-x-2">
<Input
placeholder="Message Tiger..."
className="flex-1"
autoComplete="off"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={sending}
/>
{sending ? (
<Button type="button" size="icon" variant="destructive" onClick={handleAbort}>
<Square className="h-4 w-4" />
</Button>
) : (
<Button type="submit" size="icon" disabled={!input.trim()}>
<Send className="h-4 w-4" />
</Button>
)}
</form>
</CardFooter>
</Card>
)
}

View file

@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View file

@ -0,0 +1,79 @@
"use client"
/**
* activity-feed.tsx chronological list of recent file modifications
* across all agents.
*/
import * as React from "react"
import { Clock, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
export interface ActivityEvent {
agentId: string
agentName: string
agentEmoji: string
path: string
action: string
ts: number
}
interface Props {
events: ActivityEvent[]
loading?: boolean
}
function relativeTime(ts: number): string {
const diff = Date.now() - ts
const s = Math.floor(diff / 1000)
if (s < 60) return `${s}s ago`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
export function ActivityFeed({ events, loading }: Props) {
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)
}
if (events.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<Clock className="h-6 w-6" />
<span className="text-sm">No recent activity</span>
</div>
)
}
return (
<div className="space-y-1">
{events.map((ev, i) => (
<div
key={i}
className="flex items-start gap-3 px-3 py-2.5 rounded-md hover:bg-muted/40 transition-colors"
>
{/* Agent avatar */}
<span className="text-base leading-none mt-0.5 shrink-0">{ev.agentEmoji}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium">{ev.agentName}</span>
<span className="text-xs text-muted-foreground">{ev.action}</span>
</div>
<p className="text-xs text-muted-foreground font-mono truncate">{ev.path}</p>
</div>
<span className="text-xs text-muted-foreground shrink-0 mt-0.5">
{relativeTime(ev.ts)}
</span>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,75 @@
"use client"
/**
* agent-chip-row.tsx horizontal chip row for selecting agents
* Scrolls horizontally on mobile. Badge shows fileCount.
*/
import * as React from "react"
import { cn } from "@/lib/utils"
export interface AgentInfo {
id: string
name: string
emoji: string
role: string
fileCount: number
lastActivity: number
}
interface Props {
agents: AgentInfo[]
activeId: string | null // null = "All"
onChange: (id: string | null) => void
recentIds?: Set<string> // agent ids that had recent activity (for badge highlight)
}
export function AgentChipRow({ agents, activeId, onChange, recentIds }: Props) {
return (
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
{/* All chip */}
<button
onClick={() => onChange(null)}
className={cn(
"flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
activeId === null
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
)}
>
🗂 All
</button>
{agents.map((agent) => {
const isActive = activeId === agent.id
const hasRecent = recentIds?.has(agent.id)
return (
<button
key={agent.id}
onClick={() => onChange(agent.id)}
className={cn(
"flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
)}
>
<span>{agent.emoji}</span>
<span>{agent.name}</span>
{agent.fileCount > 0 && (
<span className={cn(
"text-xs px-1.5 py-0.5 rounded-full font-mono",
isActive
? "bg-primary-foreground/20 text-primary-foreground"
: hasRecent
? "bg-orange-500/20 text-orange-400"
: "bg-background/40"
)}>
{agent.fileCount}
</span>
)}
</button>
)
})}
</div>
)
}

View file

@ -0,0 +1,376 @@
"use client"
/**
* file-preview.tsx Multi-mode file viewer/editor
*
* Mode is derived from file extension + mime type:
*
* EDIT_SAVE .md .txt textarea editor, Save/Cancel in toolbar
* HTML_RENDER .html .htm sandboxed <iframe srcdoc>, "View Source" toggle
* CODE_VIEW .ts .tsx .js .jsx read-only <pre> with token colouring
* .py .sh .json .yaml
* .yml .toml .css .xml
* IMAGE image/* <img> from base64 data-URI
* BINARY everything else "Download" only, no content shown
*/
import * as React from "react"
import ReactMarkdown from "react-markdown"
import {
Download, Edit3, Save, X, Code, Eye, Loader2, AlertCircle
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
// ─── Types ────────────────────────────────────────────────────────────────────
interface Props {
agentId: string | null
path: string | null
content: string | null
encoding: "utf8" | "base64" | null
mime: string | null
size: number
loading?: boolean
/** Called after a successful save so the parent can refresh if needed */
onSaved?: (path: string, newContent: string) => void
}
type ViewMode = "edit_save" | "html_render" | "code_view" | "image" | "binary"
// ─── Helpers ─────────────────────────────────────────────────────────────────
function detectMode(path: string | null, mime: string | null): ViewMode {
const ext = path?.split(".").pop()?.toLowerCase() ?? ""
if (["md", "txt"].includes(ext)) return "edit_save"
if (["html", "htm"].includes(ext)) return "html_render"
if (
["ts", "tsx", "js", "jsx", "py", "sh", "json", "yaml", "yml",
"toml", "css", "xml", "env", "conf", "ini", "sql", "rs", "go",
"java", "c", "cpp", "h"].includes(ext)
) return "code_view"
if (mime?.startsWith("image/")) return "image"
if (mime?.startsWith("text/") || mime?.includes("json") || mime?.includes("xml")) return "code_view"
return "binary"
}
function formatSize(bytes: number) {
if (!bytes) return ""
if (bytes < 1024) return `${bytes} B`
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1048576).toFixed(1)} MB`
}
/** Very lightweight syntax token colouring — no deps, pure CSS classes via regex spans */
function tokenise(code: string, ext: string): string {
// Escape HTML first
const escaped = code
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
const isJS = ["ts", "tsx", "js", "jsx"].includes(ext)
const isPy = ext === "py"
const isJSON = ext === "json"
if (isJSON) {
return escaped
.replace(/("(?:[^"\\]|\\.)*")(\s*:)/g, '<span class="tk-key">$1</span>$2')
.replace(/:\s*("(?:[^"\\]|\\.)*")/g, ': <span class="tk-str">$1</span>')
.replace(/\b(true|false|null)\b/g, '<span class="tk-kw">$1</span>')
.replace(/\b(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/g, '<span class="tk-num">$1</span>')
}
if (isJS || isPy) {
const kwJS = "const|let|var|function|return|import|export|from|default|class|extends|new|if|else|for|while|async|await|try|catch|throw|typeof|interface|type|enum"
const kwPy = "def|class|return|import|from|if|elif|else|for|while|try|except|raise|with|as|pass|lambda|yield|async|await|None|True|False"
const kw = isPy ? kwPy : kwJS
return escaped
.replace(/(\/\/[^\n]*|#[^\n]*)/g, '<span class="tk-comment">$1</span>')
.replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g, '<span class="tk-str">$1</span>')
.replace(new RegExp(`\\b(${kw})\\b`, "g"), '<span class="tk-kw">$1</span>')
.replace(/\b(\d+(?:\.\d+)?)\b/g, '<span class="tk-num">$1</span>')
}
return escaped
}
// ─── Sub-components ───────────────────────────────────────────────────────────
/** Toolbar shown at top of the pane */
function Toolbar({
path, size, mode, editing, dirty, saving, saveError,
onEdit, onSave, onCancel, onDownload, showSource, onToggleSource,
}: {
path: string | null; size: number; mode: ViewMode; editing: boolean
dirty: boolean; saving: boolean; saveError: string | null
onEdit: () => void; onSave: () => void; onCancel: () => void
onDownload: () => void; showSource: boolean; onToggleSource: () => void
}) {
const filename = path?.split("/").pop() ?? ""
return (
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0 gap-2 flex-wrap">
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">{filename || "No file selected"}</span>
{path && (
<span className="text-xs text-muted-foreground truncate max-w-xs">{path}</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{saveError && (
<span className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3.5 w-3.5" /> {saveError}
</span>
)}
{size > 0 && !editing && (
<span className="text-xs text-muted-foreground">{formatSize(size)}</span>
)}
{/* HTML: source toggle */}
{mode === "html_render" && path && (
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onToggleSource}>
{showSource ? <Eye className="h-3.5 w-3.5 mr-1" /> : <Code className="h-3.5 w-3.5 mr-1" />}
{showSource ? "Render" : "Source"}
</Button>
)}
{/* Edit/Save/Cancel for edit_save mode */}
{mode === "edit_save" && path && !editing && (
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onEdit}>
<Edit3 className="h-3.5 w-3.5 mr-1" /> Edit
</Button>
)}
{mode === "edit_save" && editing && (
<>
<Button
variant="ghost" size="sm"
className="h-7 px-2 text-muted-foreground"
onClick={onCancel} disabled={saving}
>
<X className="h-3.5 w-3.5 mr-1" /> Cancel
</Button>
<Button
variant={dirty ? "default" : "ghost"} size="sm"
className="h-7 px-2"
onClick={onSave} disabled={saving || !dirty}
>
{saving
? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
: <Save className="h-3.5 w-3.5 mr-1" />}
{saving ? "Saving…" : "Save"}
</Button>
</>
)}
{/* Download always available when we have content */}
{path && mode !== "edit_save" && (
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onDownload}>
<Download className="h-3.5 w-3.5 mr-1" /> Download
</Button>
)}
</div>
</div>
)
}
// ─── Main component ───────────────────────────────────────────────────────────
export function FilePreview({ agentId, path, content, encoding, mime, size, loading, onSaved }: Props) {
const mode = detectMode(path, mime)
const ext = path?.split(".").pop()?.toLowerCase() ?? ""
// Edit state (edit_save mode)
const [editing, setEditing] = React.useState(false)
const [draft, setDraft] = React.useState("")
const [saving, setSaving] = React.useState(false)
const [saveError, setSaveError] = React.useState<string | null>(null)
const [savedOk, setSavedOk] = React.useState(false)
// HTML mode: show source vs rendered
const [showSource, setShowSource] = React.useState(false)
// Reset edit state when file changes
React.useEffect(() => {
setEditing(false)
setDraft(content ?? "")
setSaveError(null)
setSavedOk(false)
setShowSource(false)
}, [path, content])
const dirty = draft !== (content ?? "")
// ── Edit handlers ──────────────────────────────────────────────────────────
const handleEdit = () => {
setDraft(content ?? "")
setSaveError(null)
setSavedOk(false)
setEditing(true)
}
const handleCancel = () => {
setDraft(content ?? "")
setEditing(false)
setSaveError(null)
}
const handleSave = async () => {
if (!agentId || !path) return
setSaving(true)
setSaveError(null)
setSavedOk(false)
try {
const res = await fetch(
`/api/tiger/agents/${agentId}/file?path=${encodeURIComponent(path)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: draft }),
}
)
const data = await res.json()
if (!data.ok) throw new Error(data.error ?? "Save failed")
setSavedOk(true)
setEditing(false)
onSaved?.(path, draft)
} catch (err: any) {
setSaveError(err.message)
} finally {
setSaving(false)
}
}
// ── Download handler ───────────────────────────────────────────────────────
const handleDownload = () => {
if (!content || !path) return
const filename = path.split("/").pop() ?? "file"
let url: string
if (encoding === "base64") {
url = `data:${mime ?? "application/octet-stream"};base64,${content}`
} else {
const blob = new Blob([content], { type: mime ?? "text/plain" })
url = URL.createObjectURL(blob)
}
const a = document.createElement("a")
a.href = url; a.download = filename; a.click()
if (encoding !== "base64") URL.revokeObjectURL(url)
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<Toolbar
path={path} size={size} mode={mode}
editing={editing} dirty={dirty} saving={saving} saveError={saveError}
onEdit={handleEdit} onSave={handleSave} onCancel={handleCancel}
onDownload={handleDownload}
showSource={showSource} onToggleSource={() => setShowSource((v) => !v)}
/>
{/* Saved banner */}
{savedOk && (
<div className="px-3 py-1.5 text-xs text-green-400 bg-green-500/10 border-b shrink-0">
Saved successfully
</div>
)}
{/* Content area */}
<div className="flex-1 overflow-hidden">
{/* ── Loading ── */}
{loading && (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{/* ── No file selected ── */}
{!loading && !path && (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
Select a file to preview it
</div>
)}
{/* ── EDIT_SAVE: textarea editor or markdown preview ── */}
{!loading && path && mode === "edit_save" && (
editing ? (
<textarea
className="w-full h-full resize-none bg-background/50 font-mono text-xs p-3 outline-none border-0 focus:ring-0"
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck={false}
/>
) : (
<div className={cn(
"h-full overflow-y-auto p-4",
ext === "md"
? "prose prose-sm prose-invert max-w-none text-foreground prose-headings:text-foreground prose-a:text-primary prose-code:bg-muted/60 prose-code:px-1 prose-code:rounded prose-pre:bg-muted/40 prose-pre:border"
: "text-sm font-mono whitespace-pre-wrap break-all text-foreground"
)}>
{ext === "md"
? <ReactMarkdown>{content ?? ""}</ReactMarkdown>
: <span>{content ?? ""}</span>
}
</div>
)
)}
{/* ── HTML_RENDER: iframe or source ── */}
{!loading && path && mode === "html_render" && (
showSource ? (
<div className="h-full overflow-y-auto">
<style>{`.tk-kw{color:#c792ea}.tk-str{color:#c3e88d}.tk-num{color:#f78c6c}.tk-comment{color:#546e7a;font-style:italic}.tk-key{color:#82aaff}`}</style>
<pre
className="text-xs font-mono p-3 leading-relaxed"
dangerouslySetInnerHTML={{ __html: tokenise(content ?? "", "html") }}
/>
</div>
) : (
<iframe
className="w-full h-full border-0 bg-white"
sandbox="allow-scripts allow-same-origin"
srcDoc={content ?? ""}
title={path}
/>
)
)}
{/* ── CODE_VIEW: syntax-highlighted read-only ── */}
{!loading && path && mode === "code_view" && (
<div className="h-full overflow-y-auto">
<style>{`.tk-kw{color:#c792ea}.tk-str{color:#c3e88d}.tk-num{color:#f78c6c}.tk-comment{color:#546e7a;font-style:italic}.tk-key{color:#82aaff}`}</style>
<pre
className="text-xs font-mono p-3 leading-relaxed whitespace-pre-wrap break-all"
dangerouslySetInnerHTML={{ __html: tokenise(content ?? "", ext) }}
/>
</div>
)}
{/* ── IMAGE ── */}
{!loading && path && mode === "image" && encoding === "base64" && (
<div className="h-full overflow-auto flex items-start justify-center p-4">
<img
src={`data:${mime};base64,${content}`}
alt={path}
className="max-w-full rounded-md border"
/>
</div>
)}
{/* ── BINARY: no preview ── */}
{!loading && path && mode === "binary" && (
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
<span className="text-3xl">📦</span>
<span className="text-sm">Binary file {formatSize(size)}</span>
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" /> Download
</Button>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,124 @@
"use client"
/**
* file-tree.tsx collapsible file/directory tree for one agent
*/
import * as React from "react"
import { Folder, FolderOpen, FileText, FileCode, ChevronRight, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
export interface FileItem {
name: string
type: "file" | "dir"
size: number
modifiedAt: number
}
interface Props {
items: FileItem[]
currentPath: string // relative path being shown (e.g. "deliverables")
selectedFile: string | null // full relative path of selected file
onNavigate: (path: string) => void // navigate into a dir
onSelectFile: (path: string) => void // select a file for preview
loading?: boolean
}
// Extension → colour class mapping
function fileColour(name: string) {
const ext = name.split(".").pop()?.toLowerCase()
if (["ts", "tsx", "js", "jsx", "py", "sh"].includes(ext ?? "")) return "text-blue-400"
if (["md", "txt"].includes(ext ?? "")) return "text-purple-400"
if (["html", "htm"].includes(ext ?? "")) return "text-orange-400"
if (["json", "yaml", "yml", "toml"].includes(ext ?? "")) return "text-green-400"
return "text-muted-foreground"
}
function FileIcon({ name, className }: { name: string; className?: string }) {
const ext = name.split(".").pop()?.toLowerCase()
const isCode = ["ts", "tsx", "js", "jsx", "py", "sh", "json", "yaml", "yml", "html", "toml"].includes(ext ?? "")
return isCode
? <FileCode className={cn("h-4 w-4 shrink-0", className)} />
: <FileText className={cn("h-4 w-4 shrink-0", className)} />
}
function formatSize(bytes: number) {
if (!bytes) return ""
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}
export function FileTree({ items, currentPath, selectedFile, onNavigate, onSelectFile, loading }: Props) {
// Split items into dirs first, then files — both sorted alphabetically
const dirs = items.filter((i) => i.type === "dir").sort((a, b) => a.name.localeCompare(b.name))
const files = items.filter((i) => i.type === "file").sort((a, b) => a.name.localeCompare(b.name))
const sorted = [...dirs, ...files]
// Breadcrumb segments from currentPath
const crumbs = currentPath ? currentPath.split("/").filter(Boolean) : []
if (loading) {
return (
<div className="space-y-1 p-1">
{[1,2,3,4].map((i) => (
<div key={i} className="h-8 rounded-md bg-muted/40 animate-pulse" />
))}
</div>
)
}
return (
<div className="flex flex-col gap-1">
{/* Breadcrumb nav */}
{crumbs.length > 0 && (
<div className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground flex-wrap">
<button onClick={() => onNavigate("")} className="hover:text-foreground transition-colors">
root
</button>
{crumbs.map((crumb, i) => (
<React.Fragment key={i}>
<ChevronRight className="h-3 w-3" />
<button
onClick={() => onNavigate(crumbs.slice(0, i + 1).join("/"))}
className="hover:text-foreground transition-colors"
>
{crumb}
</button>
</React.Fragment>
))}
</div>
)}
{sorted.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-6">Empty directory</div>
)}
{sorted.map((item) => {
const itemFullPath = currentPath ? `${currentPath}/${item.name}` : item.name
const isSelected = selectedFile === itemFullPath
return (
<button
key={item.name}
onClick={() =>
item.type === "dir" ? onNavigate(itemFullPath) : onSelectFile(itemFullPath)
}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-left text-sm transition-colors",
isSelected ? "bg-primary/15 text-foreground" : "hover:bg-muted/60",
)}
>
{item.type === "dir" ? (
<Folder className="h-4 w-4 shrink-0 text-amber-400" />
) : (
<FileIcon name={item.name} className={fileColour(item.name)} />
)}
<span className="truncate flex-1">{item.name}</span>
{item.type === "file" && item.size > 0 && (
<span className="text-xs text-muted-foreground shrink-0">{formatSize(item.size)}</span>
)}
</button>
)
})}
</div>
)
}

View file

@ -4,18 +4,31 @@
* ChatContext chat state that persists across route changes AND across * ChatContext chat state that persists across route changes AND across
* hard refreshes (via the server-side /api/chat/history endpoint). * hard refreshes (via the server-side /api/chat/history endpoint).
* *
* TWO LAYERS OF PERSISTENCE: * THREE LAYERS OF STATE:
* 1. React Context survives client-side navigation between /chat, /workspace * 1. React Context survives client-side navigation between routes
* 2. Server-side history (SQLite in bridge) survives refresh, tab close, * 2. localStorage remembers which sessionKey was active across reloads
* device change. On mount we fetch history and hydrate the messages. * 3. Server-side history sqlite in bridge; survives device change, tab close
*
* SESSION MODEL (post WS-migration):
* Each chat is keyed by a `sessionKey` like:
* - "agent:main:main" the default Tiger conversation
* - "agent:main:webchat-<8hex>" a fresh session created via "+ New"
* The sessionKey is passed to /api/chat so the gateway routes to the right
* conversation memory. It's also passed to /api/chat/history?sessionId=<key>.
* *
* FLOW: * FLOW:
* Mount GET /api/chat/history merge with default welcome message * Mount load sessions list load history for current sessionKey
* Send message optimistic local update /api/chat server persists * New POST /api/chat/sessions set as current, clear messages
* Clear DELETE /api/chat/history reset to just the welcome * Switch set current load history
* Send optimistic UI POST /api/chat with current sessionKey
* Clear DELETE /api/chat/history?sessionId=<current>
* Delete DELETE /api/chat/sessions?key=<x> drop, switch to Main
*/ */
import * as React from "react" import * as React from "react"
const DEFAULT_SESSION_KEY = "agent:main:main"
const STORAGE_KEY = "tiger.currentSessionKey"
export type ChatMessage = { export type ChatMessage = {
id: string id: string
role: "user" | "agent" | "system" role: "user" | "agent" | "system"
@ -24,73 +37,179 @@ export type ChatMessage = {
timestamp: number timestamp: number
} }
export type ChatSession = {
key: string
label: string
updatedAt: number | null
messageCount: number
isDefault: boolean
}
const DEFAULT_WELCOME: ChatMessage = { const DEFAULT_WELCOME: ChatMessage = {
id: "welcome", id: "welcome",
role: "agent", role: "agent",
content: "Hey! I am Tiger, your AI assistant. Send me a message to get started.", content: "Hey! I am Tiger, your AI assistant. Send me a message to get started.",
timestamp: 0, // sentinel — always sorted to the top timestamp: 0,
} }
type ChatContextValue = { type ChatContextValue = {
messages: ChatMessage[] messages: ChatMessage[]
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>> setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
clearChat: () => Promise<void>
loading: boolean loading: boolean
/** The sessionKey currently being chatted with — sent to /api/chat. */
currentSessionKey: string
/** All webchat-visible sessions (from gateway sessions.list). */
sessions: ChatSession[]
/** Switch to an existing session — loads its history. */
selectSession: (key: string) => Promise<void>
/** Mint and switch to a brand-new session — clears messages. */
newSession: () => Promise<void>
/** Delete a non-default session — switches to Main if the active one is removed. */
deleteSession: (key: string) => Promise<void>
/** Wipe the *current* session's history (keeps the session itself). */
clearChat: () => Promise<void>
/** Force-refresh the sessions list (e.g., after a send so updatedAt updates). */
refreshSessions: () => Promise<void>
} }
const ChatContext = React.createContext<ChatContextValue | null>(null) const ChatContext = React.createContext<ChatContextValue | null>(null)
/** Read the saved sessionKey from localStorage (SSR-safe). */
function readPersistedKey(): string {
if (typeof window === "undefined") return DEFAULT_SESSION_KEY
try {
return localStorage.getItem(STORAGE_KEY) || DEFAULT_SESSION_KEY
} catch {
return DEFAULT_SESSION_KEY
}
}
function writePersistedKey(key: string): void {
if (typeof window === "undefined") return
try { localStorage.setItem(STORAGE_KEY, key) } catch { /* quota / private mode */ }
}
/** Fetch history for a sessionKey, returning hydrated messages (welcome first). */
async function loadHistoryFor(sessionKey: string): Promise<ChatMessage[]> {
try {
const r = await fetch(`/api/chat/history?sessionId=${encodeURIComponent(sessionKey)}`, { cache: "no-store" })
if (!r.ok) throw new Error(`history ${r.status}`)
const data = await r.json()
if (!data?.ok || !Array.isArray(data.messages)) return [DEFAULT_WELCOME]
return [
DEFAULT_WELCOME,
...data.messages.map((m: any) => ({
id: String(m.id),
role: m.role,
content: m.content,
timestamp: m.timestamp,
})),
]
} catch (err) {
console.warn("[chat] could not load history for", sessionKey, err)
return [DEFAULT_WELCOME]
}
}
export function ChatProvider({ children }: { children: React.ReactNode }) { export function ChatProvider({ children }: { children: React.ReactNode }) {
const [messages, setMessages] = React.useState<ChatMessage[]>([DEFAULT_WELCOME]) const [messages, setMessages] = React.useState<ChatMessage[]>([DEFAULT_WELCOME])
const [loading, setLoading] = React.useState(true) const [loading, setLoading] = React.useState(true)
const [currentSessionKey, setCurrentSessionKey] = React.useState<string>(DEFAULT_SESSION_KEY)
const [sessions, setSessions] = React.useState<ChatSession[]>([])
// Hydrate from server on mount. This is what makes persistence actually const refreshSessions = React.useCallback(async () => {
// work across hard refresh. try {
const r = await fetch("/api/chat/sessions", { cache: "no-store" })
if (!r.ok) return
const data = await r.json()
if (data?.ok && Array.isArray(data.sessions)) setSessions(data.sessions)
} catch (err) {
console.warn("[chat] sessions list failed:", err)
}
}, [])
// Initial mount: pick stored sessionKey, load its history, fetch sessions list.
React.useEffect(() => { React.useEffect(() => {
let cancelled = false let cancelled = false
async function load() { async function init() {
try { const persisted = readPersistedKey()
const r = await fetch("/api/chat/history", { cache: "no-store" }) if (cancelled) return
if (!r.ok) throw new Error(`history ${r.status}`) setCurrentSessionKey(persisted)
const data = await r.json() const [hist] = await Promise.all([loadHistoryFor(persisted), refreshSessions()])
if (cancelled || !data?.ok || !Array.isArray(data.messages)) return if (!cancelled) {
setMessages(hist)
// Combine welcome + history, no duplicates. Sort by timestamp so setLoading(false)
// it renders in conversational order.
const hydrated: ChatMessage[] = [
DEFAULT_WELCOME,
...data.messages.map((m: any) => ({
id: String(m.id),
role: m.role,
content: m.content,
timestamp: m.timestamp,
})),
]
setMessages(hydrated)
} catch (err) {
console.warn("[chat] could not load history:", err)
} finally {
if (!cancelled) setLoading(false)
} }
} }
load() init()
return () => { return () => { cancelled = true }
cancelled = true }, [refreshSessions])
const selectSession = React.useCallback(async (key: string) => {
if (key === currentSessionKey) return
setLoading(true)
setCurrentSessionKey(key)
writePersistedKey(key)
const hist = await loadHistoryFor(key)
setMessages(hist)
setLoading(false)
}, [currentSessionKey])
const newSession = React.useCallback(async () => {
try {
const r = await fetch("/api/chat/sessions", { method: "POST" })
const data = await r.json()
if (!r.ok || !data?.ok || !data.session?.key) {
console.warn("[chat] new session failed:", data)
return
}
const key = data.session.key as string
setCurrentSessionKey(key)
writePersistedKey(key)
setMessages([DEFAULT_WELCOME])
// Add to local list optimistically; full refresh happens after first send
setSessions(prev => {
if (prev.find(s => s.key === key)) return prev
return [...prev, data.session as ChatSession]
})
} catch (err) {
console.warn("[chat] new session error:", err)
} }
}, []) }, [])
const deleteSession = React.useCallback(async (key: string) => {
if (key === DEFAULT_SESSION_KEY) return
try {
await fetch(`/api/chat/sessions?key=${encodeURIComponent(key)}`, { method: "DELETE" })
} catch (err) {
console.warn("[chat] delete session failed:", err)
}
setSessions(prev => prev.filter(s => s.key !== key))
// If the deleted session was active, fall back to Main.
if (key === currentSessionKey) {
setCurrentSessionKey(DEFAULT_SESSION_KEY)
writePersistedKey(DEFAULT_SESSION_KEY)
const hist = await loadHistoryFor(DEFAULT_SESSION_KEY)
setMessages(hist)
}
}, [currentSessionKey])
const clearChat = React.useCallback(async () => { const clearChat = React.useCallback(async () => {
// Optimistic: clear UI first, then ask server to clear.
setMessages([DEFAULT_WELCOME]) setMessages([DEFAULT_WELCOME])
try { try {
await fetch("/api/chat/history", { method: "DELETE" }) await fetch(`/api/chat/history?sessionId=${encodeURIComponent(currentSessionKey)}`, { method: "DELETE" })
} catch (err) { } catch (err) {
console.warn("[chat] clear on server failed (local cleared):", err) console.warn("[chat] clear server failed (local cleared):", err)
} }
}, []) }, [currentSessionKey])
return ( return (
<ChatContext.Provider value={{ messages, setMessages, clearChat, loading }}> <ChatContext.Provider value={{
messages, setMessages, loading,
currentSessionKey, sessions,
selectSession, newSession, deleteSession,
clearChat, refreshSessions,
}}>
{children} {children}
</ChatContext.Provider> </ChatContext.Provider>
) )

View file

@ -0,0 +1,108 @@
"use client"
/**
* ChatContext — chat state that persists across route changes AND across
* hard refreshes (via the server-side /api/chat/history endpoint).
*
* TWO LAYERS OF PERSISTENCE:
* 1. React Context → survives client-side navigation between /chat, /workspace
* 2. Server-side history (SQLite in bridge) → survives refresh, tab close,
* device change. On mount we fetch history and hydrate the messages.
*
* FLOW:
* Mount → GET /api/chat/history → merge with default welcome message
* Send message → optimistic local update → /api/chat → server persists
* Clear → DELETE /api/chat/history → reset to just the welcome
*/
import * as React from "react"
export type ChatMessage = {
id: string
role: "user" | "agent" | "system"
content: string
streaming?: boolean
timestamp: number
}
const DEFAULT_WELCOME: ChatMessage = {
id: "welcome",
role: "agent",
content: "Hey! I am Tiger, your AI assistant. Send me a message to get started.",
timestamp: 0, // sentinel — always sorted to the top
}
type ChatContextValue = {
messages: ChatMessage[]
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
clearChat: () => Promise<void>
loading: boolean
}
const ChatContext = React.createContext<ChatContextValue | null>(null)
export function ChatProvider({ children }: { children: React.ReactNode }) {
const [messages, setMessages] = React.useState<ChatMessage[]>([DEFAULT_WELCOME])
const [loading, setLoading] = React.useState(true)
// Hydrate from server on mount. This is what makes persistence actually
// work across hard refresh.
React.useEffect(() => {
let cancelled = false
async function load() {
try {
const r = await fetch("/api/chat/history", { cache: "no-store" })
if (!r.ok) throw new Error(`history ${r.status}`)
const data = await r.json()
if (cancelled || !data?.ok || !Array.isArray(data.messages)) return
// Combine welcome + history, no duplicates. Sort by timestamp so
// it renders in conversational order.
const hydrated: ChatMessage[] = [
DEFAULT_WELCOME,
...data.messages.map((m: any) => ({
id: String(m.id),
role: m.role,
content: m.content,
timestamp: m.timestamp,
})),
]
setMessages(hydrated)
} catch (err) {
console.warn("[chat] could not load history:", err)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [])
const clearChat = React.useCallback(async () => {
// Optimistic: clear UI first, then ask server to clear.
setMessages([DEFAULT_WELCOME])
try {
await fetch("/api/chat/history", { method: "DELETE" })
} catch (err) {
console.warn("[chat] clear on server failed (local cleared):", err)
}
}, [])
return (
<ChatContext.Provider value={{ messages, setMessages, clearChat, loading }}>
{children}
</ChatContext.Provider>
)
}
export function useChatContext(): ChatContextValue {
const ctx = React.useContext(ChatContext)
if (!ctx) {
throw new Error(
"useChatContext must be used inside <ChatProvider>. " +
"Make sure app/layout.tsx wraps children with <ChatProvider>."
)
}
return ctx
}

View file

@ -147,6 +147,36 @@ export async function bridgeDelete(path: string): Promise<unknown> {
* @param lines - How many historical lines to tail * @param lines - How many historical lines to tail
* @param filter - Optional keyword filter * @param filter - Optional keyword filter
*/ */
/**
* Make a PUT request to the Tiger Bridge.
* Used for file saves: PUT /tiger/agents/:id/file?path=...
*/
export async function bridgePut(
path: string,
query: Record<string, string> = {},
body: Record<string, unknown> = {}
): Promise<unknown> {
const params = new URLSearchParams(query);
const qs = params.toString() ? `?${params.toString()}` : "";
const res = await fetch(`${BRIDGE_URL}${path}${qs}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
...authHeaders(),
},
body: JSON.stringify(body),
cache: "no-store",
});
if (!res.ok) {
const errBody = await res.text().catch(() => "");
throw new Error(`Bridge PUT ${path} failed: ${res.status} ${errBody}`);
}
return res.json();
}
export function bridgeLogsUrl(lines = 100, filter = ""): string { export function bridgeLogsUrl(lines = 100, filter = ""): string {
const url = new URL(`${BRIDGE_URL}/tiger/logs`); const url = new URL(`${BRIDGE_URL}/tiger/logs`);
url.searchParams.set("lines", String(lines)); url.searchParams.set("lines", String(lines));

View file

@ -0,0 +1,324 @@
/**
* openclaw-ws.ts Direct OpenClaw Gateway WebSocket client for the dashboard.
*
* REPLACES the old /api/chat bridge docker exec fake-streaming chain.
* We now talk WS directly to the gateway and stream tokens token-by-token.
*
* PROTOCOL (verified empirically against gateway 2026.3.12):
* 1. Open WS to ws://127.0.0.1:18789 with Origin matching controlUi.allowedOrigins.
* 2. Gateway pushes unsolicited: {type:"event",event:"connect.challenge",payload:{nonce,ts}}
* 3. We reply within 3s with method:"connect" using client.id="openclaw-control-ui"
* (only this id passes when dangerouslyDisableDeviceAuth=true).
* 4. Gateway returns hello-ok.
* 5. We send method:"agent" with params { idempotencyKey, sessionId, sessionKey, message }.
* KEY DISTINCTION:
* - sessionKey ("agent:main:main", "agent:main:webchat-xyz") = real context handle
* - sessionId = per-request UUID, just for idempotency tracking
* Different sessionKey different conversation memory, isolated context.
* 6. Gateway responds twice on the same req id:
* first res payload.status="accepted" + runId (this is the ACK)
* later res payload.status="ok" + summary + result (this is the FINAL)
* 7. Between (6) we get streaming event:agent frames with payload.stream:
* "lifecycle" data:{phase:"start"|"end"}
* "assistant" data:{text, delta} the actual tokens
*
* EVENT TRANSLATION emitted to caller:
* lifecycle:start { kind:"status", content:"thinking" }
* assistant.delta { kind:"chunk", content:<delta> }
* final res ok { kind:"done", content:<full text>, meta }
* any error / connect bad { kind:"error", content:<message> }
*/
import WebSocket from "ws";
import { randomUUID } from "crypto";
export type AgentEventKind = "status" | "chunk" | "done" | "error";
export interface AgentEvent {
kind: AgentEventKind;
content: string;
meta?: { runId?: string; model?: string; durationMs?: number };
}
export interface StreamAgentArgs {
message: string;
/** OpenClaw session key like "agent:main:main" or "agent:main:webchat-abc". Defaults to "agent:main:main". */
sessionKey?: string;
/** Default: env OPENCLAW_GATEWAY_URL → http://127.0.0.1:18789 */
gatewayWsUrl?: string;
/** Default: env OPENCLAW_GATEWAY_TOKEN */
gatewayToken?: string;
/** Must match gateway.controlUi.allowedOrigins. Default: http://localhost:3100 */
origin?: string;
/** Hard ceiling on the whole run. Default 150s. */
overallTimeoutMs?: number;
/** Tighter timer for handshake only. Default 5s. */
connectTimeoutMs?: number;
}
export const DEFAULT_SESSION_KEY = "agent:main:main";
/** Convert http(s):// to ws(s):// — leaves ws:// untouched. */
function toWsUrl(url: string): string {
if (url.startsWith("ws://") || url.startsWith("wss://")) return url;
return url.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
}
/**
* Generate a new sessionKey for a fresh chat. Caller usually persists this in
* the UI / localStorage and reuses it for follow-up messages in the same chat.
*/
export function newSessionKey(): string {
return `agent:main:webchat-${randomUUID().slice(0, 8)}`;
}
/**
* Async generator that yields AgentEvents in real time.
* Caller (Next.js route) translates each into an SSE frame.
*/
export async function* streamAgentRun(
args: StreamAgentArgs
): AsyncGenerator<AgentEvent, void, void> {
const rawUrl = args.gatewayWsUrl ?? process.env.OPENCLAW_GATEWAY_URL ?? "http://127.0.0.1:18789";
const wsUrl = toWsUrl(rawUrl);
const token = args.gatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? "";
const origin = args.origin ?? process.env.OPENCLAW_GATEWAY_ORIGIN ?? "http://localhost:3100";
const sessionKey = args.sessionKey ?? DEFAULT_SESSION_KEY;
const overallTimeoutMs = args.overallTimeoutMs ?? 150_000;
const connectTimeoutMs = args.connectTimeoutMs ?? 5_000;
if (!token) {
yield { kind: "error", content: "OPENCLAW_GATEWAY_TOKEN not configured" };
return;
}
/* Queue-backed async iterator. WS callbacks push() events; pull() awaits. */
const pending: AgentEvent[] = [];
let resolver: ((ev: AgentEvent | null) => void) | null = null;
let finished = false;
const push = (ev: AgentEvent): void => {
if (finished) return;
if (resolver) { const r = resolver; resolver = null; r(ev); }
else pending.push(ev);
};
const endWith = (ev?: AgentEvent): void => {
if (finished) return;
if (ev) push(ev);
finished = true;
if (resolver) { const r = resolver; resolver = null; r(null); }
};
const pull = (): Promise<AgentEvent | null> => {
if (pending.length > 0) return Promise.resolve(pending.shift()!);
if (finished) return Promise.resolve(null);
return new Promise<AgentEvent | null>((r) => { resolver = r; });
};
const ws = new WebSocket(wsUrl, { headers: { Origin: origin } });
const tStart = Date.now();
// Hard timeout for the whole stream.
const overallTimer = setTimeout(() => {
endWith({ kind: "error", content: `overall timeout after ${overallTimeoutMs}ms` });
try { ws.close(); } catch { /* noop */ }
}, overallTimeoutMs);
// Handshake-only timer (cleared on hello-ok).
let connectTimer: NodeJS.Timeout | null = setTimeout(() => {
endWith({ kind: "error", content: `handshake timeout after ${connectTimeoutMs}ms` });
try { ws.close(); } catch { /* noop */ }
}, connectTimeoutMs);
let connectReqId: string | null = null;
let agentReqId: string | null = null;
let runId: string | null = null;
let accumulatedText = "";
ws.on("error", (err: Error) => {
endWith({ kind: "error", content: `ws error: ${err.message}` });
});
ws.on("close", (code: number, reason: Buffer) => {
if (!finished) {
endWith({ kind: "error", content: `ws closed unexpectedly: ${code} ${reason.toString()}` });
}
});
ws.on("message", (raw: WebSocket.RawData) => {
let frame: any;
try { frame = JSON.parse(raw.toString()); } catch { return; }
/* Step 2: connect.challenge → send connect */
if (frame.type === "event" && frame.event === "connect.challenge") {
connectReqId = randomUUID();
ws.send(JSON.stringify({
type: "req",
id: connectReqId,
method: "connect",
params: {
minProtocol: 3, maxProtocol: 3,
// Only "openclaw-control-ui" passes when dangerouslyDisableDeviceAuth=true
client: { id: "openclaw-control-ui", version: "tiger-dashboard-1.0", platform: "linux", mode: "webchat" },
role: "operator",
scopes: ["operator.read", "operator.write", "operator.admin"],
caps: [], commands: [], permissions: {},
auth: { token },
locale: "en-US", userAgent: "tiger-dashboard/1.0",
},
}));
return;
}
/* Step 4: hello-ok → fire agent run */
if (frame.type === "res" && frame.id === connectReqId) {
if (!frame.ok) {
endWith({ kind: "error", content: `connect rejected: ${JSON.stringify(frame.error)}` });
try { ws.close(); } catch { /* noop */ }
return;
}
if (connectTimer) { clearTimeout(connectTimer); connectTimer = null; }
agentReqId = randomUUID();
ws.send(JSON.stringify({
type: "req",
id: agentReqId,
method: "agent",
params: {
idempotencyKey: randomUUID(),
sessionId: randomUUID(), // per-run UUID, NOT the context handle
sessionKey, // THE context handle — segregates conversation memory
message: args.message,
},
}));
return;
}
/* Steps 6 & 8: res:agent comes twice (ack, then final) */
if (frame.type === "res" && frame.id === agentReqId) {
const status: string | undefined = frame.payload?.status;
if (status === "accepted") {
runId = frame.payload?.runId;
push({ kind: "status", content: "thinking", meta: { runId: runId ?? undefined } });
return;
}
if (status === "ok") {
const finalText: string = frame.payload?.result?.payloads?.[0]?.text ?? accumulatedText;
const model: string | undefined = frame.payload?.result?.meta?.agentMeta?.model;
const durationMs: number = frame.payload?.result?.meta?.durationMs ?? (Date.now() - tStart);
endWith({
kind: "done",
content: finalText,
meta: { runId: runId ?? undefined, model, durationMs },
});
try { ws.close(); } catch { /* noop */ }
return;
}
endWith({
kind: "error",
content: `agent failed: status=${status} ${JSON.stringify(frame.payload?.error ?? frame.error)}`,
});
try { ws.close(); } catch { /* noop */ }
return;
}
/* Step 7: streaming event:agent frames */
if (frame.type === "event" && frame.event === "agent") {
const p = frame.payload ?? {};
// Filter to OUR run — gateway broadcasts events to all operator clients
if (runId && p.runId && p.runId !== runId) return;
if (p.stream === "lifecycle") return; // start/end already covered by status/done
if (p.stream === "assistant") {
const data = p.data ?? {};
const delta: string | undefined = typeof data.delta === "string" ? data.delta : undefined;
const text: string | undefined = typeof data.text === "string" ? data.text : undefined;
let chunk: string | null = null;
if (delta) { chunk = delta; accumulatedText += delta; }
else if (text) {
if (text.startsWith(accumulatedText)) {
chunk = text.slice(accumulatedText.length);
accumulatedText = text;
} else { chunk = text; accumulatedText = text; }
}
if (chunk) push({ kind: "chunk", content: chunk });
return;
}
// Unknown streams (tool, subagent, etc) — surface in future
return;
}
});
/* Drain queue and yield to caller */
try {
while (true) {
const ev = await pull();
if (ev === null) return;
yield ev;
if (ev.kind === "done" || ev.kind === "error") return;
}
} finally {
clearTimeout(overallTimer);
if (connectTimer) clearTimeout(connectTimer);
try { ws.close(); } catch { /* noop */ }
}
}
/* Convenience: simple non-streaming RPC
* For methods that don't need streaming events (sessions.list, sessions.delete, etc).
* Connects, calls, returns the final res, closes.
*/
export async function callGateway<T = any>(
method: string,
params: Record<string, any> = {},
opts: Pick<StreamAgentArgs, "gatewayWsUrl" | "gatewayToken" | "origin"> = {}
): Promise<{ ok: boolean; payload?: T; error?: any }> {
const rawUrl = opts.gatewayWsUrl ?? process.env.OPENCLAW_GATEWAY_URL ?? "http://127.0.0.1:18789";
const wsUrl = toWsUrl(rawUrl);
const token = opts.gatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? "";
const origin = opts.origin ?? process.env.OPENCLAW_GATEWAY_ORIGIN ?? "http://localhost:3100";
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl, { headers: { Origin: origin } });
let connectReqId: string | null = null;
let callReqId: string | null = null;
const timer = setTimeout(() => { try { ws.close(); } catch {} reject(new Error(`callGateway timeout method=${method}`)); }, 30000);
ws.on("error", (err: Error) => { clearTimeout(timer); reject(err); });
ws.on("close", () => { /* normal */ });
ws.on("message", (raw: WebSocket.RawData) => {
let frame: any;
try { frame = JSON.parse(raw.toString()); } catch { return; }
if (frame.type === "event" && frame.event === "connect.challenge") {
connectReqId = randomUUID();
ws.send(JSON.stringify({
type: "req", id: connectReqId, method: "connect",
params: {
minProtocol: 3, maxProtocol: 3,
client: { id: "openclaw-control-ui", version: "1.0", platform: "linux", mode: "webchat" },
role: "operator", scopes: ["operator.read", "operator.write", "operator.admin"],
caps: [], commands: [], permissions: {},
auth: { token }, locale: "en-US", userAgent: "tiger-dashboard/1.0",
},
}));
return;
}
if (frame.type === "res" && frame.id === connectReqId) {
if (!frame.ok) {
clearTimeout(timer); try { ws.close(); } catch {}
return resolve({ ok: false, error: frame.error });
}
callReqId = randomUUID();
ws.send(JSON.stringify({ type: "req", id: callReqId, method, params }));
return;
}
if (frame.type === "res" && frame.id === callReqId) {
// Skip "accepted" intermediate; wait for terminal
if (frame.payload?.status === "accepted") return;
clearTimeout(timer); try { ws.close(); } catch {}
resolve({ ok: !!frame.ok, payload: frame.payload, error: frame.error });
}
});
});
}