@walkhi/code-relax 0.1.0-beta.26 → 0.1.0-beta.27
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/dist/shared/file-citations.cjs +82 -0
- package/dist/src/server.mjs +68 -25
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory();
|
|
3
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
4
|
+
else root.CodexFileCitations = api;
|
|
5
|
+
})(globalThis, function () {
|
|
6
|
+
const prefix = ':codex-file-citation{';
|
|
7
|
+
|
|
8
|
+
function parse(source, start) {
|
|
9
|
+
if (!source.startsWith(prefix, start)) return null;
|
|
10
|
+
const attributes = Object.create(null);
|
|
11
|
+
let position = start + prefix.length;
|
|
12
|
+
while (position < source.length) {
|
|
13
|
+
while (/[ \t]/.test(source[position] || '')) position++;
|
|
14
|
+
if (source[position] === '}') return { attributes, end: position + 1 };
|
|
15
|
+
const key = /^[A-Za-z_][\w-]*/.exec(source.slice(position));
|
|
16
|
+
if (!key) return null;
|
|
17
|
+
position += key[0].length;
|
|
18
|
+
const afterKey = position;
|
|
19
|
+
while (/[ \t]/.test(source[position] || '')) position++;
|
|
20
|
+
let value = '';
|
|
21
|
+
const assigned = source[position] === '=';
|
|
22
|
+
if (assigned) {
|
|
23
|
+
position++;
|
|
24
|
+
while (/[ \t]/.test(source[position] || '')) position++;
|
|
25
|
+
const quote = source[position];
|
|
26
|
+
if (quote === '"' || quote === "'") {
|
|
27
|
+
position++;
|
|
28
|
+
while (position < source.length && source[position] !== quote) {
|
|
29
|
+
if (/[\r\n]/.test(source[position])) return null;
|
|
30
|
+
if (source[position] === '\\' && source[position + 1] === quote) position++;
|
|
31
|
+
value += source[position++];
|
|
32
|
+
}
|
|
33
|
+
if (source[position++] !== quote) return null;
|
|
34
|
+
} else {
|
|
35
|
+
const match = /^[^\s{}"']+/.exec(source.slice(position));
|
|
36
|
+
if (!match) return null;
|
|
37
|
+
value = match[0];
|
|
38
|
+
position += value.length;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
attributes[key[0]] = value;
|
|
42
|
+
if ((assigned || position === afterKey) && source[position] !== '}' && !/[ \t]/.test(source[position] || '')) return null;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function positiveLine(value) {
|
|
48
|
+
const line = /^\d+$/.test(value || '') ? Number(value) : 0;
|
|
49
|
+
return Number.isSafeInteger(line) && line > 0 ? line : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function install(markdown) {
|
|
53
|
+
markdown.inline.ruler.before('text', 'codex_file_citation', (state, silent) => {
|
|
54
|
+
if (!state.env?.allowFileCitations || state.linkLevel > 0) return false;
|
|
55
|
+
const result = parse(state.src, state.pos);
|
|
56
|
+
const attributes = result?.attributes;
|
|
57
|
+
const path = attributes?.path?.trim();
|
|
58
|
+
if (!path || /[\0\r\n]/.test(path)) return false;
|
|
59
|
+
const local = /^(?:[a-z]:[\\/]|\/(?!\/))/i.test(path);
|
|
60
|
+
if (!local && !/^https?:\/\//i.test(path)) return false;
|
|
61
|
+
const line = local ? positiveLine(attributes.lineRangeStart ?? attributes.line_range_start) : null;
|
|
62
|
+
const end = local ? positiveLine(attributes.lineRangeEnd ?? attributes.line_range_end) : null;
|
|
63
|
+
const reference = local ? encodeURI(path) + (line ? `:${line}` : '') : path;
|
|
64
|
+
const href = markdown.normalizeLink(reference);
|
|
65
|
+
if (!markdown.validateLink(href)) return false;
|
|
66
|
+
if (!silent) {
|
|
67
|
+
const link = state.push('link_open', 'a', 1);
|
|
68
|
+
link.attrSet('href', href);
|
|
69
|
+
link.attrSet('title', path);
|
|
70
|
+
link.meta = { fileCitation: { path, line, end: line && end >= line ? end : null, purpose: attributes.purpose } };
|
|
71
|
+
const label = state.push('text', '', 0);
|
|
72
|
+
label.content = local ? path.split(/[\\/]/).filter(Boolean).at(-1) || path
|
|
73
|
+
: attributes.label || attributes.title || path;
|
|
74
|
+
state.push('link_close', 'a', -1);
|
|
75
|
+
}
|
|
76
|
+
state.pos = result.end;
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return Object.freeze({ install });
|
|
82
|
+
});
|
package/dist/src/server.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import vm from 'node:vm';
|
|
8
8
|
import sharp from 'sharp';
|
|
9
9
|
import { attachLanSocket } from './lan-socket-server.mjs';
|
|
10
|
-
import fileMentions from '../shared/file-mentions.cjs';
|
|
10
|
+
import fileMentions from '../shared/file-mentions.cjs';
|
|
11
|
+
import fileCitations from '../shared/file-citations.cjs';
|
|
11
12
|
import { validateInlineImage } from './message-images.mjs';
|
|
12
13
|
import { ThreadWriterConflictError } from './shared-app-server.mjs';
|
|
13
14
|
import { ManagedAppServer, managedAppServerUrl } from './managed-app-server.mjs';
|
|
@@ -29,7 +30,10 @@ import { TurnModelHistory } from './turn-model-history.mjs';
|
|
|
29
30
|
|
|
30
31
|
const turnModelHistory = new TurnModelHistory();
|
|
31
32
|
|
|
32
|
-
const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
|
|
33
|
+
const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
|
|
34
|
+
const MAX_FILE_DOWNLOAD_BYTES = 16 * 1024 * 1024;
|
|
35
|
+
// Each Base64 response fits the 8 MiB relay limit; full chunks have no padding.
|
|
36
|
+
const FILE_DOWNLOAD_CHUNK_BYTES = 3 * 1024 * 1024;
|
|
33
37
|
const MAX_CACHED_ATTACHMENTS = 32;
|
|
34
38
|
const MAX_CACHED_FILES = 64;
|
|
35
39
|
const MAX_CONTEXT_TAIL_BYTES = 4 * 1024 * 1024;
|
|
@@ -48,16 +52,25 @@ const ALLOWED_MODELS = new Set([
|
|
|
48
52
|
]);
|
|
49
53
|
const ALLOWED_THINKING = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
|
|
50
54
|
|
|
51
|
-
class RequestError extends Error {
|
|
55
|
+
class RequestError extends Error {
|
|
52
56
|
constructor(statusCode, message, details) {
|
|
53
57
|
super(message);
|
|
54
58
|
this.statusCode = statusCode;
|
|
55
59
|
this.details = details;
|
|
56
60
|
this.code = details?.code;
|
|
57
61
|
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function downloadOffset(rawOffset, size) {
|
|
65
|
+
const offset = Number(rawOffset);
|
|
66
|
+
if (!/^(0|[1-9]\d*)$/.test(rawOffset || '') || !Number.isSafeInteger(offset) ||
|
|
67
|
+
offset > size || offset % FILE_DOWNLOAD_CHUNK_BYTES !== 0) {
|
|
68
|
+
throw new RequestError(400, '无效的文件下载位置。');
|
|
69
|
+
}
|
|
70
|
+
return offset;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readRuntimeVersion() {
|
|
61
74
|
let directory = path.dirname(fileURLToPath(import.meta.url));
|
|
62
75
|
while (true) {
|
|
63
76
|
const manifest = readRecord(path.join(directory, 'package.json'));
|
|
@@ -77,7 +90,8 @@ const { codexDataRoot, stateRoot, sessionsRoot, uploadsRoot } = runtimePaths();
|
|
|
77
90
|
const markdownItScript = fs.readFileSync(path.join(webRoot, 'vendor', 'markdown-it.umd.min.js'));
|
|
78
91
|
const markdownSandbox = { atob };
|
|
79
92
|
vm.runInNewContext(markdownItScript.toString(), markdownSandbox);
|
|
80
|
-
const imageMarkdown = markdownSandbox.markdownit({ html: false });
|
|
93
|
+
const imageMarkdown = markdownSandbox.markdownit({ html: false });
|
|
94
|
+
fileCitations.install(imageMarkdown);
|
|
81
95
|
const attachmentCache = new Map();
|
|
82
96
|
const fileCache = new Map();
|
|
83
97
|
const fileCapabilitySecret = crypto.randomBytes(32);
|
|
@@ -229,9 +243,18 @@ async function handleRequest(request, response) {
|
|
|
229
243
|
} catch {}
|
|
230
244
|
}
|
|
231
245
|
if (!image) throw new RequestError(404, '这张图片已失效,无法从工作站重新读取。');
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
246
|
+
if (url.searchParams.get('variant') === 'thumbnail') {
|
|
247
|
+
const asset = await attachmentThumbnail(image);
|
|
248
|
+
sendJson(response, 200, { url: `data:${asset.mimeType};base64,${asset.buffer.toString('base64')}` });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const size = image.buffer.length;
|
|
252
|
+
if (size > MAX_ATTACHMENT_BYTES) throw new RequestError(413, '图片超过下载上限(16 MiB),无法下载。');
|
|
253
|
+
if (!size) throw new RequestError(400, '图片为空,无法下载。');
|
|
254
|
+
const offset = downloadOffset(url.searchParams.get('offset'), size);
|
|
255
|
+
const data = image.buffer.subarray(offset, Math.min(size, offset + FILE_DOWNLOAD_CHUNK_BYTES));
|
|
256
|
+
sendJson(response, 200, { name: image.name || '图片', size, offset, mimeType: image.mimeType,
|
|
257
|
+
dataBase64: data.toString('base64') });
|
|
235
258
|
return;
|
|
236
259
|
}
|
|
237
260
|
|
|
@@ -239,14 +262,34 @@ async function handleRequest(request, response) {
|
|
|
239
262
|
if (request.method === 'GET' && fileMatch) {
|
|
240
263
|
const file = fileCache.get(fileMatch[1]);
|
|
241
264
|
if (!file) throw new RequestError(404, '文件授权已失效,请重新读取对话。');
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
throw new RequestError(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
265
|
+
const offset = downloadOffset(url.searchParams.get('offset'), file.size);
|
|
266
|
+
let handle;
|
|
267
|
+
try { handle = fs.openSync(file.path, 'r'); }
|
|
268
|
+
catch (error) {
|
|
269
|
+
if (error.code === 'ENOENT') throw new RequestError(404, '电脑上的文件已不存在。');
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const validate = () => {
|
|
274
|
+
const stat = fs.fstatSync(handle);
|
|
275
|
+
if (!stat.isFile() || stat.size !== file.size || stat.mtimeMs !== file.mtimeMs) {
|
|
276
|
+
fileCache.delete(fileMatch[1]);
|
|
277
|
+
throw new RequestError(409, '电脑上的文件已发生变化,请重新读取对话。');
|
|
278
|
+
}
|
|
279
|
+
if (stat.size > MAX_FILE_DOWNLOAD_BYTES) throw new RequestError(413, '文件超过下载上限(16 MiB),无法下载。');
|
|
280
|
+
if (!stat.size) throw new RequestError(400, '文件为空,无法下载。');
|
|
281
|
+
};
|
|
282
|
+
validate();
|
|
283
|
+
const data = Buffer.alloc(Math.min(FILE_DOWNLOAD_CHUNK_BYTES, file.size - offset));
|
|
284
|
+
let bytesRead = 0;
|
|
285
|
+
while (bytesRead < data.length) {
|
|
286
|
+
const count = fs.readSync(handle, data, bytesRead, data.length - bytesRead, offset + bytesRead);
|
|
287
|
+
if (!count) throw new RequestError(409, '电脑上的文件读取不完整,请重新读取对话。');
|
|
288
|
+
bytesRead += count;
|
|
289
|
+
}
|
|
290
|
+
validate();
|
|
291
|
+
sendJson(response, 200, { name: file.name, size: file.size, offset, dataBase64: data.toString('base64') });
|
|
292
|
+
} finally { fs.closeSync(handle); }
|
|
250
293
|
return;
|
|
251
294
|
}
|
|
252
295
|
|
|
@@ -1056,8 +1099,8 @@ function markdownImageAssets(text) {
|
|
|
1056
1099
|
const assets = Object.create(null);
|
|
1057
1100
|
function visit(tokens) {
|
|
1058
1101
|
for (const token of tokens) {
|
|
1059
|
-
const value = token.type === 'image' ? token.attrGet('src')
|
|
1060
|
-
: token.type === 'link_open' ? token.attrGet('href') : null;
|
|
1102
|
+
const value = token.type === 'image' ? token.attrGet('src')
|
|
1103
|
+
: token.type === 'link_open' && !token.meta?.fileCitation ? token.attrGet('href') : null;
|
|
1061
1104
|
if (value && !Object.hasOwn(assets, value)) {
|
|
1062
1105
|
let localPath;
|
|
1063
1106
|
try { localPath = decodeURIComponent(value); } catch { continue; }
|
|
@@ -1069,10 +1112,10 @@ function markdownImageAssets(text) {
|
|
|
1069
1112
|
if (asset.available) assets[value] = asset;
|
|
1070
1113
|
}
|
|
1071
1114
|
}
|
|
1072
|
-
if (token.children) visit(token.children);
|
|
1115
|
+
if (token.children && token.type !== 'image') visit(token.children);
|
|
1073
1116
|
}
|
|
1074
1117
|
}
|
|
1075
|
-
visit(imageMarkdown.parse(text, {}));
|
|
1118
|
+
visit(imageMarkdown.parse(text, { allowFileCitations: true }));
|
|
1076
1119
|
return assets;
|
|
1077
1120
|
}
|
|
1078
1121
|
|
|
@@ -1093,10 +1136,10 @@ function markdownFileAssets(text) {
|
|
|
1093
1136
|
}
|
|
1094
1137
|
}
|
|
1095
1138
|
}
|
|
1096
|
-
if (token.children) visit(token.children);
|
|
1139
|
+
if (token.children && token.type !== 'image') visit(token.children);
|
|
1097
1140
|
}
|
|
1098
1141
|
}
|
|
1099
|
-
visit(imageMarkdown.parse(text, {}));
|
|
1142
|
+
visit(imageMarkdown.parse(text, { allowFileCitations: true }));
|
|
1100
1143
|
return assets;
|
|
1101
1144
|
}
|
|
1102
1145
|
|
|
@@ -1104,7 +1147,7 @@ function rememberLocalFile(filePath) {
|
|
|
1104
1147
|
try {
|
|
1105
1148
|
const resolved = path.resolve(filePath);
|
|
1106
1149
|
const stat = fs.statSync(resolved);
|
|
1107
|
-
if (!stat.isFile()
|
|
1150
|
+
if (!stat.isFile()) return null;
|
|
1108
1151
|
const id = crypto.createHmac('sha256', fileCapabilitySecret)
|
|
1109
1152
|
.update(resolved).update('\0').update(String(stat.size)).update('\0').update(String(stat.mtimeMs)).digest('hex');
|
|
1110
1153
|
if (!fileCache.has(id)) {
|