@olares/lares-core 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/brand/identity.d.ts +9 -0
- package/brand/identity.js +82 -0
- package/brand/manifest.js +15 -0
- package/drive/download.js +40 -0
- package/drive/execute.js +143 -0
- package/drive/files-path.js +55 -0
- package/drive/ls.js +126 -0
- package/drive/paths.js +320 -0
- package/drive/tools.js +206 -0
- package/drive/url-download.js +314 -0
- package/files/deliverables.js +32 -0
- package/files/disposition.js +20 -0
- package/files/filename.js +22 -0
- package/files/intake.js +217 -0
- package/files/limits.js +1 -0
- package/files/markdown.js +88 -0
- package/files/mention.js +64 -0
- package/files/preview-cache.js +100 -0
- package/files/preview-groups.js +21 -0
- package/files/preview-workspace.js +342 -0
- package/files/preview.js +305 -0
- package/files/upload-client.js +40 -0
- package/files/upload-http.js +22 -0
- package/files/upload.js +104 -0
- package/i18n/chat-model-switch.js +41 -0
- package/i18n/chat-model.js +40 -0
- package/i18n/files.js +39 -0
- package/i18n/mobile.js +115 -0
- package/i18n/preview.js +57 -0
- package/i18n/search.js +33 -0
- package/i18n/t.js +22 -0
- package/i18n/voice.js +70 -0
- package/icons/mark.js +22 -0
- package/larepass/chat.js +134 -0
- package/larepass/context-provenance.js +65 -0
- package/larepass/host.js +80 -0
- package/larepass/mux.js +194 -0
- package/larepass/questions.js +16 -0
- package/larepass/rpc.js +60 -0
- package/larepass/runtime.js +395 -0
- package/larepass/session-cache.js +104 -0
- package/larepass/settings.js +100 -0
- package/larepass/stage-copy.js +16 -0
- package/larepass/tool-row.js +132 -0
- package/larepass/transcript.js +332 -0
- package/media/ffmpeg-run.js +117 -0
- package/media/router-images.js +63 -0
- package/olares/entrance.js +45 -0
- package/olares/identity.js +190 -0
- package/olares/session-identity.js +48 -0
- package/olares/trusted-host.js +37 -0
- package/package.json +111 -0
- package/router/catalog-sync.js +87 -0
- package/router/catalog.d.ts +15 -0
- package/router/catalog.js +73 -0
- package/router/gateway.d.ts +4 -0
- package/router/gateway.js +21 -0
- package/router/models-client.js +39 -0
- package/router/models.d.ts +16 -0
- package/router/models.js +133 -0
- package/router/search.js +142 -0
- package/router/session-model.js +143 -0
- package/router/shim.js +134 -0
- package/router/stt.js +200 -0
- package/search/client.js +38 -0
- package/search/config.js +98 -0
- package/search/menu.js +22 -0
- package/tools/async.js +34 -0
- package/tools/crypto-uuid.js +26 -0
- package/tools/http.js +123 -0
- package/tools/json-file.js +18 -0
- package/voice/client.js +90 -0
- package/voice/config.js +59 -0
- package/voice/languages.js +29 -0
- package/voice/recorder.js +43 -0
- package/voice/service.js +83 -0
- package/workspace/env.js +13 -0
- package/workspace/home.js +9 -0
- package/workspace/path.js +80 -0
- package/workspace/seed.js +13 -0
- package/workspace/session.js +42 -0
package/larepass/mux.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
export const MUX_PATH = "/api/events.mux";
|
|
2
|
+
|
|
3
|
+
export const MUX_FETCH_HEADERS = {
|
|
4
|
+
accept: "text/event-stream",
|
|
5
|
+
"cache-control": "no-cache",
|
|
6
|
+
"accept-encoding": "identity",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function muxWsUrl(httpUrl, origin = "http://localhost") {
|
|
10
|
+
const url = new URL(httpUrl, origin);
|
|
11
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
12
|
+
return url.href;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function splitSseBlocks(buffer) {
|
|
16
|
+
const blocks = [];
|
|
17
|
+
let rest = String(buffer ?? "").replace(/\r\n/g, "\n");
|
|
18
|
+
let boundary = rest.indexOf("\n\n");
|
|
19
|
+
while (boundary !== -1) {
|
|
20
|
+
blocks.push(rest.slice(0, boundary));
|
|
21
|
+
rest = rest.slice(boundary + 2);
|
|
22
|
+
boundary = rest.indexOf("\n\n");
|
|
23
|
+
}
|
|
24
|
+
return { blocks, rest };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sseData(block) {
|
|
28
|
+
return String(block ?? "")
|
|
29
|
+
.split("\n")
|
|
30
|
+
.filter((line) => line.startsWith("data: "))
|
|
31
|
+
.map((line) => line.slice(6))
|
|
32
|
+
.join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function muxPayload(envelope) {
|
|
36
|
+
if (!envelope || typeof envelope !== "object") return null;
|
|
37
|
+
return envelope.type === "server-request" ? envelope.payload : envelope;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function muxEventFrame(envelope) {
|
|
41
|
+
const frame = muxPayload(envelope);
|
|
42
|
+
if (frame?.type !== "session/event" || !frame.event) return null;
|
|
43
|
+
return {
|
|
44
|
+
sessionId: frame.sessionId || "",
|
|
45
|
+
event: frame.view ? { ...frame.event, view: frame.view } : frame.event,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mux carries session events and answerable waits on the same stream.
|
|
51
|
+
* `question/requested` is replayed on every open; dropping it leaves the turn
|
|
52
|
+
* running with no way to answer.
|
|
53
|
+
*/
|
|
54
|
+
export function classifyMuxEnvelope(envelope) {
|
|
55
|
+
const frame = muxPayload(envelope);
|
|
56
|
+
if (!frame || typeof frame !== "object") return null;
|
|
57
|
+
const sessionId = frame.sessionId || "";
|
|
58
|
+
const rpcId = typeof envelope?.rpcId === "string" ? envelope.rpcId : "";
|
|
59
|
+
if (frame.type === "session/event" && frame.event) {
|
|
60
|
+
return {
|
|
61
|
+
kind: "event",
|
|
62
|
+
sessionId,
|
|
63
|
+
event: frame.view ? { ...frame.event, view: frame.view } : frame.event,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (frame.type === "question/requested") {
|
|
67
|
+
return {
|
|
68
|
+
kind: "question",
|
|
69
|
+
sessionId,
|
|
70
|
+
rpcId,
|
|
71
|
+
questions: Array.isArray(frame.questions) ? frame.questions : [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (frame.type === "question/resolved") {
|
|
75
|
+
return {
|
|
76
|
+
kind: "question-resolved",
|
|
77
|
+
sessionId,
|
|
78
|
+
rpcId: frame.questionRpcId || rpcId,
|
|
79
|
+
outcome: frame.outcome,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function muxSessionEvent(envelope, sessionId) {
|
|
86
|
+
const frame = muxEventFrame(envelope);
|
|
87
|
+
if (!frame) return null;
|
|
88
|
+
if (sessionId && frame.sessionId !== sessionId) return null;
|
|
89
|
+
return frame.event;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function decodeChunk(chunk, decoder) {
|
|
93
|
+
if (typeof chunk === "string") return chunk;
|
|
94
|
+
return decoder.decode(chunk, { stream: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function* iterateSseJson(chunks) {
|
|
98
|
+
const decoder = new TextDecoder();
|
|
99
|
+
let buffer = "";
|
|
100
|
+
for await (const chunk of chunks) {
|
|
101
|
+
buffer += decodeChunk(chunk, decoder);
|
|
102
|
+
const { blocks, rest } = splitSseBlocks(buffer);
|
|
103
|
+
buffer = rest;
|
|
104
|
+
for (const block of blocks) {
|
|
105
|
+
const data = sseData(block);
|
|
106
|
+
if (!data) continue;
|
|
107
|
+
try {
|
|
108
|
+
yield JSON.parse(data);
|
|
109
|
+
} catch {
|
|
110
|
+
// one corrupt frame must not kill the stream
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function* bytesOf(body) {
|
|
117
|
+
if (!body || typeof body.getReader !== "function") return;
|
|
118
|
+
const reader = body.getReader();
|
|
119
|
+
try {
|
|
120
|
+
while (true) {
|
|
121
|
+
const { done, value } = await reader.read();
|
|
122
|
+
if (done) return;
|
|
123
|
+
if (value) yield value;
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
try {
|
|
127
|
+
reader.releaseLock();
|
|
128
|
+
} catch {
|
|
129
|
+
// already released
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function* iterateWsJson(socket) {
|
|
135
|
+
const inbox = [];
|
|
136
|
+
let wake;
|
|
137
|
+
const enqueue = (item) => {
|
|
138
|
+
inbox.push(item);
|
|
139
|
+
wake?.();
|
|
140
|
+
wake = undefined;
|
|
141
|
+
};
|
|
142
|
+
const onMessage = (event) => {
|
|
143
|
+
const data = event?.data;
|
|
144
|
+
if (typeof data !== "string") return;
|
|
145
|
+
try {
|
|
146
|
+
enqueue(JSON.parse(data));
|
|
147
|
+
} catch {
|
|
148
|
+
// one corrupt frame must not kill the stream
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const onClose = () => enqueue(null);
|
|
152
|
+
socket.addEventListener("message", onMessage);
|
|
153
|
+
socket.addEventListener("close", onClose, { once: true });
|
|
154
|
+
try {
|
|
155
|
+
while (true) {
|
|
156
|
+
while (inbox.length > 0) {
|
|
157
|
+
const item = inbox.shift();
|
|
158
|
+
if (item === null) return;
|
|
159
|
+
yield item;
|
|
160
|
+
}
|
|
161
|
+
await new Promise((resolve) => {
|
|
162
|
+
wake = resolve;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
} finally {
|
|
166
|
+
socket.removeEventListener("message", onMessage);
|
|
167
|
+
socket.removeEventListener("close", onClose);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function* consumeMuxInbox(source) {
|
|
172
|
+
const envelopes = typeof source?.getReader === "function"
|
|
173
|
+
? iterateSseJson(bytesOf(source))
|
|
174
|
+
: iterateWsJson(source);
|
|
175
|
+
for await (const envelope of envelopes) {
|
|
176
|
+
const item = classifyMuxEnvelope(envelope);
|
|
177
|
+
if (item) yield item;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function* consumeMuxFrames(source) {
|
|
182
|
+
for await (const item of consumeMuxInbox(source)) {
|
|
183
|
+
if (item.kind === "event") yield { sessionId: item.sessionId, event: item.event };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function* consumeMux(source, sessionId) {
|
|
188
|
+
const currentId = () => (typeof sessionId === "function" ? sessionId() : sessionId);
|
|
189
|
+
for await (const frame of consumeMuxFrames(source)) {
|
|
190
|
+
const want = currentId();
|
|
191
|
+
if (want && frame.sessionId !== want) continue;
|
|
192
|
+
yield frame.event;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function parseAskUserQuestions(argsRaw) {
|
|
2
|
+
let parsed;
|
|
3
|
+
try {
|
|
4
|
+
parsed = JSON.parse(String(argsRaw ?? ""));
|
|
5
|
+
} catch {
|
|
6
|
+
return [];
|
|
7
|
+
}
|
|
8
|
+
const list = Array.isArray(parsed?.questions) ? parsed.questions : [];
|
|
9
|
+
return list.filter((item) => item && typeof item.id === "string" && typeof item.question === "string");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function singleSelectAnswer(questionId, label) {
|
|
13
|
+
return {
|
|
14
|
+
answers: [{ id: String(questionId), selected: [String(label)] }],
|
|
15
|
+
};
|
|
16
|
+
}
|
package/larepass/rpc.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function mintRpcId() {
|
|
2
|
+
const crypto = globalThis.crypto;
|
|
3
|
+
if (typeof crypto?.randomUUID === "function") return crypto.randomUUID();
|
|
4
|
+
if (typeof crypto?.getRandomValues === "function") {
|
|
5
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
6
|
+
bytes[6] = (bytes[6] & 15) | 64;
|
|
7
|
+
bytes[8] = (bytes[8] & 63) | 128;
|
|
8
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
9
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
10
|
+
}
|
|
11
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function rpcPath(method) {
|
|
15
|
+
return `/api/${method}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const RESPOND_PATH = "/api/respond";
|
|
19
|
+
|
|
20
|
+
export function wrapClientResponse(rpcId, result) {
|
|
21
|
+
return {
|
|
22
|
+
type: "client-response",
|
|
23
|
+
rpcId,
|
|
24
|
+
result,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function wrapClientRequest(method, payload = {}) {
|
|
29
|
+
return {
|
|
30
|
+
type: "client-request",
|
|
31
|
+
rpcId: mintRpcId(),
|
|
32
|
+
method,
|
|
33
|
+
payload,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function unwrapServerResponse(body) {
|
|
38
|
+
if (body === null || typeof body !== "object" || body.type !== "server-response") {
|
|
39
|
+
return { ok: false, error: { code: "invalid-envelope", message: "invalid rpc envelope" } };
|
|
40
|
+
}
|
|
41
|
+
const result = body.result;
|
|
42
|
+
if (result?.ok === true) return { ok: true, rpcId: body.rpcId, value: result.value };
|
|
43
|
+
if (result?.ok === false) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
rpcId: body.rpcId,
|
|
47
|
+
error: result.error ?? { code: "rpc-error", message: "rpc failed" },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { ok: false, rpcId: body.rpcId, error: { code: "invalid-envelope", message: "invalid rpc envelope" } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function promptPayload(sessionId, text, timeZone) {
|
|
54
|
+
return {
|
|
55
|
+
sessionId,
|
|
56
|
+
mode: "queue",
|
|
57
|
+
content: [{ type: "text", text: String(text ?? "") }],
|
|
58
|
+
...(timeZone ? { clientTimeZone: timeZone } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { isAuthFailure } from "./host.js";
|
|
2
|
+
import { listRootSessions, summarizeSession } from "./chat.js";
|
|
3
|
+
import { consumeMuxInbox } from "./mux.js";
|
|
4
|
+
import { wrapClientResponse } from "./rpc.js";
|
|
5
|
+
import { createSessionCache } from "./session-cache.js";
|
|
6
|
+
import { foldTranscript } from "./transcript.js";
|
|
7
|
+
|
|
8
|
+
const DRAFT_SESSION = "@lares/draft";
|
|
9
|
+
|
|
10
|
+
function isDraft(sessionId) {
|
|
11
|
+
return sessionId === DRAFT_SESSION;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function viewOf(state, cache, pendingQuestions) {
|
|
15
|
+
const page = cache.peek(state.sessionId);
|
|
16
|
+
const events = page?.events ?? [];
|
|
17
|
+
const snap = foldTranscript(events);
|
|
18
|
+
return {
|
|
19
|
+
sessionId: isDraft(state.sessionId) ? "" : state.sessionId,
|
|
20
|
+
items: snap.items,
|
|
21
|
+
messages: snap.messages,
|
|
22
|
+
running: snap.running,
|
|
23
|
+
error: state.error || snap.error,
|
|
24
|
+
failed: state.failed,
|
|
25
|
+
phase: state.phase,
|
|
26
|
+
sessions: state.sessions,
|
|
27
|
+
sessionsReady: state.sessionsReady,
|
|
28
|
+
historyLoading: Boolean(state.sessionId) && !page?.ready && events.length === 0,
|
|
29
|
+
question: pendingQuestions.get(state.sessionId) ?? null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function pinnedScroll(top, height, view, slop = 48) {
|
|
34
|
+
if (height <= view) return { top: 0, stick: true };
|
|
35
|
+
return { top, stick: height - view - top <= slop };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function restoreScroll(saved, height, view) {
|
|
39
|
+
if (!saved || saved.stick) return Math.max(0, height - view);
|
|
40
|
+
return Math.min(Math.max(0, saved.top), Math.max(0, height - view));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createChatRuntime(client) {
|
|
44
|
+
const listeners = new Set();
|
|
45
|
+
const cache = createSessionCache();
|
|
46
|
+
const pendingQuestions = new Map();
|
|
47
|
+
const state = {
|
|
48
|
+
sessionId: "",
|
|
49
|
+
error: "",
|
|
50
|
+
failed: "",
|
|
51
|
+
phase: "idle",
|
|
52
|
+
sessions: [],
|
|
53
|
+
sessionsReady: false,
|
|
54
|
+
};
|
|
55
|
+
let liveScroll = { top: 0, stick: true };
|
|
56
|
+
let abort = null;
|
|
57
|
+
let dead = false;
|
|
58
|
+
let muxLoop = null;
|
|
59
|
+
let listing = null;
|
|
60
|
+
let creating = null;
|
|
61
|
+
|
|
62
|
+
const emit = () => {
|
|
63
|
+
const view = viewOf(state, cache, pendingQuestions);
|
|
64
|
+
for (const listener of listeners) listener(view);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function noteFilled(sessionId) {
|
|
68
|
+
const row = state.sessions.find((item) => item.sessionId === sessionId);
|
|
69
|
+
if (!row?.blank) return false;
|
|
70
|
+
row.blank = false;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ingest(sessionId, extra) {
|
|
75
|
+
const changed = cache.merge(sessionId, extra);
|
|
76
|
+
const filled = extra?.length ? noteFilled(sessionId) : false;
|
|
77
|
+
if (filled || (changed && sessionId === state.sessionId)) emit();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function rememberListed(row) {
|
|
81
|
+
if (!row?.sessionId) return;
|
|
82
|
+
state.sessions = [row, ...state.sessions.filter((item) => item.sessionId !== row.sessionId)];
|
|
83
|
+
state.sessionsReady = true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pullHistory(sessionId) {
|
|
87
|
+
if (!sessionId || dead || isDraft(sessionId)) return Promise.resolve();
|
|
88
|
+
return cache.load(sessionId, async () => {
|
|
89
|
+
const history = await client.rpc("session.history", { sessionId });
|
|
90
|
+
if (dead) return;
|
|
91
|
+
if (!history.ok) {
|
|
92
|
+
if (sessionId !== state.sessionId) return;
|
|
93
|
+
cache.rememberHistory(sessionId, []);
|
|
94
|
+
state.failed = history.error?.message || history.error?.code || "history";
|
|
95
|
+
emit();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const events = history.value?.events;
|
|
99
|
+
const changed = cache.rememberHistory(sessionId, events);
|
|
100
|
+
const filled = events?.length ? noteFilled(sessionId) : false;
|
|
101
|
+
if (filled || (changed && sessionId === state.sessionId)) emit();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function listenOnce() {
|
|
106
|
+
abort?.abort();
|
|
107
|
+
abort = new AbortController();
|
|
108
|
+
const opened = await client.openMux(abort.signal);
|
|
109
|
+
if (isAuthFailure(opened.http)) {
|
|
110
|
+
state.failed = "unauthorized";
|
|
111
|
+
emit();
|
|
112
|
+
return "unauthorized";
|
|
113
|
+
}
|
|
114
|
+
if (!opened.ok || !opened.body) {
|
|
115
|
+
state.failed = opened.error?.message || `mux ${opened.http || opened.status || ""}`;
|
|
116
|
+
emit();
|
|
117
|
+
return "error";
|
|
118
|
+
}
|
|
119
|
+
state.failed = "";
|
|
120
|
+
emit();
|
|
121
|
+
try {
|
|
122
|
+
for await (const item of consumeMuxInbox(opened.body)) {
|
|
123
|
+
if (dead) return "stop";
|
|
124
|
+
if (item.kind === "event") {
|
|
125
|
+
ingest(item.sessionId, [item.event]);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (item.kind === "question") {
|
|
129
|
+
pendingQuestions.set(item.sessionId, {
|
|
130
|
+
rpcId: item.rpcId,
|
|
131
|
+
questions: item.questions,
|
|
132
|
+
});
|
|
133
|
+
emit();
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (item.kind === "question-resolved") {
|
|
137
|
+
const current = pendingQuestions.get(item.sessionId);
|
|
138
|
+
if (!current || !item.rpcId || current.rpcId === item.rpcId) {
|
|
139
|
+
pendingQuestions.delete(item.sessionId);
|
|
140
|
+
emit();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (dead || err?.name === "AbortError") return "stop";
|
|
146
|
+
state.failed = err instanceof Error ? err.message : String(err);
|
|
147
|
+
emit();
|
|
148
|
+
return "error";
|
|
149
|
+
}
|
|
150
|
+
return "end";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function listenLoop() {
|
|
154
|
+
while (!dead && state.sessionId) {
|
|
155
|
+
const result = await listenOnce();
|
|
156
|
+
if (result === "unauthorized") {
|
|
157
|
+
state.phase = "idle";
|
|
158
|
+
muxLoop = null;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (dead || result === "stop") {
|
|
162
|
+
if (state.phase === "live") state.phase = "stale";
|
|
163
|
+
muxLoop = null;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
await pullHistory(state.sessionId);
|
|
167
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
168
|
+
}
|
|
169
|
+
muxLoop = null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function ensureMux() {
|
|
173
|
+
if (dead || muxLoop || !state.sessionId) return;
|
|
174
|
+
state.phase = "live";
|
|
175
|
+
muxLoop = listenLoop();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function refreshSessions() {
|
|
179
|
+
if (dead) return state.sessions;
|
|
180
|
+
if (listing) return listing;
|
|
181
|
+
listing = (async () => {
|
|
182
|
+
const listed = await listRootSessions(client.rpc);
|
|
183
|
+
if (listed.ok) state.sessions = listed.value.items;
|
|
184
|
+
state.sessionsReady = true;
|
|
185
|
+
emit();
|
|
186
|
+
return state.sessions;
|
|
187
|
+
})().finally(() => {
|
|
188
|
+
listing = null;
|
|
189
|
+
});
|
|
190
|
+
return listing;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function occupy(sessionId) {
|
|
194
|
+
state.sessionId = sessionId;
|
|
195
|
+
state.error = "";
|
|
196
|
+
state.failed = "";
|
|
197
|
+
liveScroll = cache.scroll(sessionId);
|
|
198
|
+
emit();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function currentIsEmpty() {
|
|
202
|
+
if (!state.sessionId || isDraft(state.sessionId)) return false;
|
|
203
|
+
const page = cache.peek(state.sessionId);
|
|
204
|
+
return Boolean(page?.ready && page.events.length === 0);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function reusableBlank() {
|
|
208
|
+
return state.sessions.find((row) => {
|
|
209
|
+
if (!row?.blank || !row.sessionId || row.sessionId === state.sessionId) return false;
|
|
210
|
+
const page = cache.peek(row.sessionId);
|
|
211
|
+
return !page || page.events.length === 0;
|
|
212
|
+
})?.sessionId;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function openSession(sessionId) {
|
|
216
|
+
if (!sessionId || dead || isDraft(sessionId)) return;
|
|
217
|
+
if (sessionId === state.sessionId) {
|
|
218
|
+
emit();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
occupy(sessionId);
|
|
222
|
+
if (!cache.ready(sessionId)) await pullHistory(sessionId);
|
|
223
|
+
ensureMux();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function createSession() {
|
|
227
|
+
if (dead) return;
|
|
228
|
+
if (creating) return creating;
|
|
229
|
+
creating = (async () => {
|
|
230
|
+
if (currentIsEmpty()) {
|
|
231
|
+
emit();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const blankId = reusableBlank();
|
|
235
|
+
if (blankId) {
|
|
236
|
+
cache.readyEmpty(blankId);
|
|
237
|
+
occupy(blankId);
|
|
238
|
+
ensureMux();
|
|
239
|
+
pullHistory(blankId);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const previous = state.sessionId;
|
|
243
|
+
cache.readyEmpty(DRAFT_SESSION);
|
|
244
|
+
occupy(DRAFT_SESSION);
|
|
245
|
+
ensureMux();
|
|
246
|
+
const opened = await client.rpc("session.create", {});
|
|
247
|
+
if (dead) return;
|
|
248
|
+
if (!opened.ok) {
|
|
249
|
+
cache.drop(DRAFT_SESSION);
|
|
250
|
+
if (state.sessionId !== DRAFT_SESSION) return;
|
|
251
|
+
if (previous) occupy(previous);
|
|
252
|
+
state.failed = opened.error?.message || opened.error?.code || "session";
|
|
253
|
+
emit();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const sessionId = opened.value.sessionId;
|
|
257
|
+
cache.readyEmpty(sessionId);
|
|
258
|
+
rememberListed(summarizeSession({ ...opened.value, sessionId, blank: true }));
|
|
259
|
+
cache.drop(DRAFT_SESSION);
|
|
260
|
+
if (state.sessionId !== DRAFT_SESSION && state.sessionId !== sessionId) return;
|
|
261
|
+
occupy(sessionId);
|
|
262
|
+
ensureMux();
|
|
263
|
+
})().finally(() => {
|
|
264
|
+
creating = null;
|
|
265
|
+
});
|
|
266
|
+
return creating;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
snapshot: () => viewOf(state, cache, pendingQuestions),
|
|
271
|
+
subscribe(listener) {
|
|
272
|
+
listeners.add(listener);
|
|
273
|
+
listener(viewOf(state, cache, pendingQuestions));
|
|
274
|
+
return () => listeners.delete(listener);
|
|
275
|
+
},
|
|
276
|
+
async start() {
|
|
277
|
+
if (dead) return;
|
|
278
|
+
if (state.phase === "starting") return;
|
|
279
|
+
if (state.phase === "live" && muxLoop) return;
|
|
280
|
+
if (state.sessionId && (state.phase === "live" || state.phase === "stale")) {
|
|
281
|
+
ensureMux();
|
|
282
|
+
emit();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
state.phase = "starting";
|
|
286
|
+
state.failed = "";
|
|
287
|
+
emit();
|
|
288
|
+
const probe = await client.probe();
|
|
289
|
+
if (probe.status !== "ok") {
|
|
290
|
+
state.phase = "idle";
|
|
291
|
+
state.failed = probe.status;
|
|
292
|
+
emit();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const opened = await client.ensureSession();
|
|
296
|
+
if (!opened.ok) {
|
|
297
|
+
state.phase = "idle";
|
|
298
|
+
state.failed = opened.error?.message || opened.error?.code || "session";
|
|
299
|
+
emit();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
state.sessionId = opened.value.sessionId;
|
|
303
|
+
liveScroll = cache.scroll(state.sessionId);
|
|
304
|
+
const listed = Array.isArray(opened.value.sessions)
|
|
305
|
+
? (state.sessions = opened.value.sessions, state.sessionsReady = true, Promise.resolve(state.sessions))
|
|
306
|
+
: refreshSessions();
|
|
307
|
+
await Promise.all([pullHistory(state.sessionId), listed]);
|
|
308
|
+
ensureMux();
|
|
309
|
+
emit();
|
|
310
|
+
},
|
|
311
|
+
refreshSessions,
|
|
312
|
+
async listSessions() {
|
|
313
|
+
if (state.sessionsReady) {
|
|
314
|
+
refreshSessions();
|
|
315
|
+
return state.sessions;
|
|
316
|
+
}
|
|
317
|
+
return refreshSessions();
|
|
318
|
+
},
|
|
319
|
+
openSession,
|
|
320
|
+
createSession,
|
|
321
|
+
async send(text) {
|
|
322
|
+
if (!state.sessionId || isDraft(state.sessionId)) {
|
|
323
|
+
return { ok: false, error: { message: "no session" } };
|
|
324
|
+
}
|
|
325
|
+
return client.prompt(state.sessionId, text);
|
|
326
|
+
},
|
|
327
|
+
async answerQuestion(answers) {
|
|
328
|
+
const pending = pendingQuestions.get(state.sessionId);
|
|
329
|
+
if (!pending?.rpcId || typeof client.respond !== "function") {
|
|
330
|
+
return { ok: false, error: { message: "no question" } };
|
|
331
|
+
}
|
|
332
|
+
const receipt = await client.respond(wrapClientResponse(pending.rpcId, {
|
|
333
|
+
ok: true,
|
|
334
|
+
value: { sessionId: state.sessionId, answer: { answers } },
|
|
335
|
+
}));
|
|
336
|
+
if (!receipt?.accepted) {
|
|
337
|
+
return { ok: false, error: { message: receipt?.reason || "not-pending" } };
|
|
338
|
+
}
|
|
339
|
+
return { ok: true };
|
|
340
|
+
},
|
|
341
|
+
preview(path) {
|
|
342
|
+
if (!state.sessionId || typeof client.preview !== "function") {
|
|
343
|
+
return Promise.reject(new Error("file_preview_failed"));
|
|
344
|
+
}
|
|
345
|
+
return client.preview(state.sessionId, path);
|
|
346
|
+
},
|
|
347
|
+
mediaUrl(path, modifiedAt) {
|
|
348
|
+
return client.mediaUrl?.(state.sessionId, path, modifiedAt) ?? "";
|
|
349
|
+
},
|
|
350
|
+
downloadUrl(path) {
|
|
351
|
+
return client.downloadUrl?.(state.sessionId, path) ?? "";
|
|
352
|
+
},
|
|
353
|
+
upload(file, options, sessionId = state.sessionId) {
|
|
354
|
+
const id = sessionId || state.sessionId;
|
|
355
|
+
if (!id || isDraft(id) || typeof client.upload !== "function") {
|
|
356
|
+
return Promise.reject(new Error("file_upload_failed"));
|
|
357
|
+
}
|
|
358
|
+
return client.upload(id, file, options);
|
|
359
|
+
},
|
|
360
|
+
transcribe(blob, language, signal) {
|
|
361
|
+
if (typeof client.transcribe !== "function") {
|
|
362
|
+
return Promise.reject(new Error("voice_failed"));
|
|
363
|
+
}
|
|
364
|
+
return client.transcribe(blob, language, signal);
|
|
365
|
+
},
|
|
366
|
+
settings: client.settings,
|
|
367
|
+
rememberScroll(top, height, view) {
|
|
368
|
+
if (view <= 0) return;
|
|
369
|
+
liveScroll = pinnedScroll(top, height, view);
|
|
370
|
+
if (state.sessionId) cache.setScroll(state.sessionId, liveScroll);
|
|
371
|
+
},
|
|
372
|
+
pinToBottom() {
|
|
373
|
+
liveScroll = { top: 0, stick: true };
|
|
374
|
+
if (state.sessionId) cache.setScroll(state.sessionId, liveScroll);
|
|
375
|
+
},
|
|
376
|
+
sticking() {
|
|
377
|
+
return (state.sessionId ? cache.scroll(state.sessionId) : liveScroll).stick !== false;
|
|
378
|
+
},
|
|
379
|
+
scrollTop(height, view) {
|
|
380
|
+
if (view <= 0) return null;
|
|
381
|
+
const saved = state.sessionId ? cache.peek(state.sessionId)?.scroll ?? liveScroll : liveScroll;
|
|
382
|
+
return restoreScroll(saved, height, view);
|
|
383
|
+
},
|
|
384
|
+
dispose() {
|
|
385
|
+
dead = true;
|
|
386
|
+
abort?.abort();
|
|
387
|
+
muxLoop = null;
|
|
388
|
+
listing = null;
|
|
389
|
+
creating = null;
|
|
390
|
+
cache.clear();
|
|
391
|
+
pendingQuestions.clear();
|
|
392
|
+
listeners.clear();
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|