feat: localize interface and enrich magic notes

This commit is contained in:
lofyer
2026-08-11 12:01:58 +08:00
parent c8050f4a9a
commit aff3b82998
83 changed files with 10837 additions and 3289 deletions
+10 -9
View File
@@ -44,7 +44,7 @@ import {
type RuntimeSelectionRepairSettings
} from '../../shared/runtime-selection-contracts'
import {
MAGIC_NOTE_MAX_TOTAL_IMAGE_BYTES,
MAGIC_NOTE_MAX_NOTE_EMBED_BYTES,
type MagicNoteComment,
type MagicNoteDetail,
type MagicNoteEntry,
@@ -57,6 +57,7 @@ import {
import type { ComputerControlAuditEvent } from '../computer-control/audit'
import {
magicNoteChecklistItems,
magicNoteEmbeddedBytes,
magicNoteImageBytes,
magicNotePlainText,
magicNotePreview,
@@ -1917,7 +1918,7 @@ export class AssistantDatabase {
const now = new Date().toISOString()
database.exec('BEGIN IMMEDIATE')
try {
this.assertMagicNoteImageBudget(input.noteId, input.content)
this.assertMagicNoteEmbedBudget(input.noteId, input.content)
const noteResult = database
.prepare(
`UPDATE magic_notes
@@ -1943,7 +1944,7 @@ export class AssistantDatabase {
input.plainText,
now,
now,
magicNoteImageBytes(input.content)
magicNoteEmbeddedBytes(input.content)
)
this.syncMagicNoteTodos(
database,
@@ -1976,7 +1977,7 @@ export class AssistantDatabase {
const now = new Date().toISOString()
database.exec('BEGIN IMMEDIATE')
try {
this.assertMagicNoteImageBudget(
this.assertMagicNoteEmbedBudget(
existing.note_id,
input.content,
input.entryId
@@ -1993,7 +1994,7 @@ export class AssistantDatabase {
JSON.stringify(input.content),
input.plainText,
now,
magicNoteImageBytes(input.content),
magicNoteEmbeddedBytes(input.content),
input.entryId,
input.expectedRevision
)
@@ -4256,7 +4257,7 @@ export class AssistantDatabase {
}
}
private assertMagicNoteImageBudget(
private assertMagicNoteEmbedBudget(
noteId: string,
content: MagicNoteRichContent,
excludedEntryId?: string
@@ -4269,10 +4270,10 @@ export class AssistantDatabase {
)
.get(noteId, excludedEntryId ?? '') as { image_bytes: number }
if (
existing.image_bytes + magicNoteImageBytes(content) >
MAGIC_NOTE_MAX_TOTAL_IMAGE_BYTES
existing.image_bytes + magicNoteEmbeddedBytes(content) >
MAGIC_NOTE_MAX_NOTE_EMBED_BYTES
) {
throw new Error('一篇笔记中的图片总大小不能超过 8 MB')
throw new Error('一篇笔记中的图片、视频和附件总大小不能超过 64 MB')
}
}
+88
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
magicNoteChecklistItems,
magicNoteEmbeddedBytes,
magicNoteImageBytes,
magicNotePlainText,
setMagicNoteChecklistCompletion,
@@ -10,6 +11,13 @@ import {
const pngDataUrl = `data:image/png;base64,${Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]).toString('base64')}`
const mp4Bytes = Buffer.from([
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d
])
const mp4DataUrl = `data:video/mp4;base64,${mp4Bytes.toString('base64')}`
const attachmentBytes = Buffer.from('release notes')
const attachmentDataUrl =
`data:text/plain;base64,${attachmentBytes.toString('base64')}`
describe('magic note rich content', () => {
it('accepts bounded text formats and signature-checked local images', () => {
@@ -59,6 +67,86 @@ describe('magic note rich content', () => {
).toThrow('图片内容与声明的格式不一致')
})
it('accepts bounded font formats, local videos, and attachments', () => {
const content = validateMagicNoteRichContent({
version: 1,
ops: [
{
insert: '重点',
attributes: { size: 'large', color: '#e60000' }
},
{ insert: '\n' },
{
insert: {
localVideo: {
name: 'demo.mp4',
mimeType: 'video/mp4',
size: mp4Bytes.length,
dataUrl: mp4DataUrl
}
}
},
{
insert: {
attachment: {
name: 'notes.txt',
mimeType: 'text/plain',
size: attachmentBytes.length,
dataUrl: attachmentDataUrl
}
}
},
{ insert: '\n' }
]
})
expect(magicNotePlainText(content)).toBe(
'重点\n[视频:demo.mp4][附件:notes.txt]'
)
expect(magicNoteImageBytes(content)).toBe(0)
expect(magicNoteEmbeddedBytes(content)).toBe(
mp4Bytes.length + attachmentBytes.length
)
})
it('rejects spoofed videos and mismatched attachment metadata', () => {
expect(() =>
validateMagicNoteRichContent({
version: 1,
ops: [
{
insert: {
localVideo: {
name: 'demo.mp4',
mimeType: 'video/mp4',
size: attachmentBytes.length,
dataUrl: `data:video/mp4;base64,${attachmentBytes.toString('base64')}`
}
}
}
]
})
).toThrow('视频内容与声明的格式不一致')
expect(() =>
validateMagicNoteRichContent({
version: 1,
ops: [
{
insert: {
attachment: {
name: 'notes.txt',
mimeType: 'text/plain',
size: attachmentBytes.length + 1,
dataUrl: attachmentDataUrl
}
}
}
]
})
).toThrow('附件内容与声明的大小不一致')
})
it('rejects more than twelve images in one record', () => {
expect(() =>
validateMagicNoteRichContent({
+107 -6
View File
@@ -1,6 +1,9 @@
import {
MAGIC_NOTE_MAX_ATTACHMENT_BYTES,
MAGIC_NOTE_MAX_IMAGE_BYTES,
magicNoteImageDataBytes,
MAGIC_NOTE_MAX_VIDEO_BYTES,
MAGIC_NOTE_VIDEO_TYPES,
magicNoteDataBytes,
magicNoteRichContentSchema,
type MagicNoteRichContent
} from '../../shared/magic-notes-contracts'
@@ -52,6 +55,67 @@ function validateImage(dataUrl: string): void {
}
}
type EmbeddedFile = {
name: string
mimeType: string
size: number
dataUrl: string
}
function decodeEmbeddedFile(
file: EmbeddedFile,
maxBytes: number
): Buffer {
const separatorIndex = file.dataUrl.indexOf(',')
const prefix = file.dataUrl.slice(0, separatorIndex)
const payload = file.dataUrl.slice(separatorIndex + 1)
if (prefix !== `data:${file.mimeType};base64`) {
throw new Error('附件内容与声明的类型不一致')
}
const bytes = Buffer.from(payload, 'base64')
if (
bytes.length === 0 ||
bytes.length > maxBytes ||
bytes.length !== file.size
) {
throw new Error('附件内容与声明的大小不一致')
}
if (bytes.toString('base64') !== payload) {
throw new Error('附件数据格式无效')
}
return bytes
}
function validateVideo(file: EmbeddedFile): void {
const bytes = decodeEmbeddedFile(file, MAGIC_NOTE_MAX_VIDEO_BYTES)
const hasIsoBaseMediaSignature =
bytes.length >= 12 &&
bytes.subarray(4, 8).toString('ascii') === 'ftyp'
const signatureMatches =
(file.mimeType === 'video/mp4' && hasIsoBaseMediaSignature) ||
(file.mimeType === 'video/quicktime' && hasIsoBaseMediaSignature) ||
(file.mimeType === 'video/webm' &&
bytes.length >= 4 &&
bytes.subarray(0, 4).equals(
Buffer.from([0x1a, 0x45, 0xdf, 0xa3])
)) ||
(file.mimeType === 'video/ogg' &&
bytes.length >= 4 &&
bytes.subarray(0, 4).toString('ascii') === 'OggS')
if (
!MAGIC_NOTE_VIDEO_TYPES.includes(
file.mimeType as (typeof MAGIC_NOTE_VIDEO_TYPES)[number]
) ||
!signatureMatches
) {
throw new Error('视频内容与声明的格式不一致')
}
}
function validateAttachment(file: EmbeddedFile): void {
decodeEmbeddedFile(file, MAGIC_NOTE_MAX_ATTACHMENT_BYTES)
}
export function validateMagicNoteRichContent(
input: unknown
): MagicNoteRichContent {
@@ -61,9 +125,15 @@ export function validateMagicNoteRichContent(
continue
}
if (operation.attributes !== undefined) {
throw new Error('图片嵌入不支持行内格式')
throw new Error('嵌入内容不支持行内格式')
}
if ('image' in operation.insert) {
validateImage(operation.insert.image)
} else if ('localVideo' in operation.insert) {
validateVideo(operation.insert.localVideo)
} else {
validateAttachment(operation.insert.attachment)
}
validateImage(operation.insert.image)
}
return content
}
@@ -75,7 +145,11 @@ export function magicNotePlainText(
.map((operation) =>
typeof operation.insert === 'string'
? operation.insert
: '[图片]'
: 'image' in operation.insert
? '[图片]'
: 'localVideo' in operation.insert
? `[视频:${operation.insert.localVideo.name}]`
: `[附件:${operation.insert.attachment.name}]`
)
.join('')
.replace(/\n{3,}/g, '\n\n')
@@ -89,7 +163,29 @@ export function magicNoteImageBytes(
if (typeof operation.insert === 'string') {
return total
}
return total + magicNoteImageDataBytes(operation.insert.image)
return (
total +
('image' in operation.insert
? magicNoteDataBytes(operation.insert.image)
: 0)
)
}, 0)
}
export function magicNoteEmbeddedBytes(
content: MagicNoteRichContent
): number {
return content.ops.reduce((total, operation) => {
if (typeof operation.insert === 'string') {
return total
}
if ('image' in operation.insert) {
return total + magicNoteDataBytes(operation.insert.image)
}
if ('localVideo' in operation.insert) {
return total + magicNoteDataBytes(operation.insert.localVideo.dataUrl)
}
return total + magicNoteDataBytes(operation.insert.attachment.dataUrl)
}, 0)
}
@@ -119,7 +215,12 @@ export function magicNoteChecklistItems(
let sourceIndex = 0
for (const operation of content.ops) {
if (typeof operation.insert !== 'string') {
line += '[图片]'
line +=
'image' in operation.insert
? '[图片]'
: 'localVideo' in operation.insert
? `[视频:${operation.insert.localVideo.name}]`
: `[附件:${operation.insert.attachment.name}]`
continue
}
const segments = operation.insert.split(/(\n)/u)