Compare commits
7 commits
0970160f29
...
572418f0ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
572418f0ea | ||
|
|
438488c8c8 | ||
|
|
1a8358bb6a | ||
|
|
61e386f7fe | ||
|
|
c3cd924cdd | ||
| e48441be4e | |||
| 6127b6de24 |
12 changed files with 1294 additions and 167 deletions
|
|
@ -154,7 +154,18 @@ app.use("/tiger/route-task", routeTaskRouter);
|
|||
app.use("/tiger/keys", keysRouter);
|
||||
app.use("/tiger/chat", (await import("./routes/chat.js")).default);
|
||||
app.use("/tiger/chat/mirror", (await import("./routes/chat-mirror.js")).default);
|
||||
// Telegram mirror v2 — reads OpenClaw's native session transcript directly.
|
||||
// (chat-mirror + telegram-webhook above are the legacy write-side, kept for
|
||||
// API compatibility but no longer the data source for the dashboard card.)
|
||||
app.use("/tiger/chat/telegram", (await import("./routes/chat-telegram.js")).default);
|
||||
app.use("/tiger/telegram-webhook", (await import("./routes/telegram-webhook.js")).default);
|
||||
|
||||
// TASKS.md inbox — manual drain trigger (the scheduler below runs it on its own)
|
||||
const { drainInboxOnce, startInboxScheduler } = await import("./lib/inbox.js");
|
||||
app.post("/tiger/inbox/drain", async (_req, res) => {
|
||||
const result = await drainInboxOnce(true);
|
||||
res.json({ ok: !result.startsWith("error"), result });
|
||||
});
|
||||
app.use("/angel", (await import("./routes/angel/positions.js")).default);
|
||||
|
||||
// Gateway proxy — forwards to gateway inside Tiger container
|
||||
|
|
@ -186,6 +197,10 @@ app.listen(PORT, HOST, () => {
|
|||
// Initialize file watcher for task status updates
|
||||
initWatcher();
|
||||
|
||||
// TASKS.md inbox drainer — dispatches one pending item per cycle to a
|
||||
// spawned specialist. See lib/inbox.ts for the contract.
|
||||
startInboxScheduler();
|
||||
|
||||
// Start Telegram channel — bridge takes over from OpenClaw native handler.
|
||||
// Requires channels.telegram.enabled=false in openclaw.json.
|
||||
const tgChannel = new TelegramChannel();
|
||||
|
|
|
|||
132
bridge/src/lib/agents.ts
Normal file
132
bridge/src/lib/agents.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* lib/agents.ts — Sub-agent registry (single source of truth)
|
||||
*
|
||||
* Why this file exists:
|
||||
* Agent identity was previously scattered across spawn.ts, agents-activity.ts,
|
||||
* dispatch.ts and the dashboard, with TWO competing id schemes:
|
||||
* - classifier ids: cody / ethan / cathy / elon (lib/llm.ts AGENT_IDS)
|
||||
* - legacy UI ids: coder / researcher / writer / pm
|
||||
* This registry canonicalizes on the classifier ids and maps legacy
|
||||
* aliases onto them, so every layer can call normalizeAgentId() and agree.
|
||||
*
|
||||
* Personas:
|
||||
* Sub-agents currently run as isolated *sessions* of the `main` OpenClaw
|
||||
* agent (one shared workspace, separate conversation histories). The
|
||||
* persona block below is prepended to the task message, acting as the
|
||||
* specialist's system prompt for that session.
|
||||
*
|
||||
* Upgrade path (documented, not yet taken): define real per-agent entries
|
||||
* in openclaw.json `agents.list`, each with its own IDENTITY.md and
|
||||
* workspace, then change ONE line in spawn.ts — the `--agent` flag.
|
||||
*/
|
||||
|
||||
export type SpecialistId = "cody" | "ethan" | "cathy" | "elon";
|
||||
|
||||
export interface SpecialistAgent {
|
||||
id: SpecialistId;
|
||||
/** Display name used across the dashboard and Telegram reports. */
|
||||
name: string;
|
||||
/** Short role label for UI chips. */
|
||||
role: string;
|
||||
/** Legacy ids that must keep working (old UI, old API callers). */
|
||||
aliases: string[];
|
||||
/** Persona preamble injected at the top of every spawned session. */
|
||||
persona: string;
|
||||
}
|
||||
|
||||
export const SPECIALISTS: Record<SpecialistId, SpecialistAgent> = {
|
||||
cody: {
|
||||
id: "cody",
|
||||
name: "Cody",
|
||||
role: "Code",
|
||||
aliases: ["coder"],
|
||||
persona: [
|
||||
"You are Cody, Tiger's software engineering specialist.",
|
||||
"Scope: code, debugging, devops, deployments, scripts, infra, build systems.",
|
||||
"Style: read existing code before changing it; smallest correct diff;",
|
||||
"state assumptions explicitly; never run destructive commands without flagging.",
|
||||
].join(" "),
|
||||
},
|
||||
ethan: {
|
||||
id: "ethan",
|
||||
name: "Ethan",
|
||||
role: "Research",
|
||||
aliases: ["researcher"],
|
||||
persona: [
|
||||
"You are Ethan, Tiger's research specialist.",
|
||||
"Scope: market research, policy analysis, technical investigation, due diligence.",
|
||||
"Style: cite sources, separate facts from inference, quantify with units,",
|
||||
"end with a short actionable summary.",
|
||||
].join(" "),
|
||||
},
|
||||
cathy: {
|
||||
id: "cathy",
|
||||
name: "Cathy",
|
||||
role: "Write",
|
||||
aliases: ["writer"],
|
||||
persona: [
|
||||
"You are Cathy, Tiger's writing specialist.",
|
||||
"Scope: documents, summaries, reports, communication drafts.",
|
||||
"Style: clear structure, no filler, match the register the task asks for.",
|
||||
].join(" "),
|
||||
},
|
||||
elon: {
|
||||
id: "elon",
|
||||
name: "Elon",
|
||||
role: "PM",
|
||||
aliases: ["pm"],
|
||||
persona: [
|
||||
"You are Elon, Tiger's project management specialist.",
|
||||
"Scope: planning, prioritization, breaking work into tasks, status synthesis.",
|
||||
"Style: concrete next actions with owners and order; surface blockers first.",
|
||||
].join(" "),
|
||||
},
|
||||
};
|
||||
|
||||
/** All ids + aliases that POST /tiger/spawn accepts. */
|
||||
export const ACCEPTED_AGENT_IDS: string[] = Object.values(SPECIALISTS).flatMap(
|
||||
(a) => [a.id, ...a.aliases],
|
||||
);
|
||||
|
||||
/**
|
||||
* Map any accepted id/alias ("coder", "cody", "CODY") to its canonical
|
||||
* specialist, or null if unknown. "tiger"/"main" are deliberately NOT
|
||||
* spawnable — Tiger is the orchestrator, not a sub-agent.
|
||||
*/
|
||||
export function normalizeAgentId(raw: string): SpecialistAgent | null {
|
||||
const id = (raw || "").trim().toLowerCase();
|
||||
for (const agent of Object.values(SPECIALISTS)) {
|
||||
if (agent.id === id || agent.aliases.includes(id)) return agent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message a spawned session receives: persona + task + optional
|
||||
* context + reporting contract. The reporting contract matters — the spawn
|
||||
* runner parses the final reply and relays it to Telegram, so we ask for a
|
||||
* result the human can read in one glance.
|
||||
*/
|
||||
export function buildSpawnPrompt(
|
||||
agent: SpecialistAgent,
|
||||
task: string,
|
||||
context?: string,
|
||||
): string {
|
||||
const parts = [
|
||||
`[SUB-AGENT SESSION — ${agent.name} (${agent.role})]`,
|
||||
agent.persona,
|
||||
"",
|
||||
"TASK:",
|
||||
task.trim(),
|
||||
];
|
||||
if (context && context.trim()) {
|
||||
parts.push("", "CONTEXT:", context.trim());
|
||||
}
|
||||
parts.push(
|
||||
"",
|
||||
"When finished, end your reply with a line starting with 'RESULT:' " +
|
||||
"summarizing the outcome in 1-3 sentences. If you could not complete " +
|
||||
"the task, start that line with 'BLOCKED:' and say what you need.",
|
||||
);
|
||||
return parts.join("\n");
|
||||
}
|
||||
151
bridge/src/lib/inbox.ts
Normal file
151
bridge/src/lib/inbox.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* lib/inbox.ts — TASKS.md as Tiger's inbox, drained by the bridge
|
||||
*
|
||||
* The productivity loop this enables:
|
||||
* You drop a one-line task into the `## 📥 INBOX` section of TASKS.md
|
||||
* (from Telegram via Tiger, from the dashboard workspace editor, or by
|
||||
* hand). Every DRAIN_INTERVAL the bridge picks the FIRST unchecked item,
|
||||
* asks classifyAgent() which specialist owns it, spawns that specialist
|
||||
* (lib/agents.ts + routes/spawn.ts), and rewrites the line in place with
|
||||
* the run id so nothing is picked twice. Completion is reported to
|
||||
* Telegram by the spawn runner.
|
||||
*
|
||||
* Why the BRIDGE schedules this instead of an OpenClaw cron:
|
||||
* - an OpenClaw cron job is itself an agent turn → it would burn a model
|
||||
* call just to decide whether there is work, every hour
|
||||
* - the cron prompt would need the bridge bearer token embedded in it
|
||||
* (a secret inside a prompt — bad pattern)
|
||||
* - the bridge can check TASKS.md for free and only spend model tokens
|
||||
* (one classify call) when there is actually an item to dispatch
|
||||
* The existing "Hourly Task Check-in" cron stays — it is Tiger's
|
||||
* *narrative* status report; this is the *mechanical* dispatcher.
|
||||
*
|
||||
* INBOX line contract (inside TASKS.md):
|
||||
* - [ ] research BESS tender pipeline in Gujarat ← pending
|
||||
* - [⏳ exec_ab12cd → ethan] research BESS tender ... ← dispatched
|
||||
* The drainer only ever touches `- [ ]` lines, one per cycle.
|
||||
*/
|
||||
|
||||
import { writeFileSync, unlinkSync } from "fs";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { classifyAgent } from "./llm.js";
|
||||
import { spawnTask } from "../routes/spawn.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const DOCKER_CONTAINER = "tiger-openclaw";
|
||||
const TASKS_PATH = "/home/node/.openclaw/workspace/TASKS.md";
|
||||
const INBOX_HEADER = "## 📥 INBOX";
|
||||
/** Check every 30 minutes, only act inside working hours (IST). */
|
||||
const DRAIN_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const WORK_HOURS_IST = { start: 9, end: 20 };
|
||||
|
||||
const PENDING_LINE = /^- \[ \] (.+)$/;
|
||||
|
||||
let draining = false;
|
||||
|
||||
function istHour(): number {
|
||||
return Number(
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "Asia/Kolkata",
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date()),
|
||||
);
|
||||
}
|
||||
|
||||
async function readTasksFile(): Promise<string> {
|
||||
const { stdout } = await execAsync(
|
||||
`docker exec ${DOCKER_CONTAINER} cat ${TASKS_PATH}`,
|
||||
{ timeout: 10_000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function writeTasksFile(content: string): Promise<void> {
|
||||
// docker cp (same escaping-proof transport as spawn/telegram message passing)
|
||||
const tmp = `/tmp/tasks_inbox_${Date.now()}.md`;
|
||||
writeFileSync(tmp, content, "utf-8");
|
||||
try {
|
||||
await execAsync(`docker cp ${tmp} ${DOCKER_CONTAINER}:${TASKS_PATH}`, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
} finally {
|
||||
unlinkSync(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One drain cycle: dispatch at most ONE pending inbox item.
|
||||
* Exported so routes can trigger it manually (POST /tiger/inbox/drain).
|
||||
* Returns a human-readable outcome for logs/API.
|
||||
*/
|
||||
export async function drainInboxOnce(force = false): Promise<string> {
|
||||
if (draining) return "skipped: drain already in progress";
|
||||
const hour = istHour();
|
||||
if (!force && (hour < WORK_HOURS_IST.start || hour >= WORK_HOURS_IST.end)) {
|
||||
return `skipped: outside work hours (IST hour ${hour})`;
|
||||
}
|
||||
|
||||
draining = true;
|
||||
try {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readTasksFile();
|
||||
} catch {
|
||||
return "skipped: TASKS.md not readable";
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
const headerIdx = lines.findIndex((l) => l.trim().startsWith(INBOX_HEADER));
|
||||
if (headerIdx === -1) return "skipped: no INBOX section in TASKS.md";
|
||||
|
||||
// Scan from the header to the next section header (or EOF).
|
||||
let target = -1;
|
||||
let taskText = "";
|
||||
for (let i = headerIdx + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.startsWith("## ")) break; // next section — inbox ended
|
||||
const m = line.match(PENDING_LINE);
|
||||
if (m) {
|
||||
target = i;
|
||||
taskText = m[1].trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (target === -1) return "ok: inbox empty";
|
||||
|
||||
// Route → spawn → mark, in that order. If classify fails (e.g. the LLM
|
||||
// gateway is down) we leave the line untouched and retry next cycle.
|
||||
const { agent: agentId, reason } = await classifyAgent(taskText);
|
||||
const spawnable = agentId === "tiger" ? "elon" : agentId; // orchestrator work → PM
|
||||
const ticket = spawnTask({ agentId: spawnable, task: taskText });
|
||||
|
||||
lines[target] = `- [⏳ ${ticket.runId} → ${ticket.agent.id}] ${taskText}`;
|
||||
await writeTasksFile(lines.join("\n"));
|
||||
|
||||
console.log(
|
||||
`[inbox] dispatched "${taskText.slice(0, 60)}" → ${ticket.agent.id} ` +
|
||||
`(${ticket.runId}; classifier said ${agentId}: ${reason.slice(0, 80)})`,
|
||||
);
|
||||
return `dispatched: ${ticket.runId} → ${ticket.agent.id}`;
|
||||
} catch (err) {
|
||||
const m = err instanceof Error ? err.message : String(err);
|
||||
console.error("[inbox] drain failed:", m);
|
||||
return `error: ${m}`;
|
||||
} finally {
|
||||
draining = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Call once from index.ts at startup. */
|
||||
export function startInboxScheduler(): void {
|
||||
setInterval(() => {
|
||||
void drainInboxOnce();
|
||||
}, DRAIN_INTERVAL_MS);
|
||||
console.log(
|
||||
`[inbox] scheduler started — every ${DRAIN_INTERVAL_MS / 60000}min, ` +
|
||||
`${WORK_HOURS_IST.start}:00–${WORK_HOURS_IST.end}:00 IST`,
|
||||
);
|
||||
}
|
||||
|
|
@ -6,22 +6,24 @@
|
|||
* generateProjectTitle(text) → 3-7 word project title
|
||||
* generateProjectGoal(text) → one-line success criterion
|
||||
*
|
||||
* Configured via env vars (already declared in bridge/.env):
|
||||
* Configured via env vars (declared in bridge/.env):
|
||||
* TIGER_ROUTER_MODEL Model slug for ALL router calls.
|
||||
* Examples:
|
||||
* "anthropic/claude-haiku-4-5" → Anthropic API direct
|
||||
* "minimax/MiniMax-M2.7" → OpenRouter
|
||||
* "openrouter/auto" → OpenRouter (meta-router)
|
||||
* Default if unset: "anthropic/claude-haiku-4-5".
|
||||
* "minimax-3" → self-hosted LiteLLM gateway
|
||||
* Default if unset: "minimax-3" (gateway).
|
||||
* ANTHROPIC_API_KEY Required when ROUTER_MODEL has "anthropic/" prefix.
|
||||
* OPENROUTER_API_KEY Required for everything else.
|
||||
* LLM_GATEWAY_URL Self-hosted gateway base URL.
|
||||
* Default: https://llm.manohargupta.com/v1
|
||||
* LLM_GATEWAY_KEY Bearer key for the gateway (LiteLLM master/virtual key).
|
||||
*
|
||||
* Routing rule (intentionally simple):
|
||||
* slug startsWith "anthropic/" → Anthropic API, model = slug minus "anthropic/"
|
||||
* anything else → OpenRouter, model = slug verbatim
|
||||
* anything else → LiteLLM gateway, model = slug verbatim
|
||||
*
|
||||
* Note: "openrouter/auto" is OR's literal model ID, so we DON'T strip it.
|
||||
* This is why the rule only special-cases "anthropic/".
|
||||
* OpenRouter was removed 2026-06-10: its credits ran dry and silently took
|
||||
* classifyAgent down with it. The gateway runs on Manohar's own MiniMax /
|
||||
* Anthropic keys, so there is no third-party balance to surprise us.
|
||||
*
|
||||
* Failure mode (the most important property):
|
||||
* Every public helper catches errors internally. Callers never see exceptions
|
||||
|
|
@ -34,9 +36,10 @@
|
|||
*/
|
||||
|
||||
// ─── Configuration ─────────────────────────────────────────────────────────
|
||||
const ROUTER_MODEL = process.env.TIGER_ROUTER_MODEL || "anthropic/claude-haiku-4-5";
|
||||
const ROUTER_MODEL = process.env.TIGER_ROUTER_MODEL || "minimax-3";
|
||||
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || "";
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || "";
|
||||
const LLM_GATEWAY_URL = (process.env.LLM_GATEWAY_URL || "https://llm.manohargupta.com/v1").replace(/\/$/, "");
|
||||
const LLM_GATEWAY_KEY = process.env.LLM_GATEWAY_KEY || "";
|
||||
const ANTHROPIC_VERSION = "2023-06-01";
|
||||
|
||||
// Curated list of valid agent IDs. Used to validate classifier output.
|
||||
|
|
@ -45,7 +48,7 @@ export type AgentId = (typeof AGENT_IDS)[number];
|
|||
|
||||
// ─── Internal: provider resolution ──────────────────────────────────────────
|
||||
interface ResolvedModel {
|
||||
provider: "anthropic" | "openrouter";
|
||||
provider: "anthropic" | "gateway";
|
||||
model: string;
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +60,7 @@ function resolveModel(slug: string): ResolvedModel {
|
|||
if (slug.startsWith("anthropic/")) {
|
||||
return { provider: "anthropic", model: slug.slice("anthropic/".length) };
|
||||
}
|
||||
return { provider: "openrouter", model: slug };
|
||||
return { provider: "gateway", model: slug };
|
||||
}
|
||||
|
||||
// ─── Internal: low-level LLM call ───────────────────────────────────────────
|
||||
|
|
@ -107,18 +110,15 @@ async function callLLM(
|
|||
return text.trim();
|
||||
}
|
||||
|
||||
// OpenRouter (catch-all for everything except "anthropic/")
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
throw new Error("OPENROUTER_API_KEY not set");
|
||||
// Self-hosted LiteLLM gateway (catch-all for everything except "anthropic/")
|
||||
if (!LLM_GATEWAY_KEY) {
|
||||
throw new Error("LLM_GATEWAY_KEY not set");
|
||||
}
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
const res = await fetch(`${LLM_GATEWAY_URL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
||||
Authorization: `Bearer ${LLM_GATEWAY_KEY}`,
|
||||
"content-type": "application/json",
|
||||
// OR recommends these for observability/ranking — harmless if ignored.
|
||||
"HTTP-Referer": "https://agent.manohargupta.com",
|
||||
"X-Title": "Tiger Bridge Router",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
|
|
@ -131,13 +131,13 @@ async function callLLM(
|
|||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "<no body>");
|
||||
throw new Error(`OpenRouter API ${res.status}: ${errBody.slice(0, 200)}`);
|
||||
throw new Error(`LLM gateway ${res.status}: ${errBody.slice(0, 200)}`);
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
};
|
||||
const text = data.choices?.[0]?.message?.content;
|
||||
if (!text) throw new Error("OpenRouter returned no message content");
|
||||
if (!text) throw new Error("LLM gateway returned no message content");
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,9 +37,12 @@ function timeAgo(timestamp: number | null): string {
|
|||
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
// Use execInSandbox to call /tiger/agents from inside OpenClaw container
|
||||
// Use execInSandbox to call /tiger/agents from inside OpenClaw container.
|
||||
// Token comes from env — a previous version hardcoded it here, which
|
||||
// leaked it to the public GitHub mirror (rotated 2026-06-10).
|
||||
const token = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
const { stdout } = await execInSandbox(
|
||||
`curl -s "http://172.17.0.1:3456/tiger/agents" -H "Authorization: Bearer 14fb879429386b69beac339bbd98e43011ec29485da17592410da34ed97e0236"`
|
||||
`curl -s "http://172.17.0.1:3456/tiger/agents" -H "Authorization: Bearer ${token}"`
|
||||
);
|
||||
|
||||
let rawData: any;
|
||||
|
|
|
|||
172
bridge/src/routes/chat-telegram.ts
Normal file
172
bridge/src/routes/chat-telegram.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* chat-telegram.ts — GET /tiger/chat/telegram : the REAL Telegram mirror
|
||||
*
|
||||
* History of this feature (why the old one showed nothing):
|
||||
* The original design (telegram-webhook.ts + chat-mirror.ts) waited for
|
||||
* Telegram to POST updates to the bridge. But the bot is handled by
|
||||
* OpenClaw's NATIVE telegram channel via long-polling, and Telegram's API
|
||||
* forbids webhook + getUpdates on the same bot token — so the webhook was
|
||||
* never registered, chat_messages never received a single Telegram row,
|
||||
* and the dashboard card stayed empty. Even if it had worked, it could
|
||||
* only see inbound messages, never Tiger's replies.
|
||||
*
|
||||
* This route reads the conversation from the source of truth instead:
|
||||
* OpenClaw's session transcript (JSONL) for the telegram:direct session.
|
||||
* It is the same file Tiger's own context is built from, so the dashboard
|
||||
* is in perfect sync by construction — both directions, full history,
|
||||
* nothing to register, nothing to double-write.
|
||||
*
|
||||
* GET /tiger/chat/telegram?limit=50&before=<seq>
|
||||
* → { ok, sessionKey, messages: [{ seq, role, text, timestamp }], hasMore, oldestSeq }
|
||||
* - messages are ascending by seq (chronological)
|
||||
* - `before` pages backwards through history (omit for the newest page)
|
||||
*
|
||||
* Transcript line shape (verified live on tiger-config volume):
|
||||
* { type:"message", timestamp:"2026-06-09T21:08:38.574Z",
|
||||
* message:{ role:"user"|"assistant"|"toolResult", content:[{type:"text",text}] } }
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { readFileSync, statSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// The bridge runs on the host as root, so it reads the docker volume directly —
|
||||
// no docker-exec hop on a route that the dashboard polls every few seconds.
|
||||
const DATA_DIR =
|
||||
process.env.OPENCLAW_DATA_DIR ||
|
||||
"/var/lib/docker/volumes/tiger_tiger-config/_data";
|
||||
const SESSIONS_DIR = join(DATA_DIR, "agents", "main", "sessions");
|
||||
|
||||
interface ThreadMessage {
|
||||
seq: number;
|
||||
role: "user" | "agent";
|
||||
text: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface SessionIndexEntry {
|
||||
sessionId?: string;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
/** Newest telegram session: key + transcript path. */
|
||||
function resolveTelegramSession(): { key: string; file: string } | null {
|
||||
let index: Record<string, SessionIndexEntry>;
|
||||
try {
|
||||
index = JSON.parse(
|
||||
readFileSync(join(SESSIONS_DIR, "sessions.json"), "utf-8"),
|
||||
) as Record<string, SessionIndexEntry>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = Object.entries(index)
|
||||
.filter(([key]) => key.includes(":telegram:") || key.includes(":tg_"))
|
||||
.sort((a, b) => (b[1].updatedAt ?? 0) - (a[1].updatedAt ?? 0));
|
||||
|
||||
for (const [key, entry] of candidates) {
|
||||
if (entry.sessionId) {
|
||||
return { key, file: join(SESSIONS_DIR, `${entry.sessionId}.jsonl`) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Parse cache ─────────────────────────────────────────────────────────────
|
||||
// The card polls for new messages; re-parsing the whole transcript each poll
|
||||
// is wasted work. Cache the parsed array keyed on (path, mtime, size).
|
||||
|
||||
let cache: { file: string; mtimeMs: number; size: number; messages: ThreadMessage[] } | null = null;
|
||||
|
||||
function parseTranscript(file: string): ThreadMessage[] {
|
||||
const st = statSync(file);
|
||||
if (
|
||||
cache &&
|
||||
cache.file === file &&
|
||||
cache.mtimeMs === st.mtimeMs &&
|
||||
cache.size === st.size
|
||||
) {
|
||||
return cache.messages;
|
||||
}
|
||||
|
||||
const messages: ThreadMessage[] = [];
|
||||
const lines = readFileSync(file, "utf-8").split("\n");
|
||||
let seq = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
seq += 1;
|
||||
let entry: Record<string, any>;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
const role = entry.message?.role;
|
||||
if (role !== "user" && role !== "assistant") continue; // skip toolResult etc.
|
||||
|
||||
const content: unknown = entry.message?.content;
|
||||
let text = "";
|
||||
if (typeof content === "string") {
|
||||
text = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
text = content
|
||||
.filter((c) => c && c.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
text = text.trim();
|
||||
if (!text) continue; // tool-call-only assistant turns have no text
|
||||
|
||||
messages.push({
|
||||
seq,
|
||||
role: role === "user" ? "user" : "agent",
|
||||
text,
|
||||
timestamp: typeof entry.timestamp === "string" ? entry.timestamp : "",
|
||||
});
|
||||
}
|
||||
|
||||
cache = { file, mtimeMs: st.mtimeMs, size: st.size, messages };
|
||||
return messages;
|
||||
}
|
||||
|
||||
router.get("/", (req: Request, res: Response) => {
|
||||
const limit = Math.min(
|
||||
Math.max(parseInt(String(req.query.limit ?? "50"), 10) || 50, 1),
|
||||
200,
|
||||
);
|
||||
const before = parseInt(String(req.query.before ?? ""), 10) || null;
|
||||
|
||||
const session = resolveTelegramSession();
|
||||
if (!session) {
|
||||
return res.status(404).json({
|
||||
ok: false,
|
||||
error: "No telegram session found in OpenClaw session index",
|
||||
});
|
||||
}
|
||||
|
||||
let all: ThreadMessage[];
|
||||
try {
|
||||
all = parseTranscript(session.file);
|
||||
} catch (err) {
|
||||
const m = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ ok: false, error: `transcript read failed: ${m}` });
|
||||
}
|
||||
|
||||
const upTo = before === null ? all : all.filter((m) => m.seq < before);
|
||||
const page = upTo.slice(-limit);
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
sessionKey: session.key,
|
||||
messages: page,
|
||||
hasMore: upTo.length > page.length,
|
||||
oldestSeq: page.length > 0 ? page[0].seq : null,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,66 +1,322 @@
|
|||
/**
|
||||
* spawn.ts — POST /tiger/spawn
|
||||
* spawn.ts — POST /tiger/spawn : REAL sub-agent execution
|
||||
*
|
||||
* Trigger spawning of sub-agents. This is a placeholder -
|
||||
* real implementation requires sub-agent permission config.
|
||||
* Replaces the long-standing placeholder. A spawn is an isolated OpenClaw
|
||||
* session of the `main` agent running with a specialist persona prepended
|
||||
* (see lib/agents.ts for the registry and the per-agent upgrade path).
|
||||
*
|
||||
* POST /tiger/spawn
|
||||
* { agentId: "coder" | "researcher" | "writer" | "pm", task: "..." }
|
||||
* Flow per spawn:
|
||||
* 1. validate + normalize agent id (accepts cody/ethan/cathy/elon + legacy aliases)
|
||||
* 2. insert a row into `executions` (status = running while exit_code IS NULL)
|
||||
* 3. enqueue — at most MAX_CONCURRENT sessions run at once. The VPS is
|
||||
* memory-constrained; parallel agent turns push it into swap and every
|
||||
* turn times out. Serializing is a feature, not a limitation.
|
||||
* 4. run `openclaw agent --session-id spawn-<agent>-<n> ... --json` inside
|
||||
* the tiger-openclaw container. The message travels via docker cp of a
|
||||
* temp file — same battle-tested pattern as lib/telegram.ts, immune to
|
||||
* shell-escaping bugs from quotes/backticks/JSON in task text.
|
||||
* 5. parse the reply, complete the executions row, fire a Telegram
|
||||
* notification through the bridge's own /tiger/notify route.
|
||||
*
|
||||
* Response:
|
||||
* { ok: true, sessionId, status: "spawned" | "pending" }
|
||||
* Routes:
|
||||
* POST /tiger/spawn { agentId, task, context?, taskId? }
|
||||
* GET /tiger/spawn/runs recent spawn runs (+ live queue state)
|
||||
* GET /tiger/spawn/runs/:id one run with full output
|
||||
* GET /tiger/spawn/agents the specialist registry
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { writeFileSync, unlinkSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
import db, { generateId } from "../db.js";
|
||||
import {
|
||||
SPECIALISTS,
|
||||
ACCEPTED_AGENT_IDS,
|
||||
normalizeAgentId,
|
||||
buildSpawnPrompt,
|
||||
type SpecialistAgent,
|
||||
} from "../lib/agents.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const router = Router();
|
||||
|
||||
const validAgents = ["coder", "researcher", "writer", "pm"];
|
||||
const DOCKER_CONTAINER = "tiger-openclaw";
|
||||
/** One agent turn at a time — see header comment about RAM. Raise after the
|
||||
* server is upgraded / the homelab is evicted. */
|
||||
const MAX_CONCURRENT = 1;
|
||||
/** Keep below the 300s cron budget so cron-triggered spawns can't be the
|
||||
* thing that blows the cron's own timeout. */
|
||||
const SPAWN_TIMEOUT_SECONDS = 240;
|
||||
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const { agentId, task } = req.body;
|
||||
const BRIDGE_SELF_URL = process.env.TIGER_BRIDGE_SELF_URL || "http://127.0.0.1:3456";
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
|
||||
if (!agentId || !validAgents.includes(agentId)) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: `Invalid agent. Use: ${validAgents.join(", ")}`
|
||||
// ─── Run bookkeeping ─────────────────────────────────────────────────────────
|
||||
|
||||
interface SpawnRequest {
|
||||
runId: string;
|
||||
agent: SpecialistAgent;
|
||||
task: string;
|
||||
context?: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface SpawnOutcome {
|
||||
ok: boolean;
|
||||
reply: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let activeCount = 0;
|
||||
const queue: Array<() => Promise<void>> = [];
|
||||
|
||||
function pump(): void {
|
||||
while (activeCount < MAX_CONCURRENT && queue.length > 0) {
|
||||
const job = queue.shift();
|
||||
if (!job) break;
|
||||
activeCount += 1;
|
||||
void job().finally(() => {
|
||||
activeCount -= 1;
|
||||
pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return res.status(400).json({ ok: false, error: "task is required" });
|
||||
// ─── Core runner (exported so lib/inbox.ts can spawn without HTTP) ──────────
|
||||
|
||||
export interface SpawnTicket {
|
||||
runId: string;
|
||||
sessionId: string;
|
||||
agent: { id: string; name: string };
|
||||
queued: number;
|
||||
}
|
||||
|
||||
export function spawnTask(input: {
|
||||
agentId: string;
|
||||
task: string;
|
||||
context?: string;
|
||||
taskId?: string;
|
||||
}): SpawnTicket {
|
||||
const agent = normalizeAgentId(input.agentId);
|
||||
if (!agent) {
|
||||
throw new Error(
|
||||
`Unknown agent '${input.agentId}'. Accepted: ${ACCEPTED_AGENT_IDS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
const task = (input.task || "").trim();
|
||||
if (!task) throw new Error("task is required");
|
||||
|
||||
const runId = generateId("exec");
|
||||
const sessionId = `spawn-${agent.id}-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
// exit_code NULL = still running; completed_at NULL until the turn ends.
|
||||
db.prepare(
|
||||
`INSERT INTO executions (id, task_id, agent, command)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
).run(runId, input.taskId ?? null, agent.id, `spawn: ${task.slice(0, 300)}`);
|
||||
|
||||
const req: SpawnRequest = { runId, agent, task, context: input.context, sessionId };
|
||||
queue.push(() => executeSpawn(req));
|
||||
pump();
|
||||
|
||||
return {
|
||||
runId,
|
||||
sessionId,
|
||||
agent: { id: agent.id, name: agent.name },
|
||||
queued: queue.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeSpawn(req: SpawnRequest): Promise<void> {
|
||||
const { runId, agent, task, context, sessionId } = req;
|
||||
const prompt = buildSpawnPrompt(agent, task, context);
|
||||
const tmpFile = `/tmp/spawn_${runId}.txt`;
|
||||
|
||||
let outcome: SpawnOutcome;
|
||||
try {
|
||||
// Stage the message inside the container (escaping-proof transport).
|
||||
writeFileSync(tmpFile, prompt, "utf-8");
|
||||
await execAsync(`docker cp ${tmpFile} ${DOCKER_CONTAINER}:${tmpFile}`, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
unlinkSync(tmpFile);
|
||||
|
||||
const cmd =
|
||||
`docker exec ${DOCKER_CONTAINER} sh -c '` +
|
||||
`MSG=$(cat ${tmpFile}); rm -f ${tmpFile}; ` +
|
||||
`openclaw agent --session-id ${sessionId} -m "$MSG" --json ` +
|
||||
`--timeout ${SPAWN_TIMEOUT_SECONDS}'`;
|
||||
|
||||
const { stdout } = await execAsync(cmd, {
|
||||
timeout: (SPAWN_TIMEOUT_SECONDS + 30) * 1000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
outcome = { ok: true, reply: extractReply(stdout) };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[spawn] ${runId} (${agent.id}) failed:`, message);
|
||||
outcome = { ok: false, reply: "", error: message };
|
||||
try { unlinkSync(tmpFile); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE executions
|
||||
SET stdout = ?, stderr = ?, exit_code = ?, completed_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(outcome.reply, outcome.error ?? "", outcome.ok ? 0 : 1, runId);
|
||||
|
||||
await notifyCompletion(req, outcome);
|
||||
}
|
||||
|
||||
/** Pull the text reply out of `openclaw agent --json` output. */
|
||||
function extractReply(stdout: string): string {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch {
|
||||
return stdout.trim();
|
||||
}
|
||||
const p = parsed as Record<string, any>;
|
||||
return (
|
||||
p?.result?.payloads?.[0]?.text ||
|
||||
p?.payloads?.[0]?.text ||
|
||||
p?.summary ||
|
||||
p?.text ||
|
||||
p?.output ||
|
||||
stdout.trim()
|
||||
);
|
||||
}
|
||||
|
||||
/** Report the outcome to Telegram via the bridge's own notify route. */
|
||||
async function notifyCompletion(req: SpawnRequest, outcome: SpawnOutcome): Promise<void> {
|
||||
const { agent, task, runId } = req;
|
||||
const resultLine =
|
||||
outcome.reply
|
||||
.split("\n")
|
||||
.reverse()
|
||||
.find((l) => l.startsWith("RESULT:") || l.startsWith("BLOCKED:")) ??
|
||||
outcome.reply.slice(-300);
|
||||
|
||||
const message = outcome.ok
|
||||
? `🤖 *${agent.name}* finished: ${task.slice(0, 120)}\n\n${resultLine.slice(0, 800)}\n\n_run ${runId}_`
|
||||
: `⚠️ *${agent.name}* failed: ${task.slice(0, 120)}\n\n${(outcome.error ?? "unknown error").slice(0, 300)}\n\n_run ${runId}_`;
|
||||
|
||||
try {
|
||||
// Note: Sub-agent spawning requires config
|
||||
// This is a placeholder - returns info about what's needed
|
||||
res.json({
|
||||
ok: true,
|
||||
agentId,
|
||||
task,
|
||||
status: "pending",
|
||||
message: "Sub-agent spawning requires config. Set agents.defaults.subagents in openclaw.json"
|
||||
await fetch(`${BRIDGE_SELF_URL}/tiger/notify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${BRIDGE_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
} catch (err) {
|
||||
// Notification failure must never mark the run failed — log and move on.
|
||||
const m = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[spawn] notify failed for ${runId}:`, m);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP surface ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ExecutionRow {
|
||||
id: string;
|
||||
task_id: string | null;
|
||||
agent: string | null;
|
||||
command: string | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exit_code: number | null;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
function rowStatus(row: ExecutionRow): "running" | "done" | "error" {
|
||||
if (row.exit_code === null) return "running";
|
||||
return row.exit_code === 0 ? "done" : "error";
|
||||
}
|
||||
|
||||
router.post("/", (req: Request, res: Response) => {
|
||||
const { agentId, task, context, taskId } = req.body as {
|
||||
agentId?: string;
|
||||
task?: string;
|
||||
context?: string;
|
||||
taskId?: string;
|
||||
};
|
||||
try {
|
||||
const ticket = spawnTask({
|
||||
agentId: agentId ?? "",
|
||||
task: task ?? "",
|
||||
context,
|
||||
taskId,
|
||||
});
|
||||
res.json({ ok: true, status: "spawned", ...ticket });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
res.status(400).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET available agents
|
||||
router.get("/agents", (_req: Request, res: Response) => {
|
||||
router.get("/runs", (_req: Request, res: Response) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, task_id, agent, command, exit_code, started_at, completed_at
|
||||
FROM executions
|
||||
WHERE command LIKE 'spawn:%'
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 50`,
|
||||
)
|
||||
.all() as ExecutionRow[];
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
agents: validAgents.map(id => ({
|
||||
id,
|
||||
name: id === "coder" ? "Cody" :
|
||||
id === "researcher" ? "Ethan" :
|
||||
id === "writer" ? "Cathy" : "Elon",
|
||||
role: id === "coder" ? "Code" :
|
||||
id === "researcher" ? "Research" :
|
||||
id === "writer" ? "Write" : "PM"
|
||||
}))
|
||||
active: activeCount,
|
||||
queued: queue.length,
|
||||
runs: rows.map((r) => ({
|
||||
runId: r.id,
|
||||
agent: r.agent,
|
||||
task: (r.command ?? "").replace(/^spawn:\s*/, ""),
|
||||
status: rowStatus(r),
|
||||
startedAt: r.started_at,
|
||||
completedAt: r.completed_at,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
export default router
|
||||
router.get("/runs/:id", (req: Request, res: Response) => {
|
||||
const row = db
|
||||
.prepare(`SELECT * FROM executions WHERE id = ?`)
|
||||
.get(req.params.id) as ExecutionRow | undefined;
|
||||
if (!row) return res.status(404).json({ ok: false, error: "run not found" });
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
run: {
|
||||
runId: row.id,
|
||||
agent: row.agent,
|
||||
task: (row.command ?? "").replace(/^spawn:\s*/, ""),
|
||||
status: rowStatus(row),
|
||||
reply: row.stdout,
|
||||
error: row.stderr,
|
||||
startedAt: row.started_at,
|
||||
completedAt: row.completed_at,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/agents", (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
agents: Object.values(SPECIALISTS).map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
role: a.role,
|
||||
aliases: a.aliases,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
34
dashboard/src/app/api/chat/telegram-thread/route.ts
Normal file
34
dashboard/src/app/api/chat/telegram-thread/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* /api/chat/telegram-thread — proxy for the bridge's Telegram mirror.
|
||||
*
|
||||
* GET ?limit=50&before=<seq>
|
||||
* Same proxy pattern as /api/chat/history: the bridge bearer token stays on
|
||||
* the server, the browser only ever talks to this route.
|
||||
*/
|
||||
|
||||
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 async function GET(request: NextRequest) {
|
||||
const limit = request.nextUrl.searchParams.get("limit") || "50";
|
||||
const before = request.nextUrl.searchParams.get("before") || "";
|
||||
const qs = new URLSearchParams({ limit });
|
||||
if (before) qs.set("before", before);
|
||||
|
||||
try {
|
||||
const r = await fetch(`${BRIDGE_URL}/tiger/chat/telegram?${qs.toString()}`, {
|
||||
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await r.json();
|
||||
return NextResponse.json(data, { status: r.status });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Bridge unreachable", details: message },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
33
dashboard/src/app/api/positions/route.ts
Normal file
33
dashboard/src/app/api/positions/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { NextResponse } from "next/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const ANGEL_API_URL = process.env.ANGEL_API_URL || "https://angel.manohargupta.com"
|
||||
|
||||
async function angelFetch(path: string) {
|
||||
const res = await fetch(`${ANGEL_API_URL}${path}`, { cache: "no-store" })
|
||||
if (!res.ok) throw new Error(`angel API ${path} failed: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [posData, histData] = await Promise.all([
|
||||
angelFetch("/api/positions"),
|
||||
angelFetch("/api/pnl-history"),
|
||||
])
|
||||
return NextResponse.json({ ok: true, positions: posData.data ?? [], summary: histData.summary ?? {} })
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const res = await fetch(`${ANGEL_API_URL}/api/refresh`, { method: "POST", cache: "no-store" })
|
||||
if (!res.ok) throw new Error(`refresh failed: ${res.status}`)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 })
|
||||
}
|
||||
}
|
||||
230
dashboard/src/app/positions/page.tsx
Normal file
230
dashboard/src/app/positions/page.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { TrendingUp, TrendingDown, RefreshCw, Activity, DollarSign, BarChart2, Layers } from "lucide-react"
|
||||
import { StatCard } from "@/components/stat-card"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Position {
|
||||
key: string
|
||||
tradingsymbol: string
|
||||
exchange: string
|
||||
instrumenttype: string
|
||||
producttype: string
|
||||
netqty: number
|
||||
ltp: number
|
||||
avg_price: number
|
||||
unrealised_pnl: number
|
||||
realised_pnl: number
|
||||
total_pnl: number
|
||||
is_closed: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
totalUnrealised: number
|
||||
totalRealised: number
|
||||
totalPnl: number
|
||||
openPositions: number
|
||||
asOf?: string
|
||||
}
|
||||
|
||||
function fmt(n: number) {
|
||||
const sign = n >= 0 ? "+" : ""
|
||||
return `${sign}₹${Math.abs(n).toLocaleString("en-IN", { maximumFractionDigits: 0 })}`
|
||||
}
|
||||
|
||||
function PnlCell({ value }: { value: number }) {
|
||||
return (
|
||||
<span className={cn("font-mono tabular-nums", value > 0 ? "text-emerald-500" : value < 0 ? "text-rose-500" : "text-muted-foreground")}>
|
||||
{fmt(value)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PositionsPage() {
|
||||
const [positions, setPositions] = React.useState<Position[]>([])
|
||||
const [summary, setSummary] = React.useState<Summary | null>(null)
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [refreshing, setRefreshing] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = React.useState<Date | null>(null)
|
||||
|
||||
const load = React.useCallback(async (silent = false) => {
|
||||
if (!silent) setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch("/api/positions", { cache: "no-store" })
|
||||
const data = await res.json()
|
||||
if (!data.ok) throw new Error(data.error ?? "Failed to load")
|
||||
setPositions(data.positions ?? [])
|
||||
setSummary(data.summary ?? null)
|
||||
setLastUpdated(new Date())
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await fetch("/api/positions", { method: "POST" })
|
||||
} catch { /* ignore */ }
|
||||
await load(true)
|
||||
setRefreshing(false)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
load()
|
||||
const id = setInterval(() => load(true), 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
const open = positions.filter(p => p.netqty !== 0 && !p.is_closed)
|
||||
const closed = positions.filter(p => p.netqty === 0 && p.is_closed && p.realised_pnl !== 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<BarChart2 className="h-6 w-6 text-primary" />
|
||||
Positions
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString("en-IN", { timeZone: "Asia/Kolkata", hour12: false })} IST` : "Live positions from Angel One"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing || loading}>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-rose-500/30 bg-rose-500/10">
|
||||
<CardContent className="pt-4 text-sm text-rose-400">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Summary stat cards */}
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Total P&L"
|
||||
value={summary ? fmt(summary.totalPnl) : "—"}
|
||||
icon={summary && summary.totalPnl >= 0 ? TrendingUp : TrendingDown}
|
||||
className={summary && summary.totalPnl < 0 ? "border-rose-500/30" : "border-emerald-500/30"}
|
||||
/>
|
||||
<StatCard
|
||||
title="Unrealised"
|
||||
value={summary ? fmt(summary.totalUnrealised) : "—"}
|
||||
icon={Activity}
|
||||
description="Open positions"
|
||||
/>
|
||||
<StatCard
|
||||
title="Realised"
|
||||
value={summary ? fmt(summary.totalRealised) : "—"}
|
||||
icon={DollarSign}
|
||||
description="Closed today"
|
||||
/>
|
||||
<StatCard
|
||||
title="Open Positions"
|
||||
value={loading ? "…" : open.length}
|
||||
icon={Layers}
|
||||
description={closed.length > 0 ? `${closed.length} closed today` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Open positions table */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Open Positions ({open.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground text-sm">Loading…</div>
|
||||
) : open.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground text-sm">No open positions</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground text-xs">
|
||||
<th className="px-4 py-2 text-left font-medium">Symbol</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Qty</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Avg</th>
|
||||
<th className="px-4 py-2 text-right font-medium">LTP</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Unrealised</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Total P&L</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{open.map(p => (
|
||||
<tr key={p.key} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-2.5 font-mono font-medium">{p.tradingsymbol}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||
<span className={p.netqty > 0 ? "text-emerald-500" : "text-rose-500"}>
|
||||
{p.netqty > 0 ? "+" : ""}{p.netqty}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono tabular-nums">₹{p.avg_price.toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono tabular-nums">₹{p.ltp.toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right"><PnlCell value={p.unrealised_pnl} /></td>
|
||||
<td className="px-4 py-2.5 text-right"><PnlCell value={p.total_pnl} /></td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
{p.instrumenttype || p.producttype}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Closed today */}
|
||||
{!loading && closed.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base text-muted-foreground">Closed Today ({closed.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground text-xs">
|
||||
<th className="px-4 py-2 text-left font-medium">Symbol</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Realised P&L</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{closed.map(p => (
|
||||
<tr key={p.key} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-2.5 font-mono font-medium text-muted-foreground">{p.tradingsymbol}</td>
|
||||
<td className="px-4 py-2.5 text-right"><PnlCell value={p.realised_pnl} /></td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant="outline" className="text-xs font-normal opacity-60">
|
||||
{p.instrumenttype || p.producttype}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
* Agents — per-sub-agent detail and (Phase 3) per-agent model overrides
|
||||
* Knowledge — Tiger's brain: memory, skills, activity, scheduled jobs
|
||||
* Workspace — file tree of /sandbox + diffs
|
||||
* Positions — live P&L + open positions
|
||||
* Cost — finance-grade cost dashboard
|
||||
* Logs — raw streaming logs (dev drill-down)
|
||||
* Settings
|
||||
|
|
@ -27,6 +28,7 @@ import {
|
|||
Bot,
|
||||
Brain,
|
||||
FolderOpen,
|
||||
BarChart2,
|
||||
DollarSign,
|
||||
ScrollText,
|
||||
Settings2,
|
||||
|
|
@ -47,15 +49,16 @@ import {
|
|||
// Primary navigation — the main verbs of using Tiger.
|
||||
// Ordered by frequency-of-use so the most-tapped items sit at the top.
|
||||
const navMain = [
|
||||
{ title: "Home", url: "/", icon: Home },
|
||||
{ title: "Chat", url: "/chat", icon: MessageSquare },
|
||||
{ title: "Projects", url: "/projects", icon: Briefcase },
|
||||
{ title: "Agents", url: "/agents", icon: Bot },
|
||||
{ title: "Knowledge", url: "/knowledge", icon: Brain },
|
||||
{ title: "Workspace", url: "/workspace", icon: FolderOpen },
|
||||
{ title: "Activity", url: "/activity", icon: ScrollText },
|
||||
{ title: "Cost", url: "/cost", icon: DollarSign },
|
||||
{ title: "Logs", url: "/logs", icon: ScrollText },
|
||||
{ title: "Home", url: "/", icon: Home },
|
||||
{ title: "Chat", url: "/chat", icon: MessageSquare },
|
||||
{ title: "Projects", url: "/projects", icon: Briefcase },
|
||||
{ title: "Agents", url: "/agents", icon: Bot },
|
||||
{ title: "Knowledge", url: "/knowledge", icon: Brain },
|
||||
{ title: "Workspace", url: "/workspace", icon: FolderOpen },
|
||||
{ title: "Positions", url: "/positions", icon: BarChart2 },
|
||||
{ title: "Activity", url: "/activity", icon: ScrollText },
|
||||
{ title: "Cost", url: "/cost", icon: DollarSign },
|
||||
{ title: "Logs", url: "/logs", icon: ScrollText },
|
||||
]
|
||||
|
||||
// Secondary navigation — sits in the footer, less-frequent admin stuff.
|
||||
|
|
|
|||
|
|
@ -1,120 +1,218 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Send } from "lucide-react"
|
||||
/**
|
||||
* TelegramThreadCard — live mirror of the Telegram conversation with Tiger.
|
||||
*
|
||||
* Data source: /api/chat/telegram-thread → bridge /tiger/chat/telegram, which
|
||||
* reads OpenClaw's native session transcript (the same file Tiger's context
|
||||
* comes from). Both directions, full history, in sync by construction.
|
||||
*
|
||||
* Behaviour:
|
||||
* - loads the newest page, scrolled to the bottom (like Telegram itself)
|
||||
* - "Load older" at the top pages backwards through the entire history
|
||||
* - polls for new messages every 15s; only repaints when something changed
|
||||
* - preserves scroll position when older messages are prepended
|
||||
*/
|
||||
|
||||
interface TelegramMessage {
|
||||
role: string
|
||||
content: string
|
||||
timestamp: number
|
||||
meta?: Record<string, unknown>
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Send, ChevronUp, RefreshCw } from "lucide-react"
|
||||
|
||||
interface ThreadMessage {
|
||||
seq: number
|
||||
role: "user" | "agent"
|
||||
text: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface ThreadResponse {
|
||||
ok: boolean
|
||||
messages?: ThreadMessage[]
|
||||
hasMore?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 40
|
||||
const POLL_MS = 15_000
|
||||
|
||||
export function TelegramThreadCard() {
|
||||
const [messages, setMessages] = useState<TelegramMessage[]>([])
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([])
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
// Tracks whether the user is parked at the bottom — only then do we
|
||||
// auto-scroll on new messages, so reading history is never interrupted.
|
||||
const stickToBottom = useRef(true)
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (before?: number): Promise<ThreadResponse> => {
|
||||
const qs = new URLSearchParams({ limit: String(PAGE_SIZE) })
|
||||
if (before) qs.set("before", String(before))
|
||||
const r = await fetch(`/api/chat/telegram-thread?${qs.toString()}`)
|
||||
return r.json()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
fetch("/api/chat/history?limit=5")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data?.messages) {
|
||||
setMessages(data.messages.slice(-5).reverse())
|
||||
fetchPage()
|
||||
.then((data) => {
|
||||
if (data.ok && data.messages) {
|
||||
setMessages(data.messages)
|
||||
setHasMore(Boolean(data.hasMore))
|
||||
} else {
|
||||
setError(data.error || "No data")
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(e => {
|
||||
console.error("Failed to load:", e)
|
||||
setError(e.message)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [fetchPage])
|
||||
|
||||
const hasData = messages.length > 0
|
||||
// Poll for new messages
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => {
|
||||
fetchPage()
|
||||
.then((data) => {
|
||||
if (!data.ok || !data.messages) return
|
||||
setMessages((prev) => {
|
||||
const newest = data.messages!
|
||||
if (
|
||||
prev.length > 0 &&
|
||||
newest.length > 0 &&
|
||||
prev[prev.length - 1].seq === newest[newest.length - 1].seq
|
||||
) {
|
||||
return prev // nothing new — keep referential equality, no repaint
|
||||
}
|
||||
// Merge: keep any older pages we already loaded, append the fresh tail.
|
||||
const known = new Set(prev.map((m) => m.seq))
|
||||
const fresh = newest.filter((m) => !known.has(m.seq))
|
||||
return fresh.length > 0 ? [...prev, ...fresh] : prev
|
||||
})
|
||||
})
|
||||
.catch(() => { /* transient poll errors are fine — next tick retries */ })
|
||||
}, POLL_MS)
|
||||
return () => clearInterval(t)
|
||||
}, [fetchPage])
|
||||
|
||||
// Simple timestamp formatter
|
||||
const formatTime = (ts: number) => {
|
||||
if (!ts) return ""
|
||||
const diff = Date.now() - ts
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return "just now"
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return new Date(ts).toLocaleDateString()
|
||||
// Auto-scroll to bottom on new tail messages (only if user was at bottom)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el && stickToBottom.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
stickToBottom.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < 40
|
||||
}
|
||||
|
||||
// Simple truncate
|
||||
const truncate = (text: string, max = 40) => {
|
||||
if (!text) return ""
|
||||
return text.length > max ? text.slice(0, max) + "..." : text
|
||||
const loadOlder = async () => {
|
||||
if (loadingOlder || messages.length === 0) return
|
||||
setLoadingOlder(true)
|
||||
const el = scrollRef.current
|
||||
const prevHeight = el?.scrollHeight ?? 0
|
||||
try {
|
||||
const data = await fetchPage(messages[0].seq)
|
||||
if (data.ok && data.messages && data.messages.length > 0) {
|
||||
setMessages((prev) => [...data.messages!, ...prev])
|
||||
setHasMore(Boolean(data.hasMore))
|
||||
// Keep the viewport anchored on the message the user was reading.
|
||||
requestAnimationFrame(() => {
|
||||
if (el) el.scrollTop = el.scrollHeight - prevHeight
|
||||
})
|
||||
} else {
|
||||
setHasMore(false)
|
||||
}
|
||||
} finally {
|
||||
setLoadingOlder(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-card/40 p-4 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Send className="h-4 w-4 text-primary" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Telegram thread</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="text-sm text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="bg-card/40 p-4 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Send className="h-4 w-4 text-primary" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Telegram thread</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="text-sm text-red-500">Error: {error}</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
const formatTime = (iso: string) => {
|
||||
if (!iso) return ""
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const sameDay = d.toDateString() === now.toDateString()
|
||||
return sameDay
|
||||
? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
: d.toLocaleDateString([], { day: "numeric", month: "short" }) +
|
||||
" " +
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card/40 p-4 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="h-4 w-4 text-primary" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Chat history</span>
|
||||
</div>
|
||||
<a href="/chat?session=telegram" className="text-xs text-primary hover:underline">Open chat →</a>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Send className="h-4 w-4 text-primary" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">
|
||||
Telegram thread
|
||||
</span>
|
||||
{!loading && !error && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground/60 flex items-center gap-1">
|
||||
<RefreshCw className="h-3 w-3" /> live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasData ? (
|
||||
<ul className="space-y-2 flex-1">
|
||||
{messages.map((msg, i) => (
|
||||
<li key={i} className="text-sm">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-xs">
|
||||
{msg.role === "user" ? "You" : "Tiger"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatTime(msg.timestamp)}
|
||||
</span>
|
||||
{loading && (
|
||||
<div className="flex-1 flex items-center justify-center h-80">
|
||||
<span className="text-sm text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex-1 flex items-center justify-center h-80">
|
||||
<span className="text-sm text-red-500">Error: {error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className="h-80 overflow-y-auto pr-1 flex flex-col gap-2"
|
||||
>
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={loadOlder}
|
||||
disabled={loadingOlder}
|
||||
className="self-center text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1 py-1 px-2 rounded hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
{loadingOlder ? "Loading..." : "Load older"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{messages.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No messages yet
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.seq}
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
|
||||
m.role === "user"
|
||||
? "self-end bg-primary/15 text-foreground"
|
||||
: "self-start bg-muted/50 text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div>{m.text}</div>
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/70 text-right">
|
||||
{m.role === "user" ? "you" : "tiger"} · {formatTime(m.timestamp)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/80 truncate">
|
||||
{truncate(msg.content)}
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center py-6 px-2">
|
||||
<Send className="h-8 w-8 text-muted-foreground/30 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No messages yet.</p>
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-1 max-w-[260px]">
|
||||
Start a conversation to see messages here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue