@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.
@@ -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
+ });
@@ -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 readRuntimeVersion() {
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
- const thumbnail = url.searchParams.get('variant') === 'thumbnail';
233
- const asset = thumbnail ? await attachmentThumbnail(image) : { buffer: image.buffer, mimeType: image.mimeType };
234
- sendJson(response, 200, { url: `data:${asset.mimeType};base64,${asset.buffer.toString('base64')}` });
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
- let stat;
243
- try { stat = fs.statSync(file.path); } catch { throw new RequestError(404, '电脑上的文件已不存在。'); }
244
- if (!stat.isFile() || stat.size !== file.size || stat.mtimeMs !== file.mtimeMs) {
245
- fileCache.delete(fileMatch[1]);
246
- throw new RequestError(409, '电脑上的文件已发生变化,请重新读取对话。');
247
- }
248
- if (stat.size > MAX_ATTACHMENT_BYTES) throw new RequestError(413, '暂只支持下载不超过 16 MiB 的文件。');
249
- sendJson(response, 200, { name: file.name, size: file.size, dataBase64: fs.readFileSync(file.path).toString('base64') });
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() || stat.size > MAX_ATTACHMENT_BYTES) return null;
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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.26",
3
+ "version": "0.1.0-beta.27",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "scripts": {