fix: 修复会话归组和文件预览编码
This commit is contained in:
264
server.js
264
server.js
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user