chore: save working state before bind-mount setup (pre tiger write access)
This commit is contained in:
parent
03ae4072d9
commit
76620da6b2
36 changed files with 3476 additions and 680 deletions
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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
181
bridge/src/routes/agents.ts
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
197
bridge/src/routes/chat.ts.pre-ws-migration
Normal file
197
bridge/src/routes/chat.ts.pre-ws-migration
Normal 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;
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
19
bridge/src/routes/models.ts
Normal file
19
bridge/src/routes/models.ts
Normal 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;
|
||||||
|
|
@ -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
42
dashboard/lib_smoke.ts
Normal 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); });
|
||||||
BIN
dashboard/public/favicon.ico
Normal file
BIN
dashboard/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
dashboard/public/tiger-icon-32.png
Normal file
BIN
dashboard/public/tiger-icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 248 B |
|
|
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
137
dashboard/src/app/api/chat/route.ts.pre-ws
Normal file
137
dashboard/src/app/api/chat/route.ts.pre-ws
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
dashboard/src/app/api/chat/sessions/route.ts
Normal file
123
dashboard/src/app/api/chat/sessions/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
15
dashboard/src/app/api/tiger/activity/route.ts
Normal file
15
dashboard/src/app/api/tiger/activity/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
37
dashboard/src/app/api/tiger/agents/[id]/file/route.ts
Normal file
37
dashboard/src/app/api/tiger/agents/[id]/file/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
21
dashboard/src/app/api/tiger/agents/[id]/files/route.ts
Normal file
21
dashboard/src/app/api/tiger/agents/[id]/files/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
13
dashboard/src/app/api/tiger/agents/route.ts
Normal file
13
dashboard/src/app/api/tiger/agents/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
13
dashboard/src/app/api/tiger/config/models/route.ts
Normal file
13
dashboard/src/app/api/tiger/config/models/route.ts
Normal 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 |
20
dashboard/src/app/icon.svg
Normal file
20
dashboard/src/app/icon.svg
Normal 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 |
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
return current ?? null
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
297
dashboard/src/components/chat-interface.tsx.pre-ws
Normal file
297
dashboard/src/components/chat-interface.tsx.pre-ws
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
dashboard/src/components/ui/tabs.tsx
Normal file
91
dashboard/src/components/ui/tabs.tsx
Normal 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 }
|
||||||
79
dashboard/src/components/workspace/activity-feed.tsx
Normal file
79
dashboard/src/components/workspace/activity-feed.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
dashboard/src/components/workspace/agent-chip-row.tsx
Normal file
75
dashboard/src/components/workspace/agent-chip-row.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
376
dashboard/src/components/workspace/file-preview.tsx
Normal file
376
dashboard/src/components/workspace/file-preview.tsx
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
dashboard/src/components/workspace/file-tree.tsx
Normal file
124
dashboard/src/components/workspace/file-tree.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
108
dashboard/src/contexts/chat-context.tsx.pre-ws
Normal file
108
dashboard/src/contexts/chat-context.tsx.pre-ws
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
324
dashboard/src/lib/openclaw-ws.ts
Normal file
324
dashboard/src/lib/openclaw-ws.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue