Files
obelisk/app/renderer/js/data.js
T
tommy0103 b524339d85 feat(app): add Electron desktop UI and evolve memory/retrieval layer
Introduce an Electron app with session browser, memory list, and usage
  views (vanilla JS + Vue scaffolding). On the data layer: add content_type
  and is_meta to messages for transcript control-plane filtering, introduce
  FTS5-backed memory recall with safe tokenization, support memory archival
  via forget() through the renamed --attune runtime, and expose anchors on
  memory records.
2026-06-12 22:20:29 +08:00

381 lines
12 KiB
JavaScript

// Data loading layer -- bridges Electron IPC (window.obelisk.*) to app state.
// All DB access goes through this module.
import { state } from './state.js';
/**
* Load initial data from the DB and populate state.memories, state.sessions,
* and state.projects.
*/
export async function loadInitialData() {
const [rawMemories, rawSessions, stats, projects] = await Promise.all([
window.obelisk.getMemories(),
window.obelisk.getSessions(),
window.obelisk.getStats(),
window.obelisk.getProjects()
]);
// Transform memories: DB records -> render-layer shape
state.memories = (rawMemories || []).map(m => ({
...m,
ts: m.created_at ? new Date(m.created_at).getTime() : 0,
archived: !!m.deleted_at,
archivedAt: m.deleted_at ? new Date(m.deleted_at).getTime() : null,
health: 'ok',
anchors: [],
markdown: null // loaded on demand via loadMemoryMarkdown
}));
// Sessions: keep DB shape, add empty messages array for on-demand loading
state.sessions = (rawSessions || []).map(s => ({
...s,
messages: []
}));
state.projects = projects || [];
state.stats = stats || {};
}
/**
* Load full detail for a session: messages with inline tool_calls (each with
* result), summaries, subagents, and workflow data.
*
* Returns the assembled session object (also updates state.sessions entry).
*/
export async function loadSessionDetail(sessionId) {
const [messages, toolCalls, toolResults, subagents, workflows, summaries] =
await Promise.all([
window.obelisk.getSessionMessages(sessionId),
window.obelisk.getSessionToolCalls(sessionId),
window.obelisk.getSessionToolResults(sessionId),
window.obelisk.getSessionSubagents(sessionId),
window.obelisk.getSessionWorkflows(sessionId),
window.obelisk.getSessionSummaries(sessionId)
]);
// Index tool results by tool_use_id for fast lookup
const resultsByCallId = {};
for (const r of (toolResults || [])) {
resultsByCallId[r.tool_use_id] = r;
}
// Index subagents by parent_tool_use_id
const subagentsByCallId = {};
for (const sa of (subagents || [])) {
if (sa.parent_tool_use_id) {
subagentsByCallId[sa.parent_tool_use_id] = sa;
}
}
// Group tool_calls by message_uuid, attaching result and subagent inline
const callsByMessageUuid = {};
for (const tc of (toolCalls || [])) {
const call = {
id: tc.id,
name: tc.name,
input_json: tc.input_json,
result: resultsByCallId[tc.id] || null
};
// Attach subagent data if present
const sa = subagentsByCallId[tc.id];
if (sa) {
call.subagent = {
agent_id: sa.agent_id,
agent_type: sa.agent_type,
description: sa.description
};
}
const msgUuid = tc.message_uuid;
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
callsByMessageUuid[msgUuid].push(call);
}
// Attach workflow data to Workflow tool calls
for (const wf of (workflows || [])) {
for (const calls of Object.values(callsByMessageUuid)) {
for (const call of calls) {
if (call.name === 'Workflow' && !call.workflow) {
const resultText = call.result?.content || '';
if (resultText.includes(wf.run_id) || resultText.includes(wf.workflow_name || '___none___')) {
call.workflow = {
run_id: wf.run_id,
workflow_name: wf.workflow_name,
status: wf.status,
duration_ms: wf.duration_ms,
total_tokens: wf.total_tokens,
agent_count: wf.agent_count,
agents: (wf.agents || []).map(a => ({
agent_id: a.agent_id,
phase: a.phase,
label: a.label,
state: a.state,
tokens: a.tokens,
duration_ms: a.duration_ms,
}))
};
}
}
}
}
}
// Index summaries by session (summaries don't have per-message IDs in our schema)
const sessionSummaries = (summaries || []).map(s => ({
source: s.source,
content: s.content,
timestamp: s.timestamp
}));
// Assemble messages with tool_calls inline
const rawAssembled = (messages || []).map(msg => {
const assembled = {
uuid: msg.uuid,
type: msg.type || msg.role,
timestamp: msg.timestamp,
text: msg.text,
content_type: msg.content_type || null,
is_meta: msg.is_meta || 0
};
const calls = callsByMessageUuid[msg.uuid];
if (calls && calls.length > 0) {
assembled.tool_calls = calls;
}
return assembled;
});
// Merge adjacent assistant messages:
// - tool_result user messages are skipped (results shown inside tool_call panels)
// - consecutive tool_use messages (separated by tool_results) merge into one
// - thinking messages merge into the next non-thinking assistant message
const assembledMessages = [];
for (let i = 0; i < rawAssembled.length; i++) {
const msg = rawAssembled[i];
// Skip tool_result user messages
if (msg.content_type === 'tool_result') continue;
// For thinking messages, collect consecutive thinking blocks and attach to the next assistant
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
const thinkingParts = [msg.text || ''];
let j = i + 1;
// Absorb consecutive thinking messages
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
thinkingParts.push(rawAssembled[j].text || '');
j++;
}
// Find the next non-thinking assistant message to attach to
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
// Will be picked up by the next iteration; store thinking on it
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
i = j - 1;
continue;
}
// No following assistant message — render as standalone collapsed thinking
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
i = j - 1;
continue;
}
// For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results)
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
if (msg._thinking) merged._thinking = msg._thinking;
let j = i + 1;
while (j < rawAssembled.length) {
const next = rawAssembled[j];
if (next.content_type === 'tool_result') { j++; continue; }
if (next.type === 'assistant' && next.content_type === 'tool_use') {
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
if (next.text && !merged.text) merged.text = next.text;
j++;
continue;
}
break;
}
assembledMessages.push(merged);
i = j - 1;
} else {
// text or other assistant/user messages
const out = { ...msg };
if (msg._thinking) out._thinking = msg._thinking;
assembledMessages.push(out);
}
}
// Attach workflow data if present
const workflow = (workflows && workflows.length > 0) ? workflows[0] : null;
// Build assembled session object
const session = state.sessions.find(s => s.id === sessionId);
const assembled = {
...(session || {}),
id: sessionId,
messages: assembledMessages
};
if (workflow) {
assembled.workflow = workflow;
}
// Update in-place in state.sessions
const idx = state.sessions.findIndex(s => s.id === sessionId);
if (idx !== -1) {
state.sessions[idx] = assembled;
}
return assembled;
}
/**
* Load full detail for a subagent conversation.
* Returns assembled messages with tool_calls inline.
*/
export async function loadSubagentDetail(agentId) {
const [messages, toolCalls, toolResults] = await Promise.all([
window.obelisk.getSubagentMessages(agentId),
window.obelisk.getSubagentToolCalls(agentId),
window.obelisk.getSubagentToolResults(agentId),
]);
const resultsByCallId = {};
for (const r of (toolResults || [])) {
resultsByCallId[r.tool_use_id] = r;
}
const callsByMessageUuid = {};
for (const tc of (toolCalls || [])) {
const call = {
id: tc.id,
name: tc.name,
input_json: tc.input_json,
result: resultsByCallId[tc.id] || null
};
const msgUuid = tc.message_uuid;
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
callsByMessageUuid[msgUuid].push(call);
}
const rawAssembled = (messages || []).map(msg => {
const assembled = {
uuid: msg.uuid,
type: msg.type || msg.role,
timestamp: msg.timestamp,
text: msg.text,
content_type: msg.content_type || null,
is_meta: msg.is_meta || 0
};
const calls = callsByMessageUuid[msg.uuid];
if (calls && calls.length > 0) assembled.tool_calls = calls;
return assembled;
});
// Same merging logic as session detail
const assembledMessages = [];
for (let i = 0; i < rawAssembled.length; i++) {
const msg = rawAssembled[i];
if (msg.content_type === 'tool_result') continue;
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
const thinkingParts = [msg.text || ''];
let j = i + 1;
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
thinkingParts.push(rawAssembled[j].text || '');
j++;
}
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
i = j - 1;
continue;
}
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
i = j - 1;
continue;
}
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
if (msg._thinking) merged._thinking = msg._thinking;
let j = i + 1;
while (j < rawAssembled.length) {
const next = rawAssembled[j];
if (next.content_type === 'tool_result') { j++; continue; }
if (next.type === 'assistant' && next.content_type === 'tool_use') {
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
if (next.text && !merged.text) merged.text = next.text;
j++;
continue;
}
break;
}
assembledMessages.push(merged);
i = j - 1;
} else {
const out = { ...msg };
if (msg._thinking) out._thinking = msg._thinking;
assembledMessages.push(out);
}
}
return assembledMessages;
}
const TEXT_LIMIT = 10000;
/**
* Check if a message text was truncated during indexing.
*/
export function isTextTruncated(text) {
return text && text.length >= TEXT_LIMIT;
}
/**
* Fetch the full untruncated text for a message from its source JSONL.
* Returns the full text string or null.
*/
export async function loadFullText(uuid) {
try {
return await window.obelisk.getMessageFullText(uuid);
} catch {
return null;
}
}
/**
* Load the markdown content of a memory file.
* Returns the content string or null on failure.
*/
export async function loadMemoryMarkdown(memoryPath) {
try {
const content = await window.obelisk.readMemoryFile(memoryPath);
return content || null;
} catch {
return null;
}
}
/**
* Archive a memory by id. Updates state after successful IPC call.
*/
export async function archiveMemory(id) {
await window.obelisk.archiveMemory(id);
const mem = state.memories.find(m => m.id === id);
if (mem) {
mem.archived = true;
mem.archivedAt = Date.now();
}
}
/**
* Restore an archived memory by id. Updates state after successful IPC call.
*/
export async function restoreMemory(id) {
await window.obelisk.restoreMemory(id);
const mem = state.memories.find(m => m.id === id);
if (mem) {
mem.archived = false;
mem.archivedAt = null;
}
}