diff --git a/bots/cormini/persona/blobs.ts b/bots/cormini/persona/blobs.ts index c5648626..86a9faaa 100644 --- a/bots/cormini/persona/blobs.ts +++ b/bots/cormini/persona/blobs.ts @@ -1,4 +1,5 @@ /** save_blob 工具的参数、权限检查与回执;附件存储由 WorkspaceBlobStore 提供。 */ +import { LOG_SCHEME, blobScheme } from 'cortico/core/blobs.ts'; import type { BlobStore, CoreApi, ToolDef } from 'cortico/core/types.ts'; export const SAVE_BLOB_DECL: Omit = { @@ -11,7 +12,7 @@ export const SAVE_BLOB_DECL: Omit = { parameters: { type: 'object', properties: { - handle: { type: 'string', description: 'The log: handle (a unique prefix of 8+ hex digits is enough).' }, + handle: { type: 'string', description: 'The log: handle as written in the [blob ...] line, e.g. log:1a2b3c4d (a unique prefix of 8+ hex digits after log: is enough).' }, path: { type: 'string', description: 'Where to keep it, relative to your workspace, with an extension.' }, }, required: ['handle', 'path'], @@ -28,9 +29,11 @@ export function saveBlobTool(deps: { return { ...SAVE_BLOB_DECL, handler: async (args, ctx) => { - const handle = String(args.handle ?? '').trim(); + const given = String(args.handle ?? '').trim(); const path = String(args.path ?? '').trim(); - if (!handle) return '[save failed] handle 不能为空'; + if (!given) return '[save failed] handle 不能为空'; + // 只给了哈希时按 log: 句柄解析 + const handle = blobScheme(given) ? given : `${LOG_SCHEME}${given}`; if (!path) return '[save failed] path 不能为空'; const denied = deps.guard?.(path, ctx.role) ?? null; if (denied) return `[save failed] ${denied}`; diff --git a/src/core/usage-log.ts b/src/core/usage-log.ts index 4ad1da16..96446c18 100644 --- a/src/core/usage-log.ts +++ b/src/core/usage-log.ts @@ -1,8 +1,32 @@ /** Append-only usage ledger. Failed writes remain queued and are reported to the operator. */ -import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import type { UsageRecord } from './types.ts'; +/** The ledger is read in chunks so its size is not bounded by V8's maximum string length. */ +const READ_CHUNK_BYTES = 4 * 1024 * 1024; + +/** Lines split at the newline byte, which never occurs inside a UTF-8 multibyte sequence. */ +function* fileLines(file: string): Generator { + const fd = openSync(file, 'r'); + try { + const chunk = Buffer.alloc(READ_CHUNK_BYTES); + let rest = Buffer.alloc(0); + for (let n = readSync(fd, chunk); n > 0; n = readSync(fd, chunk)) { + const buf = rest.length ? Buffer.concat([rest, chunk.subarray(0, n)]) : chunk.subarray(0, n); + let start = 0; + for (let end = buf.indexOf(10); end !== -1; end = buf.indexOf(10, start)) { + yield buf.toString('utf8', start, end); + start = end + 1; + } + rest = Buffer.from(buf.subarray(start)); + } + if (rest.length) yield rest.toString('utf8'); + } finally { + closeSync(fd); + } +} + export class UsageLog { private pending: UsageRecord[] = []; private error: string | null = null; @@ -45,7 +69,7 @@ export class UsageLog { const out: UsageRecord[] = []; const seen = new Set(); let damaged = 0; - for (const line of readFileSync(this.file, 'utf8').split(/\r?\n/)) { + for (const line of fileLines(this.file)) { if (!line.trim()) continue; let record: UsageRecord; try { record = JSON.parse(line) as UsageRecord; } catch { damaged++; continue; } diff --git a/src/web/client/features/extensions/index.ts b/src/web/client/features/extensions/index.ts index 5b8e0b86..78c24c7b 100644 --- a/src/web/client/features/extensions/index.ts +++ b/src/web/client/features/extensions/index.ts @@ -1283,7 +1283,8 @@ export function mountExtensions(ctx: FeatureContext): void { } if (signal.aborted) return; const restartHint = action === 'delete' || resultKind !== 'bot' ? ' ' + S.restartNeeded : ''; - message(owner, area, `${S.completed(actionLabel, name)}${version ? ' ' + S.version(version) : ''}${restartHint}`); + const activateHint = actionLabel === S.installedAction && resultKind === 'world' ? ' ' + S.worldActivateHint : ''; + message(owner, area, `${S.completed(actionLabel, name)}${version ? ' ' + S.version(version) : ''}${restartHint}${activateHint}`); updates.delete(name); if (!await load()) message(owner, area, S.completed(actionLabel, name) + ' ' + S.syncedFailed, true); } catch (error) { if (!signal.aborted) message(owner, area, errorText(error), true); } diff --git a/src/web/client/features/extensions/strings.de.ts b/src/web/client/features/extensions/strings.de.ts index 6bdb60d7..bf36aee2 100644 --- a/src/web/client/features/extensions/strings.de.ts +++ b/src/web/client/features/extensions/strings.de.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} von ${name} läuft…`, completed: (action: string, name: string) => `${action} von ${name} abgeschlossen.`, restartNeeded: 'Gilt nach dem Neustart des Prozesses.', + worldActivateHint: 'Ist die World nicht aktiv, aktiviere sie in der World-Übersicht.', syncedFailed: 'Vorgang abgeschlossen, aber die Liste ist nicht synchron. Aktualisiere, um es erneut zu versuchen.', filterPlaceholder: 'Filter: Name, Beschreibung, Stichwörter', diff --git a/src/web/client/features/extensions/strings.es-419.ts b/src/web/client/features/extensions/strings.es-419.ts index 33da4e23..df7f7fe2 100644 --- a/src/web/client/features/extensions/strings.es-419.ts +++ b/src/web/client/features/extensions/strings.es-419.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} en curso: ${name}…`, completed: (action: string, name: string) => `${action} completada: ${name}.`, restartNeeded: 'Surte efecto después de reiniciar el proceso.', + worldActivateHint: 'Si el World no está activo, actívalo en Resumen de Worlds.', syncedFailed: 'La operación se completó, pero la lista no está sincronizada. Actualiza para reintentar.', filterPlaceholder: 'Filtrar: nombre, descripción, palabras clave', diff --git a/src/web/client/features/extensions/strings.fr.ts b/src/web/client/features/extensions/strings.fr.ts index 10323fdd..7521fd29 100644 --- a/src/web/client/features/extensions/strings.fr.ts +++ b/src/web/client/features/extensions/strings.fr.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} de ${name} en cours…`, completed: (action: string, name: string) => `${action} de ${name} réussie.`, restartNeeded: 'Prend effet après le redémarrage du processus.', + worldActivateHint: "Si le World n'est pas actif, activez-le dans Vue d'ensemble des World.", syncedFailed: "Opération terminée, mais la liste n'est pas synchronisée. Actualisez pour réessayer.", filterPlaceholder: 'Filtrer : nom, description, mots-clés', diff --git a/src/web/client/features/extensions/strings.it.ts b/src/web/client/features/extensions/strings.it.ts index 51d1fbae..184328c5 100644 --- a/src/web/client/features/extensions/strings.it.ts +++ b/src/web/client/features/extensions/strings.it.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} di ${name} in corso…`, completed: (action: string, name: string) => `Operazione completata: ${action.toLowerCase()} di ${name}.`, restartNeeded: 'Ha effetto dopo il riavvio del processo.', + worldActivateHint: 'Se il World non è attivo, attivalo in Panoramica dei World.', syncedFailed: "Operazione completata, ma l'elenco non è sincronizzato. Aggiorna per riprovare.", filterPlaceholder: 'Filtra: nome, descrizione, parole chiave', diff --git a/src/web/client/features/extensions/strings.ja.ts b/src/web/client/features/extensions/strings.ja.ts index 96a2ace8..986135dd 100644 --- a/src/web/client/features/extensions/strings.ja.ts +++ b/src/web/client/features/extensions/strings.ja.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action}中:${name}…`, completed: (action: string, name: string) => `${action}が完了しました:${name}。`, restartNeeded: 'プロセスの再起動後に反映されます。', + worldActivateHint: '有効になっていない World は World 一覧で有効化してください。', syncedFailed: '操作は完了しましたが、一覧が同期されていません。更新してください。', filterPlaceholder: '絞り込み:パッケージ名、説明、キーワード', diff --git a/src/web/client/features/extensions/strings.ko.ts b/src/web/client/features/extensions/strings.ko.ts index b9984065..4c533925 100644 --- a/src/web/client/features/extensions/strings.ko.ts +++ b/src/web/client/features/extensions/strings.ko.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} 중: ${name}…`, completed: (action: string, name: string) => `${action} 완료: ${name}.`, restartNeeded: '프로세스를 재시작한 뒤 적용됩니다.', + worldActivateHint: '활성화되지 않은 World는 World 개요에서 활성화하세요.', syncedFailed: '작업은 끝났지만 목록이 동기화되지 않았습니다. 새로 고치세요.', filterPlaceholder: '필터: 패키지 이름, 설명, 키워드', diff --git a/src/web/client/features/extensions/strings.pt-BR.ts b/src/web/client/features/extensions/strings.pt-BR.ts index 8d61909e..47ae11d6 100644 --- a/src/web/client/features/extensions/strings.pt-BR.ts +++ b/src/web/client/features/extensions/strings.pt-BR.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} em andamento: ${name}…`, completed: (action: string, name: string) => `${action} concluída: ${name}.`, restartNeeded: 'Tem efeito após reiniciar o processo.', + worldActivateHint: 'Se o World não estiver ativo, ative-o em Visão geral dos Worlds.', syncedFailed: 'Operação concluída, mas a lista está dessincronizada. Atualize para tentar de novo.', filterPlaceholder: 'Filtrar: nome, descrição, palavras-chave', diff --git a/src/web/client/features/extensions/strings.ru.ts b/src/web/client/features/extensions/strings.ru.ts index ae93feb5..b034d046 100644 --- a/src/web/client/features/extensions/strings.ru.ts +++ b/src/web/client/features/extensions/strings.ru.ts @@ -66,6 +66,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} ${name}…`, completed: (action: string, name: string) => `${action} ${name}: готово.`, restartNeeded: 'Вступит в силу после перезапуска процесса.', + worldActivateHint: 'Если World не активен, активируйте его на странице «Обзор World».', syncedFailed: 'Операция выполнена, но список не синхронизирован. Обновите, чтобы повторить.', filterPlaceholder: 'Фильтр: имя, описание, ключевые слова', diff --git a/src/web/client/features/extensions/strings.ts b/src/web/client/features/extensions/strings.ts index 8f51c36e..45241062 100644 --- a/src/web/client/features/extensions/strings.ts +++ b/src/web/client/features/extensions/strings.ts @@ -70,6 +70,7 @@ const zh = { progress: (action: string, name: string) => `正在${action}:${name}…`, completed: (action: string, name: string) => `${action}成功:${name}。`, restartNeeded: '重启进程后生效。', + worldActivateHint: '未激活的 World 要到 World 总览里激活。', syncedFailed: '操作已完成,但列表未同步;请刷新。', filterPlaceholder: '筛选:包名、描述、关键字', @@ -308,6 +309,7 @@ export const en: typeof zh = { progress: (action: string, name: string) => `${action}: ${name}…`, completed: (action: string, name: string) => `${action} completed: ${name}.`, restartNeeded: 'Takes effect after the process restarts.', + worldActivateHint: 'If the World is not active, activate it in World overview.', syncedFailed: 'Operation completed, but the list is out of sync. Refresh to retry.', filterPlaceholder: 'Filter: name, description, keywords', diff --git a/src/web/client/features/extensions/strings.zh-Hant.ts b/src/web/client/features/extensions/strings.zh-Hant.ts index 3e9006bc..40ee052b 100644 --- a/src/web/client/features/extensions/strings.zh-Hant.ts +++ b/src/web/client/features/extensions/strings.zh-Hant.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `正在${action}:${name}…`, completed: (action: string, name: string) => `${action}成功:${name}。`, restartNeeded: '重新啟動處理程序後生效。', + worldActivateHint: '未啟用的 World 請到 World 總覽啟用。', syncedFailed: '操作已完成,但清單未同步;請重新整理。', filterPlaceholder: '篩選:套件名稱、描述、關鍵字', diff --git a/tests/core/usage-log.test.ts b/tests/core/usage-log.test.ts index cfebf569..80b62a2c 100644 --- a/tests/core/usage-log.test.ts +++ b/tests/core/usage-log.test.ts @@ -44,6 +44,18 @@ describe('UsageLog', () => { expect(readFileSync(file, 'utf8')).toBe(raw); }); + it('账本跨多个读取块时每行完整读出,多字节字符落在块边界上也不损坏', () => { + const label = '主意识'.repeat(700); + const lines = Array.from({ length: 1400 }, (_, i) => JSON.stringify({ ...rec(1), version: 2, recordId: `r${i}`, label })); + writeFileSync(file, lines.join('\r\n') + '\n'); + expect(readFileSync(file).byteLength).toBeGreaterThan(8 * 1024 * 1024); + const errors: string[] = []; + const all = new UsageLog(file, error => errors.push(error)).readAll(); + expect(all.map(r => r.recordId)).toEqual(lines.map((_, i) => `r${i}`)); + expect(all.every(r => r.label === label)).toBe(true); + expect(errors).toEqual([]); + }); + it('坏行跳过,不影响其它行', () => { const log = new UsageLog(file); log.append(rec(1)); diff --git a/tests/cormini/blobs.test.ts b/tests/cormini/blobs.test.ts index ffef1ad4..9d4c6eaa 100644 --- a/tests/cormini/blobs.test.ts +++ b/tests/cormini/blobs.test.ts @@ -64,6 +64,7 @@ describe('save_blob 与 read_file 的二进制回执', () => { const out = await tool.handler({ handle: 'log:abcdef01', path: 'blobs/stickers/cat.png' }, ctx); expect(out).toBe(`[saved] mem:blobs/stickers/cat.png image/png ${PNG.length} 字节`); expect(readFileSync(join(dir, 'blobs', 'stickers', 'cat.png')).equals(PNG)).toBe(true); + expect(await tool.handler({ handle: 'abcdef01', path: 'blobs/bare.png' }, ctx)).toBe(`[saved] mem:blobs/bare.png image/png ${PNG.length} 字节`); expect(await tool.handler({ handle: 'log:nope', path: 'blobs/x.png' }, ctx)).toContain('[save failed] 没有 log:nope'); expect(await tool.handler({ handle: 'log:abcdef01', path: '../x.png' }, ctx)).toContain('[save failed]'); });