Merge pull request #110 from DenchHQ/kumareth/channels
feat(channels): show gateway channel sessions in chat sidebar
This commit is contained in:
commit
df63d6d9b7
@ -31,7 +31,7 @@
|
||||
**Node 22+ required.**
|
||||
|
||||
```bash
|
||||
npx denchclaw
|
||||
npx denchclaw@latest
|
||||
```
|
||||
|
||||
Opens at `localhost:3100` after completing onboarding wizard.
|
||||
@ -41,8 +41,8 @@ Opens at `localhost:3100` after completing onboarding wizard.
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx denchclaw # runs onboarding again for openclaw --profile dench
|
||||
npx denchclaw update # updates denchclaw with current settings as is
|
||||
npx denchclaw@latest # runs onboarding again for openclaw --profile dench
|
||||
npx denchclaw@latest update # updates denchclaw web-runtime with current settings as is
|
||||
npx denchclaw restart # restarts denchclaw web server
|
||||
npx denchclaw start # starts denchclaw web server
|
||||
npx denchclaw stop # stops denchclaw web server
|
||||
|
||||
64
apps/web/app/api/gateway/channels/route.ts
Normal file
64
apps/web/app/api/gateway/channels/route.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir } from "@/lib/workspace";
|
||||
import { callGatewayRpc } from "@/lib/agent-runner";
|
||||
import type { ChannelStatus } from "@/lib/gateway-transcript";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const KNOWN_CHANNELS = [
|
||||
"whatsapp", "telegram", "discord", "googlechat",
|
||||
"slack", "signal", "imessage", "nostr",
|
||||
] as const;
|
||||
|
||||
function readConfiguredChannels(): Record<string, { enabled?: boolean }> {
|
||||
const configPath = join(resolveOpenClawStateDir(), "openclaw.json");
|
||||
if (!existsSync(configPath)) return {};
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
||||
return (config.channels ?? {}) as Record<string, { enabled?: boolean }>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const configuredChannels = readConfiguredChannels();
|
||||
|
||||
let gatewayStatus: Record<string, Record<string, unknown>> = {};
|
||||
try {
|
||||
const res = await callGatewayRpc("channels.status", { probe: false });
|
||||
if (res.ok && res.payload) {
|
||||
const payload = res.payload as Record<string, unknown>;
|
||||
const channelMeta = payload.channelMeta as Record<string, Record<string, unknown>> | undefined;
|
||||
if (channelMeta) {
|
||||
gatewayStatus = channelMeta;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Gateway might be unavailable; fall back to config-only status
|
||||
}
|
||||
|
||||
const channels: ChannelStatus[] = [];
|
||||
|
||||
for (const channelId of KNOWN_CHANNELS) {
|
||||
const channelConfig = configuredChannels[channelId];
|
||||
const gwStatus = gatewayStatus[channelId];
|
||||
|
||||
const configured = !!channelConfig;
|
||||
if (!configured && !gwStatus) continue;
|
||||
|
||||
const enabled = channelConfig?.enabled !== false;
|
||||
|
||||
channels.push({
|
||||
id: channelId,
|
||||
configured,
|
||||
running: enabled && (gwStatus?.running as boolean ?? false),
|
||||
connected: gwStatus?.connected as boolean ?? false,
|
||||
error: gwStatus?.error as string | undefined,
|
||||
lastMessage: gwStatus?.lastMessageAt as number | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({ channels });
|
||||
}
|
||||
88
apps/web/app/api/gateway/chat/route.ts
Normal file
88
apps/web/app/api/gateway/chat/route.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import {
|
||||
startSubscribeRun,
|
||||
getActiveRun,
|
||||
subscribeToRun,
|
||||
reactivateSubscribeRun,
|
||||
type SseEvent,
|
||||
} from "@/lib/active-runs";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { sessionKey, message }: { sessionKey: string; message: string } = await req.json();
|
||||
|
||||
if (!sessionKey || !message?.trim()) {
|
||||
return new Response("sessionKey and message are required", { status: 400 });
|
||||
}
|
||||
|
||||
let run = getActiveRun(sessionKey);
|
||||
if (run?.status === "running") {
|
||||
return new Response("Active run already in progress for this session", { status: 409 });
|
||||
}
|
||||
|
||||
if (run) {
|
||||
reactivateSubscribeRun(sessionKey, message);
|
||||
} else {
|
||||
const sessionLabel = sessionKey.split(":").slice(2).join(":");
|
||||
run = startSubscribeRun({
|
||||
sessionKey,
|
||||
parentSessionId: sessionKey,
|
||||
task: message.slice(0, 200),
|
||||
label: sessionLabel,
|
||||
});
|
||||
reactivateSubscribeRun(sessionKey, message);
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let keepalive: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
keepalive = setInterval(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
} catch { /* ignore */ }
|
||||
}, 15_000);
|
||||
|
||||
unsubscribe = subscribeToRun(
|
||||
sessionKey,
|
||||
(event: SseEvent | null) => {
|
||||
if (closed) return;
|
||||
if (event === null) {
|
||||
closed = true;
|
||||
if (keepalive) { clearInterval(keepalive); keepalive = null; }
|
||||
try { controller.close(); } catch { /* already closed */ }
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const json = JSON.stringify(event);
|
||||
controller.enqueue(encoder.encode(`data: ${json}\n\n`));
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
{ replay: false },
|
||||
);
|
||||
|
||||
if (!unsubscribe) {
|
||||
closed = true;
|
||||
if (keepalive) { clearInterval(keepalive); keepalive = null; }
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
if (keepalive) { clearInterval(keepalive); keepalive = null; }
|
||||
unsubscribe?.();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
87
apps/web/app/api/gateway/chat/stream/route.ts
Normal file
87
apps/web/app/api/gateway/chat/stream/route.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import {
|
||||
getActiveRun,
|
||||
startSubscribeRun,
|
||||
subscribeToRun,
|
||||
type SseEvent,
|
||||
} from "@/lib/active-runs";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const sessionKey = url.searchParams.get("sessionKey");
|
||||
|
||||
if (!sessionKey) {
|
||||
return new Response("sessionKey query parameter required", { status: 400 });
|
||||
}
|
||||
|
||||
let run = getActiveRun(sessionKey);
|
||||
|
||||
if (!run) {
|
||||
const sessionLabel = sessionKey.split(":").slice(2).join(":");
|
||||
run = startSubscribeRun({
|
||||
sessionKey,
|
||||
parentSessionId: sessionKey,
|
||||
task: `Channel session: ${sessionLabel}`,
|
||||
label: sessionLabel,
|
||||
});
|
||||
}
|
||||
|
||||
if (!run) {
|
||||
return Response.json({ active: false }, { status: 404 });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let keepalive: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
keepalive = setInterval(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
} catch { /* ignore */ }
|
||||
}, 15_000);
|
||||
|
||||
unsubscribe = subscribeToRun(
|
||||
sessionKey,
|
||||
(event: SseEvent | null) => {
|
||||
if (closed) return;
|
||||
if (event === null) {
|
||||
closed = true;
|
||||
if (keepalive) { clearInterval(keepalive); keepalive = null; }
|
||||
try { controller.close(); } catch { /* already closed */ }
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const json = JSON.stringify(event);
|
||||
controller.enqueue(encoder.encode(`data: ${json}\n\n`));
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
{ replay: true },
|
||||
);
|
||||
|
||||
if (!unsubscribe) {
|
||||
closed = true;
|
||||
if (keepalive) { clearInterval(keepalive); keepalive = null; }
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
if (keepalive) { clearInterval(keepalive); keepalive = null; }
|
||||
unsubscribe?.();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Run-Active": run.status === "running" || run.status === "waiting-for-subagents" ? "true" : "false",
|
||||
},
|
||||
});
|
||||
}
|
||||
21
apps/web/app/api/gateway/sessions/[id]/route.ts
Normal file
21
apps/web/app/api/gateway/sessions/[id]/route.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { findSessionTranscriptFile, parseTranscriptToMessages } from "@/lib/gateway-transcript";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const transcriptFile = findSessionTranscriptFile(id);
|
||||
if (!transcriptFile) {
|
||||
return Response.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const content = readFileSync(transcriptFile, "utf-8");
|
||||
const messages = parseTranscriptToMessages(content);
|
||||
|
||||
return Response.json({ id, messages });
|
||||
}
|
||||
30
apps/web/app/api/gateway/sessions/route.ts
Normal file
30
apps/web/app/api/gateway/sessions/route.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { resolveActiveAgentId } from "@/lib/workspace";
|
||||
import { readGatewaySessionsForAgent, listAllAgentIds } from "@/lib/gateway-transcript";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const channelFilter = url.searchParams.get("channel");
|
||||
const activeAgentId = resolveActiveAgentId();
|
||||
|
||||
const agentIds = listAllAgentIds();
|
||||
const prioritized = [activeAgentId, ...agentIds.filter((id) => id !== activeAgentId)];
|
||||
|
||||
let sessions = prioritized.flatMap((agentId) => readGatewaySessionsForAgent(agentId));
|
||||
|
||||
const seen = new Set<string>();
|
||||
sessions = sessions.filter((s) => {
|
||||
if (seen.has(s.sessionKey)) return false;
|
||||
seen.add(s.sessionKey);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (channelFilter) {
|
||||
sessions = sessions.filter((s) => s.channel === channelFilter);
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
return Response.json({ sessions });
|
||||
}
|
||||
@ -15,15 +15,19 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
/** GET /api/web-sessions — list web chat sessions.
|
||||
* ?filePath=... → returns only sessions scoped to that file.
|
||||
* ?includeAll=true → returns all sessions (including file-scoped).
|
||||
* No filePath → returns only global (non-file) sessions. */
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const filePath = url.searchParams.get("filePath");
|
||||
const includeAll = url.searchParams.get("includeAll") === "true";
|
||||
|
||||
const all = readIndex();
|
||||
const sessions = filePath
|
||||
? all.filter((s) => s.filePath === filePath)
|
||||
: all.filter((s) => !s.filePath);
|
||||
const sessions = includeAll
|
||||
? all
|
||||
: filePath
|
||||
? all.filter((s) => s.filePath === filePath)
|
||||
: all.filter((s) => !s.filePath);
|
||||
|
||||
return Response.json({ sessions });
|
||||
}
|
||||
|
||||
@ -829,6 +829,12 @@ type ChatPanelProps = {
|
||||
hideHeaderActions?: boolean;
|
||||
/** Called whenever the panel's runtime state changes. */
|
||||
onRuntimeStateChange?: (state: ChatPanelRuntimeState) => void;
|
||||
/** Gateway session key for channel sessions (telegram, discord, etc.). */
|
||||
gatewaySessionKey?: string;
|
||||
/** Gateway session UUID for loading transcripts. */
|
||||
gatewaySessionId?: string;
|
||||
/** Channel identifier for the gateway session (e.g. "telegram"). */
|
||||
gatewayChannel?: string;
|
||||
};
|
||||
|
||||
export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
@ -852,10 +858,14 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
onBack,
|
||||
hideHeaderActions,
|
||||
onRuntimeStateChange,
|
||||
gatewaySessionKey,
|
||||
gatewaySessionId,
|
||||
gatewayChannel: _gatewayChannel,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const isSubagentMode = !!subagentSessionKey;
|
||||
const isGatewayMode = !!gatewaySessionKey;
|
||||
const editorRef = useRef<ChatEditorHandle>(null);
|
||||
const [editorEmpty, setEditorEmpty] = useState(true);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<
|
||||
@ -1095,11 +1105,15 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
reconnectAbortRef.current = abort;
|
||||
|
||||
try {
|
||||
const streamParam = options?.sessionKey
|
||||
? `sessionKey=${encodeURIComponent(options.sessionKey)}`
|
||||
: `sessionId=${encodeURIComponent(sessionId)}`;
|
||||
const sk = options?.sessionKey;
|
||||
const isGwSession = sk && !sk.includes(":subagent:") && !sk.includes(":web:");
|
||||
const streamUrl = isGwSession
|
||||
? `/api/gateway/chat/stream?sessionKey=${encodeURIComponent(sk)}`
|
||||
: sk
|
||||
? `/api/chat/stream?sessionKey=${encodeURIComponent(sk)}`
|
||||
: `/api/chat/stream?sessionId=${encodeURIComponent(sessionId)}`;
|
||||
const res = await fetch(
|
||||
`/api/chat/stream?${streamParam}`,
|
||||
streamUrl,
|
||||
{ signal: abort.signal },
|
||||
);
|
||||
if (!res.ok || !res.body) {
|
||||
@ -1334,7 +1348,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
const initialSessionHandled = useRef(false);
|
||||
const lastInitialSessionRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (filePath || isSubagentMode || !initialSessionId) {
|
||||
if (filePath || isSubagentMode || isGatewayMode || !initialSessionId) {
|
||||
return;
|
||||
}
|
||||
if (initialSessionHandled.current && initialSessionId === lastInitialSessionRef.current) {
|
||||
@ -1425,6 +1439,57 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable setters
|
||||
}, [subagentSessionKey, subagentTask, attemptReconnect]);
|
||||
|
||||
// ── Gateway session mode: load transcript + reconnect to active stream ──
|
||||
useEffect(() => {
|
||||
if (!gatewaySessionKey || !gatewaySessionId) return;
|
||||
let cancelled = false;
|
||||
|
||||
reconnectAbortRef.current?.abort();
|
||||
void stop();
|
||||
savedMessageIdsRef.current.clear();
|
||||
setQueuedMessages([]);
|
||||
setLoadingSession(true);
|
||||
|
||||
void (async () => {
|
||||
let baseMessages: Array<{ id: string; role: "user" | "assistant"; parts: UIMessage["parts"] }> = [];
|
||||
try {
|
||||
const res = await fetch(`/api/gateway/sessions/${encodeURIComponent(gatewaySessionId)}`);
|
||||
if (cancelled) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const sessionMessages: Array<{
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
parts?: Array<Record<string, unknown>>;
|
||||
}> = data.messages || [];
|
||||
|
||||
const uiMessages = sessionMessages.map((msg) => {
|
||||
savedMessageIdsRef.current.add(msg.id);
|
||||
return {
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
parts: (msg.parts ?? [{ type: "text" as const, text: msg.content }]) as UIMessage["parts"],
|
||||
};
|
||||
});
|
||||
baseMessages = uiMessages;
|
||||
if (!cancelled) setMessages(baseMessages);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!cancelled) {
|
||||
setLoadingSession(false);
|
||||
await attemptReconnect(gatewaySessionKey, baseMessages, { sessionKey: gatewaySessionKey });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
reconnectAbortRef.current?.abort();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gatewaySessionKey, gatewaySessionId, attemptReconnect]);
|
||||
|
||||
// ── Poll for subagent spawns during active streaming ──
|
||||
const [hasRunningSubagents, setHasRunningSubagents] = useState(false);
|
||||
|
||||
@ -1614,7 +1679,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
}
|
||||
|
||||
let sessionId = currentSessionId;
|
||||
if (!sessionId && !isSubagentMode) {
|
||||
if (!sessionId && !isSubagentMode && !isGatewayMode) {
|
||||
const titleSource =
|
||||
userText || "File attachment";
|
||||
const title =
|
||||
@ -1662,7 +1727,28 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
pendingHtmlRef.current = html;
|
||||
|
||||
userScrolledAwayRef.current = false;
|
||||
void sendMessage({ text: messageText });
|
||||
|
||||
if (gatewaySessionKey) {
|
||||
const userMsg = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user" as const,
|
||||
parts: [{ type: "text" as const, text: messageText }] as UIMessage["parts"],
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/gateway/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionKey: gatewaySessionKey, message: messageText }),
|
||||
});
|
||||
if (res.ok && res.body) {
|
||||
await attemptReconnect(gatewaySessionKey, [], { sessionKey: gatewaySessionKey });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} else {
|
||||
void sendMessage({ text: messageText });
|
||||
}
|
||||
},
|
||||
[
|
||||
attachedFiles,
|
||||
@ -1674,6 +1760,8 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
filePath,
|
||||
fileContext,
|
||||
sendMessage,
|
||||
gatewaySessionKey,
|
||||
attemptReconnect,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,7 @@ import { FileManagerTree, type TreeNode } from "./file-manager-tree";
|
||||
import { ProfileSwitcher } from "./profile-switcher";
|
||||
import { CreateWorkspaceDialog } from "./create-workspace-dialog";
|
||||
import { UnicodeSpinner } from "../unicode-spinner";
|
||||
import { ChatSessionsSidebar, type WebSession, type SidebarSubagentInfo } from "./chat-sessions-sidebar";
|
||||
import { ChatSessionsSidebar, type WebSession, type SidebarSubagentInfo, type SidebarGatewaySession, type SidebarChannelStatus } from "./chat-sessions-sidebar";
|
||||
|
||||
/** Shape returned by /api/workspace/suggest-files */
|
||||
type SuggestItem = {
|
||||
@ -67,6 +67,12 @@ type WorkspaceSidebarProps = {
|
||||
onSelectChatSubagent?: (sessionKey: string) => void;
|
||||
onDeleteChatSession?: (sessionId: string) => void;
|
||||
onRenameChatSession?: (sessionId: string, newTitle: string) => void;
|
||||
chatGatewaySessions?: SidebarGatewaySession[];
|
||||
chatChannelStatuses?: SidebarChannelStatus[];
|
||||
chatActiveGatewaySessionKey?: string | null;
|
||||
onSelectGatewayChatSession?: (sessionKey: string, sessionId: string) => void;
|
||||
chatFileScopedSessions?: WebSession[];
|
||||
chatHeartbeatInfo?: { intervalMs: number; nextDueEstimateMs: number | null } | null;
|
||||
/** Which tab is active. Controlled from parent if provided. */
|
||||
activeTab?: "files" | "chats";
|
||||
onTabChange?: (tab: "files" | "chats") => void;
|
||||
|
||||
@ -43,7 +43,7 @@ import {
|
||||
autoDetectViewField,
|
||||
} from "@/lib/object-filters";
|
||||
import { UnicodeSpinner } from "../components/unicode-spinner";
|
||||
import { ChatSessionsSidebar } from "../components/workspace/chat-sessions-sidebar";
|
||||
import { ChatSessionsSidebar, type SidebarGatewaySession, type SidebarChannelStatus } from "../components/workspace/chat-sessions-sidebar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -68,6 +68,7 @@ import {
|
||||
isChatTab,
|
||||
openOrFocusParentChatTab,
|
||||
openOrFocusSubagentChatTab,
|
||||
openOrFocusGatewayChatTab,
|
||||
resolveChatIdentityForTab,
|
||||
syncParentChatTabTitles,
|
||||
syncSubagentChatTabTitles,
|
||||
@ -218,6 +219,7 @@ type WebSession = {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
filePath?: string;
|
||||
};
|
||||
|
||||
const LEFT_SIDEBAR_MIN = 200;
|
||||
@ -455,8 +457,14 @@ function WorkspacePageInner() {
|
||||
const [subagents, setSubagents] = useState<SubagentSpawnInfo[]>([]);
|
||||
const [activeSubagentKey, setActiveSubagentKey] = useState<string | null>(null);
|
||||
|
||||
// Gateway channel sessions
|
||||
const [gatewaySessions, setGatewaySessions] = useState<SidebarGatewaySession[]>([]);
|
||||
const [channelStatuses, setChannelStatuses] = useState<SidebarChannelStatus[]>([]);
|
||||
const [activeGatewaySessionKey, setActiveGatewaySessionKey] = useState<string | null>(null);
|
||||
|
||||
// Cron jobs state
|
||||
const [cronJobs, setCronJobs] = useState<CronJob[]>([]);
|
||||
const [heartbeatInfo, setHeartbeatInfo] = useState<{ intervalMs: number; nextDueEstimateMs: number | null } | null>(null);
|
||||
|
||||
// Cron URL-backed view state
|
||||
const [cronView, setCronView] = useState<import("@/lib/workspace-links").CronDashboardView>("overview");
|
||||
@ -538,7 +546,7 @@ function WorkspacePageInner() {
|
||||
[tabState],
|
||||
);
|
||||
const mainChatTabs = useMemo(
|
||||
() => tabState.tabs.filter((tab) => tab.id !== HOME_TAB_ID && isChatTab(tab)),
|
||||
() => tabState.tabs.filter((tab) => tab.id !== HOME_TAB_ID && (tab.type === "chat" || tab.type === "gateway-chat")),
|
||||
[tabState.tabs],
|
||||
);
|
||||
|
||||
@ -572,10 +580,28 @@ function WorkspacePageInner() {
|
||||
setTabState((prev) => openOrFocusSubagentChatTab(prev, params));
|
||||
}, []);
|
||||
|
||||
const openGatewayChatTab = useCallback((sessionKey: string, sessionId: string, channel?: string, title?: string) => {
|
||||
setActivePath(null);
|
||||
setContent({ kind: "none" });
|
||||
setActiveSessionId(null);
|
||||
setActiveSubagentKey(null);
|
||||
setActiveGatewaySessionKey(sessionKey);
|
||||
setTabState((prev) => openOrFocusGatewayChatTab(prev, {
|
||||
sessionKey,
|
||||
sessionId,
|
||||
channel: channel ?? "unknown",
|
||||
title: title ?? "Channel Chat",
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const visibleMainChatTabId = useMemo(() => {
|
||||
if (isChatTab(activeTab)) {
|
||||
if (activeTab.type === "chat" || activeTab.type === "gateway-chat") {
|
||||
return activeTab.id;
|
||||
}
|
||||
if (activeGatewaySessionKey) {
|
||||
const matchingGwTab = mainChatTabs.find((tab) => tab.type === "gateway-chat" && tab.sessionKey === activeGatewaySessionKey);
|
||||
if (matchingGwTab) return matchingGwTab.id;
|
||||
}
|
||||
if (activeSubagentKey) {
|
||||
const matchingSubagentTab = mainChatTabs.find((tab) => tab.sessionKey === activeSubagentKey);
|
||||
if (matchingSubagentTab) {
|
||||
@ -593,15 +619,16 @@ function WorkspacePageInner() {
|
||||
if (blankTab) return blankTab.id;
|
||||
}
|
||||
return mainChatTabs[0]?.id ?? null;
|
||||
}, [activeTab, activeSessionId, activeSubagentKey, mainChatTabs, tabState.activeTabId]);
|
||||
}, [activeTab, activeSessionId, activeSubagentKey, activeGatewaySessionKey, mainChatTabs, tabState.activeTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isChatTab(activeTab)) {
|
||||
if (activeTab.type !== "chat" && activeTab.type !== "gateway-chat") {
|
||||
return;
|
||||
}
|
||||
const identity = resolveChatIdentityForTab(activeTab);
|
||||
setActiveSessionId((prev) => prev === identity.sessionId ? prev : identity.sessionId);
|
||||
setActiveSubagentKey((prev) => prev === identity.subagentKey ? prev : identity.subagentKey);
|
||||
setActiveGatewaySessionKey((prev) => prev === identity.gatewaySessionKey ? prev : identity.gatewaySessionKey);
|
||||
}, [activeTab]);
|
||||
|
||||
const setMainChatPanelRef = useCallback((tabId: string, handle: ChatPanelHandle | null) => {
|
||||
@ -806,12 +833,16 @@ function WorkspacePageInner() {
|
||||
}, [refreshContext]);
|
||||
|
||||
// Fetch chat sessions
|
||||
const [fileScopedSessions, setFileScopedSessions] = useState<WebSession[]>([]);
|
||||
|
||||
const fetchSessions = useCallback(async () => {
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/web-sessions");
|
||||
const res = await fetch("/api/web-sessions?includeAll=true");
|
||||
const data = await res.json();
|
||||
setSessions(data.sessions ?? []);
|
||||
const all: Array<WebSession & { filePath?: string }> = data.sessions ?? [];
|
||||
setSessions(all.filter((s) => !s.filePath));
|
||||
setFileScopedSessions(all.filter((s) => !!s.filePath));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
@ -827,6 +858,41 @@ function WorkspacePageInner() {
|
||||
setSidebarRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
// Fetch gateway channel sessions
|
||||
const fetchGatewaySessions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/gateway/sessions");
|
||||
const data = await res.json();
|
||||
const sessions: SidebarGatewaySession[] = (data.sessions ?? []).map(
|
||||
(s: { sessionKey: string; sessionId: string; channel: string; origin?: { label?: string; provider?: string }; updatedAt: number }) => ({
|
||||
sessionKey: s.sessionKey,
|
||||
sessionId: s.sessionId,
|
||||
channel: s.channel,
|
||||
title: s.origin?.label || `${s.channel.charAt(0).toUpperCase() + s.channel.slice(1)} Session`,
|
||||
updatedAt: s.updatedAt,
|
||||
origin: s.origin,
|
||||
}),
|
||||
);
|
||||
setGatewaySessions(sessions);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const fetchChannelStatuses = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/gateway/channels");
|
||||
const data = await res.json();
|
||||
setChannelStatuses(data.channels ?? []);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchGatewaySessions();
|
||||
void fetchChannelStatuses();
|
||||
const gwInterval = setInterval(fetchGatewaySessions, 10_000);
|
||||
const chInterval = setInterval(fetchChannelStatuses, 30_000);
|
||||
return () => { clearInterval(gwInterval); clearInterval(chInterval); };
|
||||
}, [fetchGatewaySessions, fetchChannelStatuses]);
|
||||
|
||||
const handleWorkspaceChanged = useCallback(() => {
|
||||
resetWorkspaceStateOnSwitch({
|
||||
setBrowseDir,
|
||||
@ -946,6 +1012,7 @@ function WorkspacePageInner() {
|
||||
const res = await fetch("/api/cron/jobs");
|
||||
const data: CronJobsResponse = await res.json();
|
||||
setCronJobs(data.jobs ?? []);
|
||||
if (data.heartbeat) setHeartbeatInfo(data.heartbeat);
|
||||
} catch {
|
||||
// ignore - cron might not be configured
|
||||
}
|
||||
@ -2091,7 +2158,7 @@ function WorkspacePageInner() {
|
||||
}, [stopParentSession, stopSubagentSession, tabState.tabs]);
|
||||
|
||||
// Whether to show the main chat workspace instead of file/object content.
|
||||
const showMainChat = activeTab.type === "chat" || activeTab.id === HOME_TAB_ID || (!activePath || content.kind === "none");
|
||||
const showMainChat = activeTab.type === "chat" || activeTab.type === "gateway-chat" || activeTab.id === HOME_TAB_ID || (!activePath || content.kind === "none");
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
@ -2142,6 +2209,16 @@ function WorkspacePageInner() {
|
||||
onSelectChatSubagent={handleSelectSubagent}
|
||||
onDeleteChatSession={handleDeleteSession}
|
||||
onRenameChatSession={handleRenameSession}
|
||||
chatGatewaySessions={gatewaySessions}
|
||||
chatChannelStatuses={channelStatuses}
|
||||
chatActiveGatewaySessionKey={activeGatewaySessionKey}
|
||||
onSelectGatewayChatSession={(sessionKey, sessionId) => {
|
||||
const gs = gatewaySessions.find((s) => s.sessionKey === sessionKey);
|
||||
openGatewayChatTab(sessionKey, sessionId, gs?.channel, gs?.title);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
chatFileScopedSessions={fileScopedSessions}
|
||||
chatHeartbeatInfo={heartbeatInfo}
|
||||
activeTab={sidebarTab}
|
||||
onTabChange={setSidebarTab}
|
||||
mobile
|
||||
@ -2203,6 +2280,15 @@ function WorkspacePageInner() {
|
||||
onSelectChatSubagent={handleSelectSubagent}
|
||||
onDeleteChatSession={handleDeleteSession}
|
||||
onRenameChatSession={handleRenameSession}
|
||||
chatGatewaySessions={gatewaySessions}
|
||||
chatChannelStatuses={channelStatuses}
|
||||
chatActiveGatewaySessionKey={activeGatewaySessionKey}
|
||||
onSelectGatewayChatSession={(sessionKey, sessionId) => {
|
||||
const gs = gatewaySessions.find((s) => s.sessionKey === sessionKey);
|
||||
openGatewayChatTab(sessionKey, sessionId, gs?.channel, gs?.title);
|
||||
}}
|
||||
chatFileScopedSessions={fileScopedSessions}
|
||||
chatHeartbeatInfo={heartbeatInfo}
|
||||
activeTab={sidebarTab}
|
||||
onTabChange={setSidebarTab}
|
||||
/>
|
||||
@ -2478,7 +2564,8 @@ function WorkspacePageInner() {
|
||||
style={{ background: "var(--color-main-bg)" }}
|
||||
>
|
||||
{mainChatTabs.map((tab) => {
|
||||
const subagent = tab.sessionKey
|
||||
const isGateway = tab.type === "gateway-chat";
|
||||
const subagent = !isGateway && tab.sessionKey
|
||||
? subagents.find((entry) => entry.childSessionKey === tab.sessionKey)
|
||||
: null;
|
||||
const isVisible = tab.id === visibleMainChatTabId;
|
||||
@ -2490,20 +2577,23 @@ function WorkspacePageInner() {
|
||||
<ChatPanel
|
||||
ref={(handle) => setMainChatPanelRef(tab.id, handle)}
|
||||
sessionTitle={tab.title}
|
||||
initialSessionId={tab.sessionKey ? undefined : tab.sessionId ?? undefined}
|
||||
onActiveSessionChange={tab.sessionKey ? undefined : (id) => handleChatTabSessionChange(tab.id, id)}
|
||||
onSessionsChange={refreshSessions}
|
||||
initialSessionId={isGateway ? undefined : (tab.sessionKey ? undefined : tab.sessionId ?? undefined)}
|
||||
onActiveSessionChange={isGateway || tab.sessionKey ? undefined : (id) => handleChatTabSessionChange(tab.id, id)}
|
||||
onSessionsChange={isGateway ? undefined : refreshSessions}
|
||||
onSubagentClick={handleSubagentClickFromChat}
|
||||
onFilePathClick={handleFilePathClickFromChat}
|
||||
onDeleteSession={tab.sessionKey ? undefined : handleDeleteSession}
|
||||
onRenameSession={tab.sessionKey ? undefined : handleRenameSession}
|
||||
onDeleteSession={isGateway || tab.sessionKey ? undefined : handleDeleteSession}
|
||||
onRenameSession={isGateway || tab.sessionKey ? undefined : handleRenameSession}
|
||||
compact={isMobile}
|
||||
sessionKey={tab.sessionKey ?? undefined}
|
||||
sessionKey={isGateway ? undefined : (tab.sessionKey ?? undefined)}
|
||||
subagentTask={subagent?.task}
|
||||
subagentLabel={subagent?.label}
|
||||
onBack={tab.sessionKey ? handleBackFromSubagent : undefined}
|
||||
onBack={tab.sessionKey && !isGateway ? handleBackFromSubagent : undefined}
|
||||
hideHeaderActions={!isMobile}
|
||||
onRuntimeStateChange={(runtime) => handleChatRuntimeStateChange(tab.id, runtime)}
|
||||
gatewaySessionKey={isGateway ? tab.sessionKey : undefined}
|
||||
gatewaySessionId={isGateway ? tab.sessionId : undefined}
|
||||
gatewayChannel={isGateway ? tab.channel : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -2584,6 +2674,15 @@ function WorkspacePageInner() {
|
||||
onRenameSession={handleRenameSession}
|
||||
onStopSession={(sessionId) => { void stopParentSession(sessionId); }}
|
||||
onStopSubagent={(sessionKey) => { void stopSubagentSession(sessionKey); }}
|
||||
gatewaySessions={gatewaySessions}
|
||||
channelStatuses={channelStatuses}
|
||||
activeGatewaySessionKey={activeGatewaySessionKey}
|
||||
onSelectGatewaySession={(sessionKey, sessionId) => {
|
||||
const gs = gatewaySessions.find((s) => s.sessionKey === sessionKey);
|
||||
openGatewayChatTab(sessionKey, sessionId, gs?.channel, gs?.title);
|
||||
}}
|
||||
fileScopedSessions={fileScopedSessions}
|
||||
heartbeatInfo={heartbeatInfo}
|
||||
embedded
|
||||
/>
|
||||
</div>
|
||||
@ -2649,6 +2748,16 @@ function WorkspacePageInner() {
|
||||
onRenameSession={handleRenameSession}
|
||||
onStopSession={(sessionId) => { void stopParentSession(sessionId); }}
|
||||
onStopSubagent={(sessionKey) => { void stopSubagentSession(sessionKey); }}
|
||||
gatewaySessions={gatewaySessions}
|
||||
channelStatuses={channelStatuses}
|
||||
activeGatewaySessionKey={activeGatewaySessionKey}
|
||||
onSelectGatewaySession={(sessionKey, sessionId) => {
|
||||
const gs = gatewaySessions.find((s) => s.sessionKey === sessionKey);
|
||||
openGatewayChatTab(sessionKey, sessionId, gs?.channel, gs?.title);
|
||||
setMobileChatSessionsOpen(false);
|
||||
}}
|
||||
fileScopedSessions={fileScopedSessions}
|
||||
heartbeatInfo={heartbeatInfo}
|
||||
mobile
|
||||
width={280}
|
||||
onClose={() => setMobileChatSessionsOpen(false)}
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
* - New HTTP connections can re-attach to a running stream.
|
||||
*/
|
||||
import { createInterface } from "node:readline";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve, basename } from "node:path";
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
@ -207,15 +207,28 @@ const activeRuns: Map<string, ActiveRun> =
|
||||
|
||||
const fileMutationQueues = new Map<string, Promise<void>>();
|
||||
|
||||
async function pathExistsAsync(path: string): Promise<boolean> {
|
||||
async function pathExistsAsync(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `.jsonl` path for `sessionId` that is guaranteed to live inside
|
||||
* `webChatDir()`. `basename()` strips any directory-traversal segments.
|
||||
*/
|
||||
function safeSessionFilePath(sessionId: string): string {
|
||||
const dir = resolve(webChatDir());
|
||||
const safe = resolve(dir, basename(sessionId) + ".jsonl");
|
||||
if (!safe.startsWith(dir + "/")) {
|
||||
throw new Error("Invalid session id");
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
async function queueFileMutation<T>(
|
||||
filePath: string,
|
||||
mutate: () => Promise<T>,
|
||||
@ -425,7 +438,7 @@ export async function persistSubscribeUserMessage(
|
||||
// Write the user message to the session JSONL (same as persistUserMessage
|
||||
// does for parent sessions) so it survives page reloads.
|
||||
try {
|
||||
const fp = join(webChatDir(), `${sessionKey}.jsonl`);
|
||||
const fp = safeSessionFilePath(sessionKey);
|
||||
await ensureDir();
|
||||
await queueFileMutation(fp, async () => {
|
||||
if (!await pathExistsAsync(fp)) {await writeFile(fp, "");}
|
||||
@ -1248,7 +1261,7 @@ function deferredTranscriptEnrich(sessionKey: string, pinnedAgentId?: string): v
|
||||
if (textToTools.size === 0) {return;}
|
||||
|
||||
// Read and enrich web-chat JSONL
|
||||
const fp = join(webChatDir(), `${sessionKey}.jsonl`);
|
||||
const fp = safeSessionFilePath(sessionKey);
|
||||
if (!existsSync(fp)) {return;}
|
||||
const lines = readFileSync(fp, "utf-8").split("\n").filter((l) => l.trim());
|
||||
const messages = lines.map((l) => { try { return JSON.parse(l) as Record<string, unknown>; } catch { return null; } }).filter(Boolean) as Array<Record<string, unknown>>;
|
||||
@ -1296,7 +1309,7 @@ export async function persistUserMessage(
|
||||
msg: { id: string; content: string; parts?: unknown[]; html?: string },
|
||||
): Promise<void> {
|
||||
await ensureDir();
|
||||
const filePath = join(webChatDir(), `${sessionId}.jsonl`);
|
||||
const filePath = safeSessionFilePath(sessionId);
|
||||
|
||||
const line = JSON.stringify({
|
||||
id: msg.id,
|
||||
@ -2218,7 +2231,7 @@ async function upsertMessage(
|
||||
message: Record<string, unknown>,
|
||||
) {
|
||||
await ensureDir();
|
||||
const fp = join(webChatDir(), `${sessionId}.jsonl`);
|
||||
const fp = safeSessionFilePath(sessionId);
|
||||
const msgId = message.id as string;
|
||||
let found = false;
|
||||
await queueFileMutation(fp, async () => {
|
||||
|
||||
@ -158,21 +158,61 @@ export function closeChatTabsForSession(
|
||||
};
|
||||
}
|
||||
|
||||
export function createGatewayChatTab(params: {
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
channel: string;
|
||||
title?: string;
|
||||
}): Tab {
|
||||
return {
|
||||
id: generateTabId(),
|
||||
type: "gateway-chat",
|
||||
title: params.title || "Channel Chat",
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
channel: params.channel,
|
||||
};
|
||||
}
|
||||
|
||||
export function isGatewayChatTab(tab: Tab | undefined | null): tab is Tab {
|
||||
return tab?.type === "gateway-chat";
|
||||
}
|
||||
|
||||
export function openOrFocusGatewayChatTab(
|
||||
state: TabState,
|
||||
params: { sessionKey: string; sessionId: string; channel: string; title?: string },
|
||||
): TabState {
|
||||
return openTab(state, createGatewayChatTab(params));
|
||||
}
|
||||
|
||||
export function resolveChatIdentityForTab(tab: Tab | undefined | null): {
|
||||
sessionId: string | null;
|
||||
subagentKey: string | null;
|
||||
gatewaySessionKey: string | null;
|
||||
} {
|
||||
if (!tab || tab.type !== "chat") {
|
||||
return { sessionId: null, subagentKey: null };
|
||||
if (!tab) {
|
||||
return { sessionId: null, subagentKey: null, gatewaySessionKey: null };
|
||||
}
|
||||
if (tab.type === "gateway-chat") {
|
||||
return {
|
||||
sessionId: tab.sessionId ?? null,
|
||||
subagentKey: null,
|
||||
gatewaySessionKey: tab.sessionKey ?? null,
|
||||
};
|
||||
}
|
||||
if (tab.type !== "chat") {
|
||||
return { sessionId: null, subagentKey: null, gatewaySessionKey: null };
|
||||
}
|
||||
if (tab.sessionKey) {
|
||||
return {
|
||||
sessionId: tab.parentSessionId ?? null,
|
||||
subagentKey: tab.sessionKey,
|
||||
gatewaySessionKey: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
sessionId: tab.sessionId ?? null,
|
||||
subagentKey: null,
|
||||
gatewaySessionKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
289
apps/web/lib/gateway-transcript.ts
Normal file
289
apps/web/lib/gateway-transcript.ts
Normal file
@ -0,0 +1,289 @@
|
||||
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenClawStateDir } from "./workspace";
|
||||
|
||||
export type GatewaySessionEntry = {
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
channel: string;
|
||||
origin: {
|
||||
label?: string;
|
||||
provider: string;
|
||||
surface: string;
|
||||
chatType: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
};
|
||||
updatedAt: number;
|
||||
chatType: string;
|
||||
};
|
||||
|
||||
export type ChannelStatus = {
|
||||
id: string;
|
||||
configured: boolean;
|
||||
running: boolean;
|
||||
connected: boolean;
|
||||
error?: string;
|
||||
lastMessage?: number;
|
||||
};
|
||||
|
||||
export type TranscriptMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
parts?: Array<Record<string, unknown>>;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
function deriveChannelFromKey(sessionKey: string, lastChannel?: string): string {
|
||||
if (lastChannel && lastChannel !== "unknown") return lastChannel;
|
||||
const parts = sessionKey.split(":");
|
||||
if (parts.length >= 3) {
|
||||
const segment = parts[2];
|
||||
if (segment === "web" || segment === "main") return "webchat";
|
||||
if (segment === "cron") return "cron";
|
||||
if (segment === "telegram" || segment === "whatsapp" || segment === "discord"
|
||||
|| segment === "slack" || segment === "signal" || segment === "imessage"
|
||||
|| segment === "nostr" || segment === "googlechat") return segment;
|
||||
}
|
||||
return lastChannel || "unknown";
|
||||
}
|
||||
|
||||
export function readGatewaySessionsForAgent(agentId: string): GatewaySessionEntry[] {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const sessionsFile = join(stateDir, "agents", agentId, "sessions", "sessions.json");
|
||||
if (!existsSync(sessionsFile)) return [];
|
||||
|
||||
let data: Record<string, Record<string, unknown>>;
|
||||
try {
|
||||
data = JSON.parse(readFileSync(sessionsFile, "utf-8"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: GatewaySessionEntry[] = [];
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (key.includes(":subagent:")) continue;
|
||||
|
||||
const channel = deriveChannelFromKey(key, val.lastChannel as string | undefined);
|
||||
if (channel === "webchat" || channel === "unknown") continue;
|
||||
|
||||
const origin = (val.origin ?? {}) as Record<string, unknown>;
|
||||
entries.push({
|
||||
sessionKey: key,
|
||||
sessionId: val.sessionId as string,
|
||||
channel,
|
||||
origin: {
|
||||
label: origin.label as string | undefined,
|
||||
provider: (origin.provider ?? channel) as string,
|
||||
surface: (origin.surface ?? channel) as string,
|
||||
chatType: (origin.chatType ?? val.chatType ?? "direct") as string,
|
||||
from: origin.from as string | undefined,
|
||||
to: origin.to as string | undefined,
|
||||
accountId: origin.accountId as string | undefined,
|
||||
},
|
||||
updatedAt: val.updatedAt as number ?? 0,
|
||||
chatType: (val.chatType ?? "direct") as string,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function listAllAgentIds(): string[] {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const agentsDir = join(stateDir, "agents");
|
||||
if (!existsSync(agentsDir)) return [];
|
||||
try {
|
||||
return readdirSync(agentsDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function findSessionTranscriptFile(sessionId: string): string | null {
|
||||
const stateDir = resolveOpenClawStateDir();
|
||||
const agentsDir = join(stateDir, "agents");
|
||||
if (!existsSync(agentsDir)) return null;
|
||||
try {
|
||||
for (const d of readdirSync(agentsDir, { withFileTypes: true })) {
|
||||
if (!d.isDirectory()) continue;
|
||||
const p = join(agentsDir, d.name, "sessions", `${sessionId}.jsonl`);
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseTranscriptToMessages(content: string): TranscriptMessage[] {
|
||||
const lines = content.trim().split("\n").filter((l) => l.trim());
|
||||
const messages: TranscriptMessage[] = [];
|
||||
const pendingToolCalls = new Map<string, { toolName: string; args?: unknown }>();
|
||||
let currentAssistant: TranscriptMessage | null = null;
|
||||
|
||||
const flushAssistant = () => {
|
||||
if (!currentAssistant) return;
|
||||
const textSummary = (currentAssistant.parts ?? [])
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text as string)
|
||||
.join("\n")
|
||||
.slice(0, 200);
|
||||
currentAssistant.content = textSummary;
|
||||
messages.push(currentAssistant);
|
||||
currentAssistant = null;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
let entry: Record<string, unknown>;
|
||||
try { entry = JSON.parse(line); } catch { continue; }
|
||||
if (entry.type !== "message" || !entry.message) continue;
|
||||
|
||||
const msg = entry.message as Record<string, unknown>;
|
||||
const role = msg.role as string;
|
||||
|
||||
if (role === "toolResult") {
|
||||
const toolCallId = (msg.toolCallId as string) ?? "";
|
||||
const rawContent = msg.content;
|
||||
const outputText = typeof rawContent === "string"
|
||||
? rawContent
|
||||
: Array.isArray(rawContent)
|
||||
? (rawContent as Array<{ type: string; text?: string }>)
|
||||
.filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n")
|
||||
: JSON.stringify(rawContent ?? "");
|
||||
let result: unknown;
|
||||
try { result = JSON.parse(outputText); } catch { result = { output: outputText.slice(0, 5000) }; }
|
||||
|
||||
const assistantParts = currentAssistant?.parts;
|
||||
if (assistantParts) {
|
||||
const tc = assistantParts.find(
|
||||
(p) => p.type === "tool-invocation" && p.toolCallId === toolCallId,
|
||||
);
|
||||
if (tc) {
|
||||
delete tc.state;
|
||||
tc.result = result;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role !== "assistant") continue;
|
||||
const tc = messages[i].parts?.find(
|
||||
(p) => p.type === "tool-invocation" && p.toolCallId === toolCallId,
|
||||
);
|
||||
if (tc) {
|
||||
delete tc.state;
|
||||
tc.result = result;
|
||||
}
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === "user") {
|
||||
flushAssistant();
|
||||
}
|
||||
|
||||
if (role !== "user" && role !== "assistant") continue;
|
||||
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content as Array<Record<string, unknown>>) {
|
||||
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
||||
parts.push({ type: "text", text: part.text });
|
||||
} else if (part.type === "thinking" && typeof part.thinking === "string" && part.thinking.trim()) {
|
||||
parts.push({ type: "reasoning", text: part.thinking });
|
||||
} else if (part.type === "toolCall") {
|
||||
const toolName = (part.name ?? part.toolName ?? "unknown") as string;
|
||||
const toolCallId = (part.id ?? part.toolCallId ?? `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`) as string;
|
||||
const args = part.arguments ?? part.input ?? part.args ?? {};
|
||||
pendingToolCalls.set(toolCallId, { toolName, args });
|
||||
parts.push({ type: "tool-invocation", toolCallId, toolName, args });
|
||||
} else if (part.type === "tool_use" || part.type === "tool-call") {
|
||||
const toolName = (part.name ?? part.toolName ?? "unknown") as string;
|
||||
const toolCallId = (part.id ?? part.toolCallId ?? `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`) as string;
|
||||
const args = part.input ?? part.args ?? {};
|
||||
pendingToolCalls.set(toolCallId, { toolName, args });
|
||||
parts.push({ type: "tool-invocation", toolCallId, toolName, args });
|
||||
} else if (part.type === "tool_result" || part.type === "tool-result") {
|
||||
const toolCallId = (part.tool_use_id ?? part.toolCallId ?? "") as string;
|
||||
const pending = pendingToolCalls.get(toolCallId);
|
||||
const raw = part.content ?? part.output;
|
||||
const outputText = typeof raw === "string"
|
||||
? raw
|
||||
: Array.isArray(raw)
|
||||
? (raw as Array<{ type: string; text?: string }>).filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n")
|
||||
: JSON.stringify(raw ?? "");
|
||||
let result: unknown;
|
||||
try { result = JSON.parse(outputText); } catch { result = { output: outputText.slice(0, 5000) }; }
|
||||
|
||||
const existingMsg = messages[messages.length - 1];
|
||||
if (existingMsg) {
|
||||
const tc = existingMsg.parts?.find(
|
||||
(p) => p.type === "tool-invocation" && p.toolCallId === toolCallId,
|
||||
);
|
||||
if (tc) {
|
||||
delete tc.state;
|
||||
tc.result = result;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
parts.push({
|
||||
type: "tool-invocation",
|
||||
toolCallId,
|
||||
toolName: pending?.toolName ?? "tool",
|
||||
args: pending?.args ?? {},
|
||||
result,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (typeof msg.content === "string" && msg.content.trim()) {
|
||||
parts.push({ type: "text", text: msg.content });
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
const timestamp = (entry.timestamp as string) ?? new Date((entry.ts as number) ?? Date.now()).toISOString();
|
||||
if (role === "assistant") {
|
||||
if (!currentAssistant) {
|
||||
currentAssistant = {
|
||||
id: (entry.id as string) ?? `msg-${messages.length}`,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
parts: [],
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
currentAssistant.parts = [...(currentAssistant.parts ?? []), ...parts];
|
||||
currentAssistant.timestamp = timestamp;
|
||||
} else {
|
||||
messages.push({
|
||||
id: (entry.id as string) ?? `msg-${messages.length}`,
|
||||
role: "user",
|
||||
content: parts
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text as string)
|
||||
.join("\n")
|
||||
.slice(0, 200),
|
||||
parts,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
flushAssistant();
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function sessionDisplayTitle(entry: GatewaySessionEntry): string {
|
||||
if (entry.origin.label) return entry.origin.label;
|
||||
if (entry.channel === "cron") {
|
||||
const cronMatch = entry.sessionKey.match(/cron:([^:]+)/);
|
||||
return cronMatch ? `Cron: ${cronMatch[1].slice(0, 8)}` : "Cron Job";
|
||||
}
|
||||
const channelName = entry.channel.charAt(0).toUpperCase() + entry.channel.slice(1);
|
||||
return `${channelName} Session`;
|
||||
}
|
||||
@ -5,7 +5,7 @@
|
||||
* The URL reflects only the active tab's content (backward compatible).
|
||||
*/
|
||||
|
||||
export type TabType = "home" | "file" | "chat" | "app" | "object" | "cron";
|
||||
export type TabType = "home" | "file" | "chat" | "app" | "object" | "cron" | "gateway-chat";
|
||||
|
||||
export const HOME_TAB_ID = "__home__";
|
||||
|
||||
@ -26,6 +26,8 @@ export type Tab = {
|
||||
sessionKey?: string;
|
||||
parentSessionId?: string;
|
||||
pinned?: boolean;
|
||||
/** Channel identifier for gateway-chat tabs (e.g. "telegram", "discord"). */
|
||||
channel?: string;
|
||||
};
|
||||
|
||||
export type TabState = {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "denchclaw",
|
||||
"version": "2.3.14",
|
||||
"version": "2.3.15",
|
||||
"description": "Fully Managed OpenClaw Framework for managing your CRM, Sales Automation and Outreach agents. The only local productivity tool you need.",
|
||||
"keywords": [],
|
||||
"homepage": "https://github.com/DenchHQ/DenchClaw#readme",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user