@oar-sh/oar 0.9.7 → 0.9.9
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/CHANGELOG.md +344 -0
- package/README.md +89 -40
- package/bin/oar.js +1023 -135
- package/package.json +4 -5
- package/server/README.md +20 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
- package/server/claude-worker/claude-session-process.mjs +196 -28
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/db-schema.mjs +8 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/api-client.js +36 -2
- package/server/public/app/attachments-view.js +508 -125
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/bootstrap.js +170 -59
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-view.js +172 -32
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/journal-view.js +16 -2
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/pwa-install.js +6 -1
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/router.js +8 -2
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +4 -1
- package/server/public/app/socket-handlers.js +8 -1
- package/server/public/app/store.js +32 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +43 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon-light-192.png +0 -0
- package/server/public/app-icon-light-512.png +0 -0
- package/server/public/app-icon-light-maskable-512.png +0 -0
- package/server/public/app-icon-light.svg +4 -0
- package/server/public/app-icon-maskable-512.png +0 -0
- package/server/public/app-icon.svg +3 -3
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +487 -67
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +5 -0
- package/server/routes/messages-routes.mjs +36 -62
- package/server/routes/sessions-routes.mjs +107 -26
- package/server/server-runtime.mjs +39 -8
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +205 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-history-refresh-service.mjs +24 -0
- package/server/services/session-worker-registry-service.mjs +18 -1
- package/server/services/update-install-service.mjs +9 -3
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
"scope": "./",
|
|
8
8
|
"display_override": ["standalone"],
|
|
9
9
|
"display": "standalone",
|
|
10
|
-
"background_color": "#
|
|
11
|
-
"theme_color": "#
|
|
10
|
+
"background_color": "#0d1117",
|
|
11
|
+
"theme_color": "#0d1117",
|
|
12
12
|
"icons": [
|
|
13
13
|
{
|
|
14
|
-
"src": "app-icon.svg?v=
|
|
14
|
+
"src": "app-icon.svg?v=26",
|
|
15
15
|
"sizes": "any",
|
|
16
16
|
"type": "image/svg+xml",
|
|
17
17
|
"purpose": "any"
|
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
"sizes": "512x512",
|
|
28
28
|
"type": "image/png",
|
|
29
29
|
"purpose": "any"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"src": "app-icon-maskable-512.png",
|
|
33
|
+
"sizes": "512x512",
|
|
34
|
+
"type": "image/png",
|
|
35
|
+
"purpose": "maskable"
|
|
30
36
|
}
|
|
31
37
|
]
|
|
32
38
|
}
|
package/server/public/sw.js
CHANGED
|
@@ -13,6 +13,11 @@ const STATIC_ASSETS = [
|
|
|
13
13
|
'app-icon-512.png',
|
|
14
14
|
'favicon.ico',
|
|
15
15
|
'app-icon.svg',
|
|
16
|
+
'app-icon-light-192.png',
|
|
17
|
+
'app-icon-light-512.png',
|
|
18
|
+
'app-icon-light.svg',
|
|
19
|
+
'app-icon-maskable-512.png',
|
|
20
|
+
'app-icon-light-maskable-512.png',
|
|
16
21
|
];
|
|
17
22
|
|
|
18
23
|
function sameOrigin(url) {
|
|
@@ -26,7 +31,7 @@ function isApiRequest(url) {
|
|
|
26
31
|
function isPwaMetadataRequest(url) {
|
|
27
32
|
if (!url.pathname.startsWith(REGISTRATION_SCOPE_PATH)) return false;
|
|
28
33
|
const relativePath = url.pathname.slice(REGISTRATION_SCOPE_PATH.length);
|
|
29
|
-
return /^(?:manifest\.webmanifest|app-icon(?:-\d+)?\.png|
|
|
34
|
+
return /^(?:manifest\.webmanifest|app-icon(?:-light)?(?:-maskable)?(?:-\d+)?\.(?:png|svg)|favicon\.ico)$/.test(relativePath);
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
function isApplicationModuleRequest(url) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pinned messages: the pinned_at column detection and its SQL in one place.
|
|
4
|
+
// The statements are null on a database the 0008 migration has not reached
|
|
5
|
+
// yet; every caller treats that as "no pins".
|
|
6
|
+
export function createMessagePinStatements(db) {
|
|
7
|
+
const messageColumns = new Set(
|
|
8
|
+
db.prepare(`PRAGMA table_info(messages)`).all().map((column) => String(column?.name || '').trim()),
|
|
9
|
+
);
|
|
10
|
+
const messagesSupportPins = messageColumns.has('pinned_at');
|
|
11
|
+
if (!messagesSupportPins) {
|
|
12
|
+
return {
|
|
13
|
+
messagesSupportPins,
|
|
14
|
+
setMessagePinnedAt: null,
|
|
15
|
+
listPinnedMessages: null,
|
|
16
|
+
countPinnedMessages: null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
messagesSupportPins,
|
|
21
|
+
setMessagePinnedAt: db.prepare(`
|
|
22
|
+
UPDATE messages
|
|
23
|
+
SET pinned_at = ?
|
|
24
|
+
WHERE id = ? AND conversation_id = ?
|
|
25
|
+
`),
|
|
26
|
+
// Conversation order, not pin order: the list reads like a table of contents.
|
|
27
|
+
listPinnedMessages: db.prepare(`
|
|
28
|
+
SELECT id, role, text, mode, attachments, timestamp, pinned_at, hidden_from_shares
|
|
29
|
+
FROM messages
|
|
30
|
+
WHERE conversation_id = ? AND pinned_at IS NOT NULL
|
|
31
|
+
ORDER BY timestamp ASC
|
|
32
|
+
`),
|
|
33
|
+
countPinnedMessages: db.prepare(`
|
|
34
|
+
SELECT COUNT(*) AS cnt
|
|
35
|
+
FROM messages
|
|
36
|
+
WHERE conversation_id = ? AND pinned_at IS NOT NULL
|
|
37
|
+
`),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import { createShareVisibilityStatements } from './share-visibility-statements.mjs';
|
|
4
|
+
import { createMessagePinStatements } from './message-pin-statements.mjs';
|
|
4
5
|
import { sessionWorkerProviderSqlList } from '../../shared/provider-routing.mjs';
|
|
5
6
|
|
|
6
7
|
export function createMessageRepository(db) {
|
|
7
8
|
const shareVisibility = createShareVisibilityStatements(db);
|
|
9
|
+
const messagePins = createMessagePinStatements(db);
|
|
8
10
|
const queueHasImageOperationId = db.prepare(`PRAGMA table_info(queue)`).all()
|
|
9
11
|
.some((column) => column.name === 'image_operation_id');
|
|
10
12
|
// A turn paused at the Claude usage limit (usage-limit-pause-service): a
|
|
@@ -27,6 +29,9 @@ export function createMessageRepository(db) {
|
|
|
27
29
|
getSharedMessages: shareVisibility.getSharedMessages,
|
|
28
30
|
getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
|
|
29
31
|
setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
|
|
32
|
+
setMessagePinnedAt: messagePins.setMessagePinnedAt,
|
|
33
|
+
listPinnedMessages: messagePins.listPinnedMessages,
|
|
34
|
+
countPinnedMessages: messagePins.countPinnedMessages,
|
|
30
35
|
getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
|
|
31
36
|
getRecentMessagesDesc: db.prepare(`SELECT id, role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
|
|
32
37
|
insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
@@ -5,7 +5,7 @@ import path from 'path';
|
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
6
|
import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
|
|
7
7
|
import { resolveUploadMimeType } from '../services/mime-sniffer.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { fileVersionToken, serveFileWithRangeSupport, withFileVersion } from '../services/file-serving.mjs';
|
|
9
9
|
import {
|
|
10
10
|
shouldParkForRestart,
|
|
11
11
|
parkPendingQueueForRestart,
|
|
@@ -71,6 +71,11 @@ const STRANDED_SESSION_HEARTBEAT_FRESH_MS = 15_000;
|
|
|
71
71
|
const RELAY_QUESTION_FINALIZATION_HOLD_MS = 5_000;
|
|
72
72
|
const AUTO_MODEL_SENTINEL = 'auto';
|
|
73
73
|
const USAGE_FETCH_RETRY_DELAY_MS = 250;
|
|
74
|
+
// Preview kinds whose bytes travel in the preview JSON; media and PDF are
|
|
75
|
+
// fetched by the browser from the raw URL instead.
|
|
76
|
+
const TEXT_PREVIEW_KINDS = new Set(['markdown', 'code', 'text']);
|
|
77
|
+
// An upload's URL names its bytes (the sha256), so a kept answer stays right.
|
|
78
|
+
const UPLOAD_CONTENT_CACHE_CONTROL = 'private, max-age=31536000, immutable';
|
|
74
79
|
|
|
75
80
|
export function resolveOpenAIReasoningEffort(explicitReasoningEffort = '', model = '') {
|
|
76
81
|
const explicit = String(explicitReasoningEffort || '').trim().toLowerCase();
|
|
@@ -1610,57 +1615,6 @@ export function cleanupPersistedGeneratedImagesForAssistantResponse({
|
|
|
1610
1615
|
}
|
|
1611
1616
|
}
|
|
1612
1617
|
|
|
1613
|
-
function serveFileWithRangeSupport(req, res, filePath, meta, { safeName, cacheDelete = null } = {}) {
|
|
1614
|
-
const fileSize = meta.size;
|
|
1615
|
-
const name = safeName || path.basename(filePath).replace(/"/g, '');
|
|
1616
|
-
const rangeHeader = req.headers['range'];
|
|
1617
|
-
|
|
1618
|
-
res.setHeader('Accept-Ranges', 'bytes');
|
|
1619
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
1620
|
-
// Neutralize browser-executable types (HTML/SVG/XML), force nosniff, and
|
|
1621
|
-
// sandbox the response so a worker-written workspace file can't run as script
|
|
1622
|
-
// on this origin. Media stays inline for preview; everything else downloads.
|
|
1623
|
-
applySafeServedContentHeaders(res, meta.contentType, { fileName: name });
|
|
1624
|
-
|
|
1625
|
-
const onStreamError = (error) => {
|
|
1626
|
-
if (cacheDelete) cacheDelete(filePath);
|
|
1627
|
-
if (res.headersSent) { res.destroy(error); return; }
|
|
1628
|
-
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
|
|
1629
|
-
res.status(404).json({ error: 'File not found' });
|
|
1630
|
-
return;
|
|
1631
|
-
}
|
|
1632
|
-
res.status(500).json({ error: 'Failed to read file' });
|
|
1633
|
-
};
|
|
1634
|
-
|
|
1635
|
-
if (rangeHeader) {
|
|
1636
|
-
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
|
|
1637
|
-
if (!match) {
|
|
1638
|
-
res.setHeader('Content-Range', `bytes */${fileSize}`);
|
|
1639
|
-
res.status(416).end();
|
|
1640
|
-
return;
|
|
1641
|
-
}
|
|
1642
|
-
const start = parseInt(match[1], 10);
|
|
1643
|
-
const end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
|
|
1644
|
-
if (start > end || start >= fileSize || end >= fileSize) {
|
|
1645
|
-
res.setHeader('Content-Range', `bytes */${fileSize}`);
|
|
1646
|
-
res.status(416).end();
|
|
1647
|
-
return;
|
|
1648
|
-
}
|
|
1649
|
-
const chunkSize = end - start + 1;
|
|
1650
|
-
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
|
1651
|
-
res.setHeader('Content-Length', String(chunkSize));
|
|
1652
|
-
res.status(206);
|
|
1653
|
-
const stream = fs.createReadStream(filePath, { start, end });
|
|
1654
|
-
stream.on('error', onStreamError);
|
|
1655
|
-
stream.pipe(res);
|
|
1656
|
-
} else {
|
|
1657
|
-
res.setHeader('Content-Length', String(fileSize));
|
|
1658
|
-
const stream = fs.createReadStream(filePath);
|
|
1659
|
-
stream.on('error', onStreamError);
|
|
1660
|
-
stream.pipe(res);
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
1618
|
// Structural sanitizer for the workflow-progress digest a Claude session
|
|
1665
1619
|
// worker attaches to its background-task rows (docs/plans/workflow-progress-tree.md,
|
|
1666
1620
|
// Phase 2). The relay never trusts worker JSON: strings are trimmed and
|
|
@@ -1769,6 +1723,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1769
1723
|
fetchLinuxDirectoryEntries,
|
|
1770
1724
|
mapLinuxDirectoryEntry,
|
|
1771
1725
|
readWorkspaceFileMeta,
|
|
1726
|
+
// The cache readWorkspaceFileMeta fills. The routes drop an entry when a
|
|
1727
|
+
// read shows it is out of date; the name was used here without ever
|
|
1728
|
+
// being handed in, so each of those paths threw a ReferenceError.
|
|
1729
|
+
workspaceFileMetaCache,
|
|
1772
1730
|
resolveWorkspaceFilePath,
|
|
1773
1731
|
normalizeWorkspaceRelativePath,
|
|
1774
1732
|
previewLanguageForWorkspaceFile,
|
|
@@ -3408,9 +3366,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3408
3366
|
if (!file) return res.status(404).json({ error: 'Not found' });
|
|
3409
3367
|
const filePath = uploadPathForSha(sha256);
|
|
3410
3368
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Missing file on disk' });
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3369
|
+
serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
|
|
3370
|
+
safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
|
|
3371
|
+
cacheControl: UPLOAD_CONTENT_CACHE_CONTROL,
|
|
3372
|
+
});
|
|
3414
3373
|
});
|
|
3415
3374
|
|
|
3416
3375
|
app.get('/api/generated-image/:conversationId/:messageId/:imageId/content', auth, (req, res) => {
|
|
@@ -3483,6 +3442,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3483
3442
|
const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
|
|
3484
3443
|
if (!filePath || !normalizedPath) return res.status(400).json({ error: 'Invalid file path' });
|
|
3485
3444
|
|
|
3445
|
+
// The preview carries the version the download link is built from, so
|
|
3446
|
+
// it reads the file's metadata fresh, not from the short-lived cache.
|
|
3447
|
+
workspaceFileMetaCache.delete(filePath);
|
|
3486
3448
|
let meta = null;
|
|
3487
3449
|
try {
|
|
3488
3450
|
meta = readWorkspaceFileMeta(filePath);
|
|
@@ -3538,10 +3500,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3538
3500
|
size,
|
|
3539
3501
|
truncated,
|
|
3540
3502
|
previewBytes: contentBuffer.length,
|
|
3541
|
-
|
|
3503
|
+
mtimeMs: meta.mtimeMs ?? null,
|
|
3504
|
+
version: fileVersionToken(meta),
|
|
3505
|
+
rawUrl: withFileVersion(`${remotePath}/api/files/${normalizedWebPath.split('/').map((part) => encodeURIComponent(part)).join('/')}${scopeSuffix}`, fileVersionToken(meta)),
|
|
3542
3506
|
};
|
|
3543
3507
|
|
|
3544
|
-
if (kind
|
|
3508
|
+
if (TEXT_PREVIEW_KINDS.has(kind)) {
|
|
3545
3509
|
payload.content = contentBuffer.toString('utf8');
|
|
3546
3510
|
}
|
|
3547
3511
|
|
|
@@ -3917,6 +3881,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3917
3881
|
const filePath = normalizeLinuxAbsolutePath(requestedPath);
|
|
3918
3882
|
if (!filePath) return res.status(400).json({ error: 'Invalid Linux file path' });
|
|
3919
3883
|
|
|
3884
|
+
// Fresh metadata: the download link's version comes from this answer.
|
|
3885
|
+
workspaceFileMetaCache.delete(filePath);
|
|
3920
3886
|
let meta = null;
|
|
3921
3887
|
try {
|
|
3922
3888
|
meta = readWorkspaceFileMeta(filePath);
|
|
@@ -3969,10 +3935,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3969
3935
|
size,
|
|
3970
3936
|
truncated,
|
|
3971
3937
|
previewBytes: contentBuffer.length,
|
|
3972
|
-
|
|
3938
|
+
mtimeMs: meta.mtimeMs ?? null,
|
|
3939
|
+
version: fileVersionToken(meta),
|
|
3940
|
+
rawUrl: withFileVersion(`${remotePath}/api/drives/file?path=${encodeURIComponent(filePath)}`, fileVersionToken(meta)),
|
|
3973
3941
|
};
|
|
3974
3942
|
|
|
3975
|
-
if (kind
|
|
3943
|
+
if (TEXT_PREVIEW_KINDS.has(kind)) {
|
|
3976
3944
|
payload.content = contentBuffer.toString('utf8');
|
|
3977
3945
|
}
|
|
3978
3946
|
|
|
@@ -3989,6 +3957,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3989
3957
|
return res.status(400).json({ error: 'Invalid drive file path' });
|
|
3990
3958
|
}
|
|
3991
3959
|
|
|
3960
|
+
// Fresh metadata: the download link's version comes from this answer.
|
|
3961
|
+
workspaceFileMetaCache.delete(filePath);
|
|
3992
3962
|
let meta = null;
|
|
3993
3963
|
try {
|
|
3994
3964
|
meta = readWorkspaceFileMeta(filePath);
|
|
@@ -4043,10 +4013,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4043
4013
|
size,
|
|
4044
4014
|
truncated,
|
|
4045
4015
|
previewBytes: contentBuffer.length,
|
|
4046
|
-
|
|
4016
|
+
mtimeMs: meta.mtimeMs ?? null,
|
|
4017
|
+
version: fileVersionToken(meta),
|
|
4018
|
+
rawUrl: withFileVersion(`${remotePath}/api/drives/file?path=${encodeURIComponent(normalizedWebPath)}`, fileVersionToken(meta)),
|
|
4047
4019
|
};
|
|
4048
4020
|
|
|
4049
|
-
if (kind
|
|
4021
|
+
if (TEXT_PREVIEW_KINDS.has(kind)) {
|
|
4050
4022
|
payload.content = contentBuffer.toString('utf8');
|
|
4051
4023
|
}
|
|
4052
4024
|
|
|
@@ -5147,7 +5119,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5147
5119
|
// in either must reach the status payload (composer gate, bubble
|
|
5148
5120
|
// Cancel), so it counts as a change like the hold fields do.
|
|
5149
5121
|
|| (stored.supported === true) !== (steering.supported === true)
|
|
5150
|
-
|| storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n')
|
|
5122
|
+
|| storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n')
|
|
5123
|
+
// The running CLI's compaction window (context-usage modal).
|
|
5124
|
+
|| JSON.stringify(stored.autoCompactWindow ?? null) !== JSON.stringify(steering.autoCompactWindow ?? null);
|
|
5151
5125
|
if (workerForSteering && changed) {
|
|
5152
5126
|
sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
|
|
5153
5127
|
// Pushed rather than left for the clients' status poll: the
|
|
@@ -7,8 +7,9 @@ import path from 'path';
|
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
9
|
import { createSdkSessionSyncService } from '../services/sdk-session-sync-service.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { serveFileWithRangeSupport } from '../services/file-serving.mjs';
|
|
11
11
|
import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
|
|
12
|
+
import { MESSAGE_PIN_LIMIT, buildPinList } from '../services/message-pin-list.mjs';
|
|
12
13
|
import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
|
|
13
14
|
import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
|
|
14
15
|
import { CLAUDE_ATTRIBUTION_MODES, DEFAULT_CLAUDE_ATTRIBUTION_MODE, claudeAttributionModelLabel, claudeAttributionTrailer, normalizeClaudeAttributionMode, resolveClaudeAttributionMode } from '../../shared/claude-attribution.mjs';
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
withClaudeUltracodeTier,
|
|
22
23
|
} from '../services/provider-reasoning-effort.mjs';
|
|
23
24
|
import { mapUsageSnapshotRow, fetchUsageSummaryPromise } from '../services/usage-snapshot-helpers.mjs';
|
|
24
|
-
import { readStoredClaudeContextUsage } from '../services/claude-context-usage.mjs';
|
|
25
|
+
import { readStoredClaudeContextUsage, resolveAutoCompactWindowStatus } from '../services/claude-context-usage.mjs';
|
|
25
26
|
import { buildContextUsageView } from '../services/context-usage-view.mjs';
|
|
26
27
|
import { cleanupGeneratedImagesForConversation as cleanupGeneratedImagesForConversationDefault } from '../services/generated-image-cleanup-service.mjs';
|
|
27
28
|
import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
|
|
@@ -88,6 +89,9 @@ const SESSION_WORKER_STATUS_QUEUE_STATES = Object.freeze(['pending', 'processing
|
|
|
88
89
|
// Registry statuses that mean "this worker is currently running" (same triple
|
|
89
90
|
// as countActiveCliWorkers in server-runtime.mjs).
|
|
90
91
|
const SESSION_WORKER_ACTIVE_STATUSES = Object.freeze(['starting', 'ready', 'processing']);
|
|
92
|
+
// A shared attachment's URL names its bytes (sha256 or a fixed generated-image
|
|
93
|
+
// path), so a kept answer stays right.
|
|
94
|
+
const SHARED_CONTENT_CACHE_CONTROL = 'private, max-age=31536000, immutable';
|
|
91
95
|
|
|
92
96
|
function normalizeWorkerStatusText(value, fallback = null) {
|
|
93
97
|
const text = String(value || '').trim();
|
|
@@ -1834,6 +1838,7 @@ export function buildConversationMessages({
|
|
|
1834
1838
|
attachments: message?.attachments || [],
|
|
1835
1839
|
mode: message?.mode || undefined,
|
|
1836
1840
|
hiddenFromShares: Number(message?.hidden_from_shares || 0) === 1,
|
|
1841
|
+
pinned: !!message?.pinned_at,
|
|
1837
1842
|
timestamp: message?.timestamp,
|
|
1838
1843
|
sourceMessageId,
|
|
1839
1844
|
executedProvider: message?.executed_provider || message?.executedProvider || undefined,
|
|
@@ -2218,6 +2223,23 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2218
2223
|
AND (id = ? OR response_message_id = ?)
|
|
2219
2224
|
LIMIT 1
|
|
2220
2225
|
`);
|
|
2226
|
+
function listConversationPins(conversationId) {
|
|
2227
|
+
return buildPinList(stmts.listPinnedMessages?.all?.(conversationId) || []);
|
|
2228
|
+
}
|
|
2229
|
+
// Orders the pin lists a browser receives. A conversation load that left
|
|
2230
|
+
// before a pin change can arrive after the change's own socket event; with
|
|
2231
|
+
// the revision the browser can tell the older list from the newer one. In
|
|
2232
|
+
// memory only: a restart starts above every revision handed out before.
|
|
2233
|
+
const pinRevisionFloor = Date.now();
|
|
2234
|
+
const pinRevisionByConversation = new Map();
|
|
2235
|
+
function conversationPinRevision(conversationId) {
|
|
2236
|
+
return pinRevisionByConversation.get(conversationId) || pinRevisionFloor;
|
|
2237
|
+
}
|
|
2238
|
+
function bumpConversationPinRevision(conversationId) {
|
|
2239
|
+
const next = Math.max(Date.now(), conversationPinRevision(conversationId) + 1);
|
|
2240
|
+
pinRevisionByConversation.set(conversationId, next);
|
|
2241
|
+
return next;
|
|
2242
|
+
}
|
|
2221
2243
|
const listSessionWorkerQueueRows = db.prepare(`
|
|
2222
2244
|
SELECT id, conversation_id, runtime_session_id, owner_sdk_session_id, status
|
|
2223
2245
|
FROM queue
|
|
@@ -2712,7 +2734,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2712
2734
|
});
|
|
2713
2735
|
messages = messages.map((message) => {
|
|
2714
2736
|
const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
|
|
2715
|
-
|
|
2737
|
+
// Pins are the owner's bookmarks: a public share never learns of them.
|
|
2738
|
+
const { pinned: _pinned, ...unpinnedMessage } = message;
|
|
2739
|
+
const withSource = sourceMessageId ? { ...unpinnedMessage, sourceMessageId } : unpinnedMessage;
|
|
2716
2740
|
// A public share never shows another relay's address or ids.
|
|
2717
2741
|
const nextMessage = withSource.origin
|
|
2718
2742
|
? { ...withSource, origin: publicRemoteRelayOrigin(withSource.origin) }
|
|
@@ -3212,6 +3236,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3212
3236
|
return {
|
|
3213
3237
|
snapshot: stored.snapshot,
|
|
3214
3238
|
contextUsage: stored.contextUsage,
|
|
3239
|
+
modelContextLimit: stored.modelContextLimit,
|
|
3215
3240
|
eventsPath: null,
|
|
3216
3241
|
error: stored.snapshot
|
|
3217
3242
|
? null
|
|
@@ -3231,6 +3256,15 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3231
3256
|
const conversationRow = resolveConversationByIdOrSdkSessionId(
|
|
3232
3257
|
String(runtimeSession?.conversation_id || '').trim() || lookupId,
|
|
3233
3258
|
);
|
|
3259
|
+
const autoCompactWindow = parseAutoCompactWindow(conversationRow?.auto_compact_window);
|
|
3260
|
+
// Only the local Claude worker runs a CLI whose window the slider sets;
|
|
3261
|
+
// it reports that CLI on its heartbeat's steering snapshot.
|
|
3262
|
+
const windowStatus = resolveAutoCompactWindowStatus({
|
|
3263
|
+
storedWindow: autoCompactWindow,
|
|
3264
|
+
steering: providerType === 'claude' && copilotSessionId
|
|
3265
|
+
? sessionWorkerRegistry?.getWorker?.(copilotSessionId)?.steering || null
|
|
3266
|
+
: null,
|
|
3267
|
+
});
|
|
3234
3268
|
|
|
3235
3269
|
return {
|
|
3236
3270
|
conversationId: lookupId,
|
|
@@ -3238,7 +3272,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3238
3272
|
// for the modal's refresh handle), so the id a preferences PATCH needs is
|
|
3239
3273
|
// reported separately.
|
|
3240
3274
|
resolvedConversationId: conversationRow?.id || null,
|
|
3241
|
-
autoCompactWindow
|
|
3275
|
+
autoCompactWindow,
|
|
3276
|
+
activeAutoCompactWindow: windowStatus.activeAutoCompactWindow,
|
|
3277
|
+
autoCompactWindowDeferred: windowStatus.autoCompactWindowDeferred,
|
|
3278
|
+
modelContextLimit: parsed.modelContextLimit ?? null,
|
|
3242
3279
|
thinkingEnabled: parseThinkingEnabled(conversationRow?.thinking_enabled),
|
|
3243
3280
|
thinkingDisplay: parseThinkingDisplay(conversationRow?.thinking_display),
|
|
3244
3281
|
// What this folder's Claude commits say (the 🧠 modal's override row).
|
|
@@ -3587,6 +3624,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3587
3624
|
cloud: conversationCloudPayload(conv, runtimeSession),
|
|
3588
3625
|
messages: history.messages,
|
|
3589
3626
|
pageInfo: history.pageInfo,
|
|
3627
|
+
// A re-import can move a pin onto a new message id.
|
|
3628
|
+
pins: listConversationPins(conversationId),
|
|
3629
|
+
pinsRevision: conversationPinRevision(conversationId),
|
|
3590
3630
|
refreshed: true,
|
|
3591
3631
|
});
|
|
3592
3632
|
});
|
|
@@ -3764,20 +3804,14 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3764
3804
|
if (!file) return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3765
3805
|
const filePath = uploadPathForSha(sha256);
|
|
3766
3806
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Missing file on disk' });
|
|
3767
|
-
// Unauthenticated share viewers:
|
|
3768
|
-
//
|
|
3769
|
-
|
|
3807
|
+
// Unauthenticated share viewers: the serving helper neutralizes executable
|
|
3808
|
+
// types (HTML/SVG) and forces nosniff + sandbox so an attachment can't run
|
|
3809
|
+
// as script on this origin; ranges let a shared video seek.
|
|
3770
3810
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
if (!res.headersSent) {
|
|
3775
|
-
res.status(500).json({ error: 'Failed to stream shared attachment' });
|
|
3776
|
-
return;
|
|
3777
|
-
}
|
|
3778
|
-
res.destroy();
|
|
3811
|
+
serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
|
|
3812
|
+
safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
|
|
3813
|
+
cacheControl: SHARED_CONTENT_CACHE_CONTROL,
|
|
3779
3814
|
});
|
|
3780
|
-
stream.pipe(res);
|
|
3781
3815
|
});
|
|
3782
3816
|
|
|
3783
3817
|
app.get('/api/shared/:token/generated-image/:messageId/:imageId/content', (req, res) => {
|
|
@@ -3819,18 +3853,10 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3819
3853
|
return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3820
3854
|
}
|
|
3821
3855
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Shared attachment not found' });
|
|
3822
|
-
res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
|
|
3823
3856
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
stream.on('error', () => {
|
|
3827
|
-
if (!res.headersSent) {
|
|
3828
|
-
res.status(500).json({ error: 'Failed to stream shared attachment' });
|
|
3829
|
-
return;
|
|
3830
|
-
}
|
|
3831
|
-
res.destroy();
|
|
3857
|
+
serveFileWithRangeSupport(req, res, filePath, { contentType: attachment.type }, {
|
|
3858
|
+
cacheControl: SHARED_CONTENT_CACHE_CONTROL,
|
|
3832
3859
|
});
|
|
3833
|
-
stream.pipe(res);
|
|
3834
3860
|
});
|
|
3835
3861
|
|
|
3836
3862
|
// GET /api/conversation/:id — get paginated conversation history
|
|
@@ -3987,6 +4013,9 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3987
4013
|
backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
|
|
3988
4014
|
usageLimitPause: usageLimitPauseService?.getPause?.(resolvedConversationId) || null,
|
|
3989
4015
|
previews: listConversationPreviews?.(resolvedConversationId) || [],
|
|
4016
|
+
// Every pin of the conversation, also those outside this history page.
|
|
4017
|
+
pins: listConversationPins(resolvedConversationId),
|
|
4018
|
+
pinsRevision: conversationPinRevision(resolvedConversationId),
|
|
3990
4019
|
preferredRelayMode: preferences.preferredRelayMode,
|
|
3991
4020
|
preferredModel: preferences.preferredModel,
|
|
3992
4021
|
preferredReasoningEffort: preferences.preferredReasoningEffort,
|
|
@@ -4046,6 +4075,57 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4046
4075
|
});
|
|
4047
4076
|
});
|
|
4048
4077
|
|
|
4078
|
+
// Pin or unpin one message. Unlike share visibility this may change while
|
|
4079
|
+
// the message's turn runs (nothing public depends on it), and every open
|
|
4080
|
+
// client learns of it through the socket instead of reloading.
|
|
4081
|
+
app.patch('/api/conversation/:id/message/:messageId/pin', auth, (req, res) => {
|
|
4082
|
+
const conversationId = String(req.params.id || '').trim();
|
|
4083
|
+
const messageId = String(req.params.messageId || '').trim();
|
|
4084
|
+
if (!conversationId || !messageId) {
|
|
4085
|
+
return res.status(400).json({ error: 'Missing conversation or message id' });
|
|
4086
|
+
}
|
|
4087
|
+
if (typeof req.body?.pinned !== 'boolean') {
|
|
4088
|
+
return res.status(400).json({ error: 'pinned must be a boolean' });
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
const conv = stmts.getConvAnyStatus.get(conversationId);
|
|
4092
|
+
if (!conv || String(conv.status || '').trim().toLowerCase() === 'deleted') {
|
|
4093
|
+
return res.status(404).json({ error: 'Conversation not found' });
|
|
4094
|
+
}
|
|
4095
|
+
const message = stmts.getMessageByConversation?.get(messageId, conversationId) || null;
|
|
4096
|
+
if (!message) return res.status(404).json({ error: 'Message not found' });
|
|
4097
|
+
if (!stmts.setMessagePinnedAt || !stmts.countPinnedMessages) {
|
|
4098
|
+
return res.status(503).json({ error: 'Pinning messages is unavailable until the relay restarts' });
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
const pinned = req.body.pinned === true;
|
|
4102
|
+
const currentlyPinned = !!message.pinned_at;
|
|
4103
|
+
if (currentlyPinned !== pinned) {
|
|
4104
|
+
if (pinned && Number(stmts.countPinnedMessages.get(conversationId)?.cnt || 0) >= MESSAGE_PIN_LIMIT) {
|
|
4105
|
+
return res.status(409).json({
|
|
4106
|
+
error: `This conversation already has ${MESSAGE_PIN_LIMIT} pinned messages. Unpin one first.`,
|
|
4107
|
+
code: 'pin-limit',
|
|
4108
|
+
limit: MESSAGE_PIN_LIMIT,
|
|
4109
|
+
});
|
|
4110
|
+
}
|
|
4111
|
+
stmts.setMessagePinnedAt.run(pinned ? new Date().toISOString() : null, messageId, conversationId);
|
|
4112
|
+
}
|
|
4113
|
+
|
|
4114
|
+
const pins = listConversationPins(conversationId);
|
|
4115
|
+
if (currentlyPinned !== pinned) {
|
|
4116
|
+
const revision = bumpConversationPinRevision(conversationId);
|
|
4117
|
+
io?.emit?.('conversation_pins_updated', { conversationId, pins, revision });
|
|
4118
|
+
}
|
|
4119
|
+
return res.json({
|
|
4120
|
+
ok: true,
|
|
4121
|
+
conversationId,
|
|
4122
|
+
messageId,
|
|
4123
|
+
pinned,
|
|
4124
|
+
pins,
|
|
4125
|
+
pinsRevision: conversationPinRevision(conversationId),
|
|
4126
|
+
});
|
|
4127
|
+
});
|
|
4128
|
+
|
|
4049
4129
|
app.patch('/api/conversation/:id', auth, (req, res) => {
|
|
4050
4130
|
const conversationId = String(req.params.id || '').trim();
|
|
4051
4131
|
const result = persistConversationTitle({
|
|
@@ -5448,6 +5528,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
5448
5528
|
reconnectAttempts: runtimeState.cloudflaredTunnelState?.reconnectAttempts ?? 0,
|
|
5449
5529
|
connectedSince: runtimeState.cloudflaredTunnelState?.connectedSince ?? null,
|
|
5450
5530
|
lastError: runtimeState.cloudflaredTunnelState?.lastError ?? null,
|
|
5531
|
+
binaryMissing: runtimeState.cloudflaredTunnelState?.binaryMissing ?? false,
|
|
5451
5532
|
valid: runtimeState.cloudflaredTunnelState?.valid ?? true,
|
|
5452
5533
|
},
|
|
5453
5534
|
workerWebSocket: runtimeState.workerWebSocketStatus || null,
|