fix: 修复会话归组和文件预览编码

This commit is contained in:
shiyue
2026-08-07 10:54:08 +08:00
parent 3ddf21af85
commit 5ad2839b2c
21 changed files with 765 additions and 42 deletions

264
server.js
View File

@@ -1790,6 +1790,36 @@ function readFilePreviewBuffer(filePath, maxBytes) {
}
}
function decodeTextPreview(buffer, options = {}) {
const truncated = options.truncated === true;
const decode = (encoding, bytes) => {
try {
return new TextDecoder(encoding, { fatal: true }).decode(bytes, { stream: truncated });
} catch {
return null;
}
};
if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
const content = decode('utf-8', buffer.subarray(3));
if (content !== null) return { encoding: 'utf-8-bom', content };
}
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
const content = decode('utf-16le', buffer.subarray(2));
if (content !== null) return { encoding: 'utf-16le', content };
}
if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
const content = decode('utf-16be', buffer.subarray(2));
if (content !== null) return { encoding: 'utf-16be', content };
}
const utf8 = decode('utf-8', buffer);
if (utf8 !== null) return { encoding: 'utf-8', content: utf8 };
const gb18030 = decode('gb18030', buffer);
if (gb18030 !== null) return { encoding: 'gb18030', content: gb18030 };
return { encoding: 'gb18030', content: new TextDecoder('gb18030').decode(buffer) };
}
const COMPOSER_COMMANDS = [
{ name: '/clear', description: '清除当前会话', insertion: '/clear ' },
{ name: '/model', description: '查看/切换模型', insertion: '/model ' },
@@ -2876,6 +2906,9 @@ function handleFileSystemReadApi(req, res, url) {
return jsonResponse(res, 415, { ok: false, message: '当前仅支持预览简单文本文件' });
}
const truncated = stat.size > FILE_BROWSER_MAX_PREVIEW_BYTES;
const decodedPreview = decodeTextPreview(previewBuffer, { truncated });
return jsonResponse(res, 200, {
ok: true,
sessionId,
@@ -2884,9 +2917,10 @@ function handleFileSystemReadApi(req, res, url) {
name: path.basename(target.realPath),
size: stat.size,
updatedAt: stat.mtime.toISOString(),
truncated: stat.size > FILE_BROWSER_MAX_PREVIEW_BYTES,
truncated,
previewBytes: previewBuffer.length,
content: previewBuffer.toString('utf8'),
encoding: decodedPreview.encoding,
content: decodedPreview.content,
});
}
@@ -3818,34 +3852,187 @@ function safeReadSessionJson(filePath, maxBytes, context = {}) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function jsonStringFieldFromPreview(text, key) {
const pattern = new RegExp(`"${key}"\\s*:\\s*("(?:(?:\\\\.)|[^"\\\\])*"|null)`);
const match = pattern.exec(text);
if (!match) return null;
if (match[1] === 'null') return null;
try {
return JSON.parse(match[1]);
} catch {
return null;
}
const SESSION_META_PREVIEW_TOP_LEVEL_KEYS = new Set([
'id',
'title',
'updated',
'updatedAt',
'created',
'pinnedAt',
'titleSource',
'createdFrom',
'hasUnread',
'agent',
'cwd',
]);
function isJsonWhitespace(ch) {
return ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t';
}
function jsonNestedStringFieldFromPreview(text, objectKey, key) {
const pattern = new RegExp(`"${objectKey}"\\s*:\\s*\\{[\\s\\S]{0,1200}?"${key}"\\s*:\\s*("(?:(?:\\\\.)|[^"\\\\])*"|null)`);
const match = pattern.exec(text);
if (!match) return null;
if (match[1] === 'null') return null;
try {
return JSON.parse(match[1]);
} catch {
return null;
}
function skipJsonWhitespace(text, index) {
let i = index;
while (i < text.length && isJsonWhitespace(text[i])) i += 1;
return i;
}
function jsonBooleanFieldFromPreview(text, key) {
const pattern = new RegExp(`"${key}"\\s*:\\s*(true|false)`);
const match = pattern.exec(text);
return match ? match[1] === 'true' : false;
function readJsonStringEnd(text, start) {
if (text[start] !== '"') return -1;
let escaped = false;
for (let i = start + 1; i < text.length; i += 1) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === '"') return i + 1;
}
return -1;
}
function readJsonContainerEnd(text, start) {
const first = text[start];
if (first !== '{' && first !== '[') return -1;
const stack = [first];
let inString = false;
let escaped = false;
for (let i = start + 1; i < text.length; i += 1) {
const ch = text[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === '{' || ch === '[') {
stack.push(ch);
continue;
}
if (ch === '}' || ch === ']') {
const open = stack.pop();
if ((ch === '}' && open !== '{') || (ch === ']' && open !== '[')) return -1;
if (stack.length === 0) return i + 1;
}
}
return -1;
}
function readJsonValueEnd(text, start) {
const i = skipJsonWhitespace(text, start);
const ch = text[i];
if (!ch) return -1;
if (ch === '"') return readJsonStringEnd(text, i);
if (ch === '{' || ch === '[') return readJsonContainerEnd(text, i);
if (text.startsWith('true', i)) return i + 4;
if (text.startsWith('false', i)) return i + 5;
if (text.startsWith('null', i)) return i + 4;
const numberMatch = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
return numberMatch ? i + numberMatch[0].length : -1;
}
function parseJsonTopLevelPrefixFields(text) {
const fields = {};
let i = skipJsonWhitespace(text, 0);
if (text[i] !== '{') return fields;
i += 1;
while (i < text.length) {
i = skipJsonWhitespace(text, i);
if (text[i] === '}') break;
if (text[i] !== '"') break;
const keyStart = i;
const keyEnd = readJsonStringEnd(text, keyStart);
if (keyEnd < 0) break;
let key = '';
try {
key = JSON.parse(text.slice(keyStart, keyEnd));
} catch {
break;
}
i = skipJsonWhitespace(text, keyEnd);
if (text[i] !== ':') break;
const valueStart = skipJsonWhitespace(text, i + 1);
const valueEnd = readJsonValueEnd(text, valueStart);
if (valueEnd < 0) break;
if (SESSION_META_PREVIEW_TOP_LEVEL_KEYS.has(key)) {
try {
fields[key] = JSON.parse(text.slice(valueStart, valueEnd));
} catch {}
}
i = skipJsonWhitespace(text, valueEnd);
if (text[i] === ',') {
i += 1;
continue;
}
if (text[i] === '}') break;
break;
}
return fields;
}
function previousNonWhitespaceChar(text, index) {
for (let i = index - 1; i >= 0; i -= 1) {
if (!isJsonWhitespace(text[i])) return text[i];
}
return '';
}
function parseJsonTopLevelSuffixFields(text) {
let end = text.length;
while (end > 0 && isJsonWhitespace(text[end - 1])) end -= 1;
if (text[end - 1] !== '}') return {};
const content = text.slice(0, end - 1);
const keyPattern = /"(id|title|updated|updatedAt|created|pinnedAt|titleSource|createdFrom|hasUnread|agent|cwd)"\s*:/g;
let match;
while ((match = keyPattern.exec(content))) {
const start = match.index;
const prev = previousNonWhitespaceChar(content, start);
if (prev && prev !== ',' && prev !== '{') continue;
try {
const parsed = JSON.parse(`{${content.slice(start)}}`);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
const fields = {};
for (const key of SESSION_META_PREVIEW_TOP_LEVEL_KEYS) {
if (Object.prototype.hasOwnProperty.call(parsed, key)) fields[key] = parsed[key];
}
return fields;
} catch {}
}
return {};
}
function readSessionPreviewTopLevelFields(preview) {
// 头尾分段独立解析,避免把截断边界两侧拼成不存在的 JSON 字段。
return {
...parseJsonTopLevelPrefixFields(preview.head || ''),
...parseJsonTopLevelSuffixFields(preview.tail || ''),
};
}
function previewStringField(fields, key) {
return typeof fields?.[key] === 'string' ? fields[key] : null;
}
function previewBooleanField(fields, key) {
return typeof fields?.[key] === 'boolean' ? fields[key] : false;
}
function previewCreatedFromKind(fields) {
const createdFrom = fields?.createdFrom;
return createdFrom && typeof createdFrom === 'object' && typeof createdFrom.kind === 'string'
? createdFrom.kind
: null;
}
function readSessionPreview(filePath, stat) {
@@ -3855,10 +4042,10 @@ function readSessionPreview(filePath, stat) {
try {
const head = Buffer.alloc(headSize);
fs.readSync(fd, head, 0, headSize, 0);
if (stat.size <= headSize) return head.toString('utf8');
if (stat.size <= headSize) return { head: head.toString('utf8'), tail: '' };
const tail = Buffer.alloc(tailSize);
fs.readSync(fd, tail, 0, tailSize, Math.max(0, stat.size - tailSize));
return `${head.toString('utf8')}\n${tail.toString('utf8')}`;
return { head: head.toString('utf8'), tail: tail.toString('utf8') };
} finally {
fs.closeSync(fd);
}
@@ -3889,17 +4076,18 @@ function loadSessionMetaFromFile(filePath) {
}
const preview = readSessionPreview(filePath, stat);
const cwd = jsonStringFieldFromPreview(preview, 'cwd') || '';
const previewFields = readSessionPreviewTopLevelFields(preview);
const cwd = previewStringField(previewFields, 'cwd') || '';
return {
id: jsonStringFieldFromPreview(preview, 'id') || fallbackId,
title: jsonStringFieldFromPreview(preview, 'title') || 'Untitled',
updated: jsonStringFieldFromPreview(preview, 'updated') || jsonStringFieldFromPreview(preview, 'updatedAt') || stat.mtime.toISOString(),
created: jsonStringFieldFromPreview(preview, 'created') || null,
pinnedAt: jsonStringFieldFromPreview(preview, 'pinnedAt') || null,
titleSource: jsonStringFieldFromPreview(preview, 'titleSource') || null,
createdFromKind: jsonNestedStringFieldFromPreview(preview, 'createdFrom', 'kind') || null,
hasUnread: jsonBooleanFieldFromPreview(preview, 'hasUnread'),
agent: normalizeAgent(jsonStringFieldFromPreview(preview, 'agent')),
id: previewStringField(previewFields, 'id') || fallbackId,
title: previewStringField(previewFields, 'title') || 'Untitled',
updated: previewStringField(previewFields, 'updated') || previewStringField(previewFields, 'updatedAt') || stat.mtime.toISOString(),
created: previewStringField(previewFields, 'created') || null,
pinnedAt: previewStringField(previewFields, 'pinnedAt') || null,
titleSource: previewStringField(previewFields, 'titleSource') || null,
createdFromKind: previewCreatedFromKind(previewFields),
hasUnread: previewBooleanField(previewFields, 'hasUnread'),
agent: normalizeAgent(previewStringField(previewFields, 'agent')),
cwd,
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
fileBytes: stat.size,