@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/files/mention.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const REFERENCE_SOURCE = "reference";
|
|
2
|
+
|
|
3
|
+
export function mentionOf(path) {
|
|
4
|
+
return /[\s"]/u.test(path) ? `@"${path}"` : `@${path}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function appendDraftMentions(draft, paths) {
|
|
8
|
+
let next = String(draft ?? "");
|
|
9
|
+
for (const path of Array.isArray(paths) ? paths : []) {
|
|
10
|
+
if (!path) continue;
|
|
11
|
+
const ref = mentionOf(path);
|
|
12
|
+
if (next && !/\s$/u.test(next)) next += " ";
|
|
13
|
+
next += ref;
|
|
14
|
+
}
|
|
15
|
+
return next;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function uploadReference(path) {
|
|
19
|
+
const ref = mentionOf(path);
|
|
20
|
+
return {
|
|
21
|
+
source: REFERENCE_SOURCE,
|
|
22
|
+
ref,
|
|
23
|
+
label: path.slice(path.lastIndexOf("/") + 1),
|
|
24
|
+
appearance: "file",
|
|
25
|
+
clipboardText: ref,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Append one reference per uploaded path at the end of the draft.
|
|
31
|
+
* @returns the paths the input machine refused, in order.
|
|
32
|
+
*/
|
|
33
|
+
export function insertUploadReferences(input, paths) {
|
|
34
|
+
const refused = [];
|
|
35
|
+
for (const path of paths) {
|
|
36
|
+
const current = input.state.getSnapshot();
|
|
37
|
+
if (current.draft !== "" && !/\s$/u.test(current.draft)) input.setDraft(`${current.draft} `);
|
|
38
|
+
const state = input.state.getSnapshot();
|
|
39
|
+
const at = state.draft.length;
|
|
40
|
+
const applied = input.insertReference(uploadReference(path), {
|
|
41
|
+
start: at,
|
|
42
|
+
end: at,
|
|
43
|
+
draftRev: state.draftRev,
|
|
44
|
+
});
|
|
45
|
+
if (!applied) refused.push(path);
|
|
46
|
+
}
|
|
47
|
+
return refused;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function commitUploadPaths(input, paths, notifyUnlinked) {
|
|
51
|
+
if (!input) return;
|
|
52
|
+
for (const path of insertUploadReferences(input, paths)) {
|
|
53
|
+
notifyUnlinked(path);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createUploadCommit({ scopeSession, inputFor, unlinkedMessage }) {
|
|
58
|
+
return (sessionId) => (paths) => {
|
|
59
|
+
const sessionCtx = scopeSession(sessionId);
|
|
60
|
+
if (sessionCtx === undefined) return;
|
|
61
|
+
const input = inputFor(sessionCtx);
|
|
62
|
+
commitUploadPaths(input, paths, (path) => input.notify("error", unlinkedMessage(path)));
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { runOlaresDownload } from "../drive/download.js";
|
|
5
|
+
import { sanitizeFilename } from "./filename.js";
|
|
6
|
+
|
|
7
|
+
const inflight = new Map();
|
|
8
|
+
|
|
9
|
+
export function previewCacheRoot(env = process.env) {
|
|
10
|
+
const root = env.LARES_DATA_DIR?.trim();
|
|
11
|
+
return join(root || "/data/lares", "preview-cache");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function cacheDir(cacheRoot, source) {
|
|
15
|
+
const id = createHash("sha256").update(source).digest("hex").slice(0, 24);
|
|
16
|
+
return join(cacheRoot, id);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readMeta(path) {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
22
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
23
|
+
} catch {
|
|
24
|
+
// Missing or corrupt meta is a cache miss.
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function localSize(path) {
|
|
30
|
+
try {
|
|
31
|
+
const info = await stat(path);
|
|
32
|
+
return info.isFile() ? info.size : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function attachLocal(file, absolutePath) {
|
|
39
|
+
const info = await stat(absolutePath);
|
|
40
|
+
return {
|
|
41
|
+
...file,
|
|
42
|
+
absolutePath,
|
|
43
|
+
size: info.size,
|
|
44
|
+
modifiedAt: info.mtimeMs,
|
|
45
|
+
device: info.dev,
|
|
46
|
+
inode: info.ino,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function materializeOnce(file, options) {
|
|
51
|
+
const download = options.download ?? runOlaresDownload;
|
|
52
|
+
const cacheRoot = options.cacheRoot ?? previewCacheRoot(options.env);
|
|
53
|
+
const dir = cacheDir(cacheRoot, file.path);
|
|
54
|
+
const dest = join(dir, sanitizeFilename(file.name));
|
|
55
|
+
const metaPath = join(dir, "meta.json");
|
|
56
|
+
const expected = { source: file.path, size: file.size, modifiedAt: file.modifiedAt };
|
|
57
|
+
|
|
58
|
+
const meta = await readMeta(metaPath);
|
|
59
|
+
const size = await localSize(dest);
|
|
60
|
+
const metaMatches = meta?.source === expected.source
|
|
61
|
+
&& meta?.size === expected.size
|
|
62
|
+
&& meta?.modifiedAt === expected.modifiedAt;
|
|
63
|
+
|
|
64
|
+
if (metaMatches && size === expected.size) return attachLocal(file, dest);
|
|
65
|
+
|
|
66
|
+
await mkdir(dir, { recursive: true });
|
|
67
|
+
await writeFile(metaPath, JSON.stringify(expected));
|
|
68
|
+
const resume = metaMatches && size !== null && size < expected.size;
|
|
69
|
+
await download(file.path, dest, {
|
|
70
|
+
signal: options.signal,
|
|
71
|
+
overwrite: !resume,
|
|
72
|
+
resume,
|
|
73
|
+
...(options.spawnFn ? { spawnFn: options.spawnFn } : {}),
|
|
74
|
+
});
|
|
75
|
+
const next = await localSize(dest);
|
|
76
|
+
if (next !== expected.size) {
|
|
77
|
+
const error = new Error(
|
|
78
|
+
`downloaded size ${next} does not match files backend size ${expected.size}`,
|
|
79
|
+
);
|
|
80
|
+
error.code = "files_unavailable";
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
return attachLocal(file, dest);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Copy one files-backend file into `$LARES_DATA_DIR/preview-cache` so range
|
|
88
|
+
* streaming can use a local handle. Concurrent requests for the same source
|
|
89
|
+
* share one download; a matching size/mtime reuses the cache.
|
|
90
|
+
*/
|
|
91
|
+
export function materializeFilesFile(file, options = {}) {
|
|
92
|
+
const key = file.path;
|
|
93
|
+
const pending = inflight.get(key);
|
|
94
|
+
if (pending) return pending;
|
|
95
|
+
const done = materializeOnce(file, options).finally(() => {
|
|
96
|
+
if (inflight.get(key) === done) inflight.delete(key);
|
|
97
|
+
});
|
|
98
|
+
inflight.set(key, done);
|
|
99
|
+
return done;
|
|
100
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const MEDIA_KINDS = new Set(["image", "video", "audio"]);
|
|
2
|
+
|
|
3
|
+
export function partitionPreviews(paths, previews) {
|
|
4
|
+
const media = [];
|
|
5
|
+
const files = [];
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
let loading = false;
|
|
8
|
+
for (const original of paths) {
|
|
9
|
+
if (!previews.has(original)) {
|
|
10
|
+
loading = true;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const preview = previews.get(original);
|
|
14
|
+
const path = preview?.path ?? original;
|
|
15
|
+
if (seen.has(path)) continue;
|
|
16
|
+
seen.add(path);
|
|
17
|
+
if (preview !== null && MEDIA_KINDS.has(preview.kind)) media.push(preview);
|
|
18
|
+
else files.push(path);
|
|
19
|
+
}
|
|
20
|
+
return { media, files, loading };
|
|
21
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
export class NullScrollport {
|
|
2
|
+
offset() {
|
|
3
|
+
return null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
scrollTo() {}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const MAX_TABS = 8;
|
|
10
|
+
|
|
11
|
+
export function fileName(path) {
|
|
12
|
+
const parts = String(path).split(/[/\\]/);
|
|
13
|
+
return parts.at(-1) || path;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function initialSnapshot() {
|
|
17
|
+
return {
|
|
18
|
+
mode: "chat",
|
|
19
|
+
tabs: [],
|
|
20
|
+
activePath: null,
|
|
21
|
+
content: { status: "idle" },
|
|
22
|
+
evictedName: null,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function errorCode(payload) {
|
|
27
|
+
return payload?.error?.code || "file_preview_failed";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function fetchPreview(sessionId, path) {
|
|
31
|
+
const query = new URLSearchParams({ sessionId, path });
|
|
32
|
+
const response = await fetch(`/api/lares/file-preview/preview?${query}`);
|
|
33
|
+
const payload = await response.json().catch(() => null);
|
|
34
|
+
if (!response.ok) throw new Error(errorCode(payload));
|
|
35
|
+
return payload;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function fetchPreviewMap(sessionId, paths) {
|
|
39
|
+
const entries = await Promise.all(paths.map(async (path) => {
|
|
40
|
+
try {
|
|
41
|
+
return [path, await fetchPreview(sessionId, path)];
|
|
42
|
+
} catch {
|
|
43
|
+
return [path, null];
|
|
44
|
+
}
|
|
45
|
+
}));
|
|
46
|
+
return new Map(entries);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isPrimaryUnmodifiedClick(event) {
|
|
50
|
+
return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function workspaceLinkClickPath(sessionId, event) {
|
|
54
|
+
if (!isPrimaryUnmodifiedClick(event)) return null;
|
|
55
|
+
const anchor = event.target?.closest?.("a[href]");
|
|
56
|
+
if (!anchor) return null;
|
|
57
|
+
return rawUrlPath(sessionId, anchor.getAttribute("href"));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function interceptOpenPath(workspace, path, openNative) {
|
|
61
|
+
if (await workspace.openCurrent(path)) return;
|
|
62
|
+
await openNative(path);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const RAW_ROUTE = "/api/lares/file-preview/raw";
|
|
66
|
+
const DOWNLOAD_ROUTE = "/api/lares/file-preview/download";
|
|
67
|
+
|
|
68
|
+
export function previewMetaUrl(sessionId, path) {
|
|
69
|
+
const query = new URLSearchParams({ sessionId, path });
|
|
70
|
+
return `/api/lares/file-preview/preview?${query}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function rawFileUrl(sessionId, path, modifiedAt) {
|
|
74
|
+
const query = new URLSearchParams({ sessionId, path });
|
|
75
|
+
if (modifiedAt !== undefined) query.set("v", String(modifiedAt));
|
|
76
|
+
return `${RAW_ROUTE}?${query}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function downloadFileUrl(sessionId, path) {
|
|
80
|
+
const query = new URLSearchParams({ sessionId, path });
|
|
81
|
+
return `${DOWNLOAD_ROUTE}?${query}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function pageOrigin() {
|
|
85
|
+
return globalThis.location?.origin ?? "http://localhost";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Absolute form: markdown targets survive the renderer only as full URLs. */
|
|
89
|
+
export function rawFileHref(sessionId, path) {
|
|
90
|
+
return new URL(rawFileUrl(sessionId, path), pageOrigin()).href;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The workspace path behind a raw URL this session owns, or null for anything
|
|
95
|
+
* else — the seam that turns a rewritten markdown target back into a preview.
|
|
96
|
+
* The pathname may carry a host-proxy prefix (LarePass `/laresHost`).
|
|
97
|
+
*/
|
|
98
|
+
function isRawFilePathname(pathname) {
|
|
99
|
+
return pathname === RAW_ROUTE || pathname.endsWith(RAW_ROUTE);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function rawUrlPath(sessionId, href) {
|
|
103
|
+
let url;
|
|
104
|
+
try {
|
|
105
|
+
url = new URL(href, pageOrigin());
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (url.origin !== pageOrigin() || !isRawFilePathname(url.pathname)) return null;
|
|
110
|
+
if (url.searchParams.get("sessionId") !== sessionId) return null;
|
|
111
|
+
return url.searchParams.get("path");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class FilePreviewWorkspace {
|
|
115
|
+
constructor(scrollport = new NullScrollport()) {
|
|
116
|
+
this.sessions = new Map();
|
|
117
|
+
this.current = null;
|
|
118
|
+
this.scrollport = scrollport;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
session(sessionId) {
|
|
122
|
+
let state = this.sessions.get(sessionId);
|
|
123
|
+
if (state) return state;
|
|
124
|
+
state = {
|
|
125
|
+
snapshot: initialSnapshot(),
|
|
126
|
+
lru: [],
|
|
127
|
+
contents: new Map(),
|
|
128
|
+
offsets: new Map(),
|
|
129
|
+
listeners: new Set(),
|
|
130
|
+
requestVersions: new Map(),
|
|
131
|
+
chatOffset: null,
|
|
132
|
+
};
|
|
133
|
+
this.sessions.set(sessionId, state);
|
|
134
|
+
return state;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getSnapshot(sessionId) {
|
|
138
|
+
return this.session(sessionId).snapshot;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
subscribe(sessionId, listener) {
|
|
142
|
+
const state = this.session(sessionId);
|
|
143
|
+
state.listeners.add(listener);
|
|
144
|
+
return () => state.listeners.delete(listener);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
bindCurrent(sessionId) {
|
|
148
|
+
const binding = { sessionId };
|
|
149
|
+
this.current = binding;
|
|
150
|
+
return () => {
|
|
151
|
+
if (this.current === binding) this.current = null;
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Claim a Host open request, or decline it. The preview serves regular files
|
|
157
|
+
* only, so the target is resolved before any tab exists: a directory stays
|
|
158
|
+
* with the Host's own opener instead of becoming a tab that can never load.
|
|
159
|
+
*/
|
|
160
|
+
async openCurrent(path) {
|
|
161
|
+
if (!this.current) return false;
|
|
162
|
+
const { sessionId } = this.current;
|
|
163
|
+
const content = await this.fetchContent(sessionId, path);
|
|
164
|
+
if (content.status === "error" && content.message === "path_not_file") return false;
|
|
165
|
+
this.open(sessionId, path, content);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
showChat(sessionId) {
|
|
170
|
+
this.emit(sessionId, { mode: "chat", evictedName: null });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @param resolved - content the caller already fetched; absent loads it here. */
|
|
174
|
+
open(sessionId, path, resolved) {
|
|
175
|
+
if (resolved?.status === "ready" && resolved.data?.path) path = resolved.data.path;
|
|
176
|
+
const state = this.session(sessionId);
|
|
177
|
+
const existing = state.snapshot.tabs.find((tab) => tab.path === path);
|
|
178
|
+
let tabs = state.snapshot.tabs;
|
|
179
|
+
let evictedName = null;
|
|
180
|
+
if (!existing) {
|
|
181
|
+
if (tabs.length >= MAX_TABS) {
|
|
182
|
+
const victim = state.lru[0];
|
|
183
|
+
const evicted = tabs.find((tab) => tab.path === victim);
|
|
184
|
+
tabs = tabs.filter((tab) => tab.path !== victim);
|
|
185
|
+
state.lru = state.lru.filter((item) => item !== victim);
|
|
186
|
+
state.contents.delete(victim);
|
|
187
|
+
state.offsets.delete(victim);
|
|
188
|
+
state.requestVersions.delete(victim);
|
|
189
|
+
evictedName = evicted?.name ?? null;
|
|
190
|
+
}
|
|
191
|
+
tabs = [...tabs, { path, name: fileName(path) }];
|
|
192
|
+
}
|
|
193
|
+
if (resolved !== undefined) {
|
|
194
|
+
state.contents.set(path, resolved);
|
|
195
|
+
state.requestVersions.set(path, (state.requestVersions.get(path) ?? 0) + 1);
|
|
196
|
+
}
|
|
197
|
+
this.touch(state, path);
|
|
198
|
+
this.emit(sessionId, {
|
|
199
|
+
mode: "preview",
|
|
200
|
+
tabs,
|
|
201
|
+
activePath: path,
|
|
202
|
+
content: state.contents.get(path) ?? { status: "idle" },
|
|
203
|
+
evictedName,
|
|
204
|
+
});
|
|
205
|
+
if (resolved === undefined) void this.load(sessionId, path, true);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
activate(sessionId, path) {
|
|
209
|
+
const state = this.session(sessionId);
|
|
210
|
+
if (!state.snapshot.tabs.some((tab) => tab.path === path)) return;
|
|
211
|
+
this.touch(state, path);
|
|
212
|
+
this.emit(sessionId, {
|
|
213
|
+
mode: "preview",
|
|
214
|
+
activePath: path,
|
|
215
|
+
content: state.contents.get(path) ?? { status: "idle" },
|
|
216
|
+
evictedName: null,
|
|
217
|
+
});
|
|
218
|
+
void this.load(sessionId, path, true);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
close(sessionId, path) {
|
|
222
|
+
const state = this.session(sessionId);
|
|
223
|
+
if (!state.snapshot.tabs.some((tab) => tab.path === path)) return;
|
|
224
|
+
const tabs = state.snapshot.tabs.filter((tab) => tab.path !== path);
|
|
225
|
+
state.lru = state.lru.filter((item) => item !== path);
|
|
226
|
+
state.contents.delete(path);
|
|
227
|
+
state.offsets.delete(path);
|
|
228
|
+
state.requestVersions.set(path, (state.requestVersions.get(path) ?? 0) + 1);
|
|
229
|
+
if (tabs.length === 0) {
|
|
230
|
+
this.emit(sessionId, {
|
|
231
|
+
mode: "chat",
|
|
232
|
+
tabs,
|
|
233
|
+
activePath: null,
|
|
234
|
+
content: { status: "idle" },
|
|
235
|
+
evictedName: null,
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (state.snapshot.activePath !== path) {
|
|
240
|
+
this.emit(sessionId, { tabs, evictedName: null });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const activePath = state.lru.at(-1) ?? tabs.at(-1).path;
|
|
244
|
+
this.emit(sessionId, {
|
|
245
|
+
tabs,
|
|
246
|
+
activePath,
|
|
247
|
+
content: state.contents.get(activePath) ?? { status: "idle" },
|
|
248
|
+
evictedName: null,
|
|
249
|
+
});
|
|
250
|
+
void this.load(sessionId, activePath);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
clearEvicted(sessionId) {
|
|
254
|
+
this.emit(sessionId, { evictedName: null });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
retry(sessionId, path) {
|
|
258
|
+
const state = this.session(sessionId);
|
|
259
|
+
state.contents.delete(path);
|
|
260
|
+
void this.load(sessionId, path);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
touch(state, path) {
|
|
264
|
+
state.lru = state.lru.filter((item) => item !== path);
|
|
265
|
+
state.lru.push(path);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Where a tab was scrolled to. The scrolling element belongs to whichever kind
|
|
270
|
+
* is on screen, so it does not outlive the tab: the offset lives here for the
|
|
271
|
+
* tab's lifetime instead. It stays out of the snapshot — scrolling must not
|
|
272
|
+
* re-render the view.
|
|
273
|
+
*/
|
|
274
|
+
scrollOffset(sessionId, path) {
|
|
275
|
+
return this.session(sessionId).offsets.get(path) ?? 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
rememberScroll(sessionId, path, offset) {
|
|
279
|
+
this.session(sessionId).offsets.set(path, offset);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async fetchContent(sessionId, path) {
|
|
283
|
+
try {
|
|
284
|
+
return { status: "ready", data: await fetchPreview(sessionId, path) };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return {
|
|
287
|
+
status: "error",
|
|
288
|
+
message: error instanceof Error ? error.message : "file_preview_failed",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async load(sessionId, path, force = false) {
|
|
294
|
+
const state = this.session(sessionId);
|
|
295
|
+
const current = state.contents.get(path);
|
|
296
|
+
if (!force && (current?.status === "ready" || current?.status === "loading")) return;
|
|
297
|
+
const version = (state.requestVersions.get(path) ?? 0) + 1;
|
|
298
|
+
state.requestVersions.set(path, version);
|
|
299
|
+
if (current?.status !== "ready") {
|
|
300
|
+
state.contents.set(path, { status: "loading" });
|
|
301
|
+
if (state.snapshot.activePath === path) this.emit(sessionId, { content: { status: "loading" } });
|
|
302
|
+
}
|
|
303
|
+
const content = await this.fetchContent(sessionId, path);
|
|
304
|
+
if (state.requestVersions.get(path) !== version) return;
|
|
305
|
+
state.contents.set(path, content);
|
|
306
|
+
if (state.snapshot.activePath === path) this.emit(sessionId, { content });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Hand the chat scrollport back where the reader left it. Called once the
|
|
311
|
+
* overlay is off screen — earlier the flow is still collapsed and the write
|
|
312
|
+
* would be clamped away again.
|
|
313
|
+
*/
|
|
314
|
+
restoreChatScroll(sessionId) {
|
|
315
|
+
const state = this.session(sessionId);
|
|
316
|
+
if (state.chatOffset === null) return;
|
|
317
|
+
const offset = state.chatOffset;
|
|
318
|
+
state.chatOffset = null;
|
|
319
|
+
this.scrollport.scrollTo(offset);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Drop a captured offset without writing it. Switching sessions unmounts the
|
|
324
|
+
* previous conversation: its scroller is gone, and writing into the new one
|
|
325
|
+
* would jump a different thread.
|
|
326
|
+
*/
|
|
327
|
+
abandonChatScroll(sessionId) {
|
|
328
|
+
this.session(sessionId).chatOffset = null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
emit(sessionId, patch) {
|
|
332
|
+
const state = this.session(sessionId);
|
|
333
|
+
const next = { ...state.snapshot, ...patch };
|
|
334
|
+
// Mode transitions run ahead of the commit that mounts the overlay, so this
|
|
335
|
+
// is the last moment the reader's own offset is still readable.
|
|
336
|
+
if (state.snapshot.mode === "chat" && next.mode === "preview") {
|
|
337
|
+
state.chatOffset = this.scrollport.offset();
|
|
338
|
+
}
|
|
339
|
+
state.snapshot = next;
|
|
340
|
+
for (const listener of state.listeners) listener();
|
|
341
|
+
}
|
|
342
|
+
}
|