Files
goodbuddy/src/shared/magic-notes-contracts.ts
T
mesalogo 1094433a7d feat: connect conversations with Magic Notes
Let users retain useful assistant replies or complete conversations without
leaving their chat workflow. Preview and edit captured text, choose an existing
note or atomically create a note with its first entry, and return to the source
conversation or message from saved records.

Add an application-scoped Notes workbar tab with title/body search and quick
text appends. Preserve drafts across conversation switches and guard closing,
replacement and disabling. Schema 48 stores optional source metadata without
rewriting file-backed bodies; source deletion retains the saved note. Older
clients cannot reopen an upgraded database without a pre-upgrade backup.

Release note: 可将助手回答或完整会话预览、编辑后加入魔法笔记,并从笔记返回原会话或消息。右侧快速笔记支持搜索和追加文字,切换会话保留草稿;采集本身不调用模型,保存后自动评论仍遵循已有设置。

Validation: isolated HEAD-based candidate passed 22 files / 930 focused tests,
plus 20 gateway tests after including its create-result fixture adjustment.
Real Electron App/preload/IPC/SQLite acceptance passed for all 502 historical
messages, exact-entry/source navigation, native keyboard input in a 214px
panel, reopen and source-deletion retention, with zero capture model calls.
Repository lint and web TypeScript passed. Cached whitespace and secret-pattern
checks passed; native-client changes and unrelated IPC extraction are excluded.

Validation limits: the isolated source tree reuses installed node_modules,
where a parallel task upgraded DSH from the committed 0.1.2-rc.1 to 0.1.7-rc.2.
Full typecheck reports 14 DSH errors in three unchanged files; the full-suite
attempt with --bail=1 also fails in deepseek-harness-host.test.ts. These are not
all-green repository or release-package results. Earlier execution-time full
suite failures and fixture repairs remain recorded in the feature progress doc.
2026-09-27 08:17:54 +08:00

553 lines
16 KiB
TypeScript

import { z } from 'zod'
export const MAGIC_NOTE_MAX_IMAGES = 12
export const MAGIC_NOTE_MAX_IMAGE_BYTES = 2 * 1024 * 1024
export const MAGIC_NOTE_MAX_TOTAL_IMAGE_BYTES = 8 * 1024 * 1024
export const MAGIC_NOTE_MAX_VIDEOS = 4
export const MAGIC_NOTE_MAX_VIDEO_BYTES = 16 * 1024 * 1024
export const MAGIC_NOTE_MAX_ATTACHMENTS = 8
export const MAGIC_NOTE_MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
export const MAGIC_NOTE_MAX_TOTAL_EMBED_BYTES = 32 * 1024 * 1024
export const MAGIC_NOTE_MAX_NOTE_EMBED_BYTES = 64 * 1024 * 1024
export const MAGIC_NOTE_MAX_TEXT_BYTES = 500 * 1024
export const MAGIC_NOTE_VIDEO_TYPES = [
'video/mp4',
'video/webm',
'video/ogg',
'video/quicktime'
] as const
export const MAGIC_NOTE_TEXT_COLORS = [
'#000000',
'#e60000',
'#ff9900',
'#ffff00',
'#008a00',
'#0066cc',
'#9933ff',
'#ffffff',
'#facccc',
'#ffebcc',
'#ffffcc',
'#cce8cc',
'#cce0f5',
'#ebd6ff',
'#bbbbbb',
'#f06666',
'#ffc266',
'#ffff66',
'#66b966',
'#66a3e0',
'#c285ff',
'#888888',
'#a10000',
'#b26b00',
'#b2b200',
'#006100',
'#0047b2',
'#6b24b2',
'#444444',
'#5c0000',
'#663d00',
'#666600',
'#003700',
'#002966',
'#3d1466'
] as const
export function magicNoteDataBytes(dataUrl: string): number {
const payload = dataUrl.slice(dataUrl.indexOf(',') + 1)
const padding = payload.endsWith('==')
? 2
: payload.endsWith('=')
? 1
: 0
return Math.floor((payload.length * 3) / 4) - padding
}
export const magicNoteImageDataBytes = magicNoteDataBytes
const magicNoteIdSchema = z.string().uuid()
const imageDataUrlSchema = z
.string()
.max(Math.ceil((MAGIC_NOTE_MAX_IMAGE_BYTES * 4) / 3) + 128)
.regex(
/^data:image\/(?:jpeg|png|gif|webp);base64,[A-Za-z0-9+/]+={0,2}$/,
'只支持本地 JPEG、PNG、GIF 或 WebP 图片'
)
const embeddedFileNameSchema = z
.string()
.trim()
.min(1)
.max(255)
.regex(/^[^<>:"/\\|?*]+$/u, '附件名称包含不支持的字符')
.refine(
(name) =>
[...name].every((character) => {
const code = character.charCodeAt(0)
return code >= 32 && code !== 127
}),
{ message: '附件名称包含不支持的字符' }
)
.refine((name) => name !== '.' && name !== '..', {
message: '附件名称无效'
})
const embeddedMimeTypeSchema = z
.string()
.trim()
.toLowerCase()
.max(127)
.regex(
/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u,
'附件类型无效'
)
function embeddedDataUrlSchema(maxBytes: number): z.ZodString {
return z
.string()
.max(Math.ceil((maxBytes * 4) / 3) + 256)
.regex(
/^data:[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*;base64,[A-Za-z0-9+/]+={0,2}$/u,
'只支持本地附件数据'
)
}
const embeddedFileFields = {
name: embeddedFileNameSchema,
mimeType: embeddedMimeTypeSchema,
size: z.number().int().positive()
}
const localVideoSchema = z
.object({
...embeddedFileFields,
mimeType: z.enum(MAGIC_NOTE_VIDEO_TYPES),
size: z.number().int().positive().max(MAGIC_NOTE_MAX_VIDEO_BYTES),
dataUrl: embeddedDataUrlSchema(MAGIC_NOTE_MAX_VIDEO_BYTES)
})
.strict()
const attachmentSchema = z
.object({
...embeddedFileFields,
size: z.number().int().positive().max(MAGIC_NOTE_MAX_ATTACHMENT_BYTES),
dataUrl: embeddedDataUrlSchema(MAGIC_NOTE_MAX_ATTACHMENT_BYTES)
})
.strict()
const magicNoteAttributesSchema = z
.object({
bold: z.literal(true).optional(),
italic: z.literal(true).optional(),
underline: z.literal(true).optional(),
strike: z.literal(true).optional(),
code: z.literal(true).optional(),
header: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(),
blockquote: z.literal(true).optional(),
'code-block': z.literal(true).optional(),
list: z
.enum(['ordered', 'bullet', 'checked', 'unchecked'])
.optional(),
align: z.enum(['center', 'right', 'justify']).optional(),
indent: z.number().int().min(1).max(8).optional(),
size: z.enum(['small', 'large', 'huge']).optional(),
color: z.enum(MAGIC_NOTE_TEXT_COLORS).optional()
})
.strict()
export const magicNoteRichContentSchema = z
.object({
version: z.literal(1),
ops: z
.array(
z
.object({
insert: z.union([
z.string().max(200_000),
z.object({ image: imageDataUrlSchema }).strict(),
z.object({ localVideo: localVideoSchema }).strict(),
z.object({ attachment: attachmentSchema }).strict()
]),
attributes: magicNoteAttributesSchema.optional()
})
.strict()
)
.min(1)
.max(10_000)
})
.strict()
.superRefine((content, context) => {
const encoder = new TextEncoder()
let textBytes = 0
const imageData: string[] = []
const videoData: string[] = []
const attachmentData: string[] = []
for (const operation of content.ops) {
if (typeof operation.insert === 'string') {
textBytes += encoder.encode(operation.insert).byteLength
} else if ('image' in operation.insert) {
imageData.push(operation.insert.image)
} else if ('localVideo' in operation.insert) {
videoData.push(operation.insert.localVideo.dataUrl)
} else {
attachmentData.push(operation.insert.attachment.dataUrl)
}
}
if (textBytes > MAGIC_NOTE_MAX_TEXT_BYTES) {
context.addIssue({
code: 'custom',
message: '每条记录的文字内容不能超过 500 KB'
})
}
if (imageData.length > MAGIC_NOTE_MAX_IMAGES) {
context.addIssue({
code: 'custom',
message: `每条记录最多包含 ${MAGIC_NOTE_MAX_IMAGES} 张图片`
})
}
const estimatedBytes = imageData.reduce(
(total, dataUrl) => total + magicNoteImageDataBytes(dataUrl),
0
)
if (estimatedBytes > MAGIC_NOTE_MAX_TOTAL_IMAGE_BYTES) {
context.addIssue({
code: 'custom',
message: '一篇笔记中的图片总大小不能超过 8 MB'
})
}
if (videoData.length > MAGIC_NOTE_MAX_VIDEOS) {
context.addIssue({
code: 'custom',
message: `每条记录最多包含 ${MAGIC_NOTE_MAX_VIDEOS} 个视频`
})
}
if (attachmentData.length > MAGIC_NOTE_MAX_ATTACHMENTS) {
context.addIssue({
code: 'custom',
message: `每条记录最多包含 ${MAGIC_NOTE_MAX_ATTACHMENTS} 个附件`
})
}
const embeddedBytes = [...imageData, ...videoData, ...attachmentData].reduce(
(total, dataUrl) => total + magicNoteDataBytes(dataUrl),
0
)
if (embeddedBytes > MAGIC_NOTE_MAX_TOTAL_EMBED_BYTES) {
context.addIssue({
code: 'custom',
message: '每条记录中的图片、视频和附件总大小不能超过 32 MB'
})
}
})
export type MagicNoteRichContent = z.infer<
typeof magicNoteRichContentSchema
>
const canvasDataUrlSchema = embeddedDataUrlSchema(Number.MAX_SAFE_INTEGER)
export const magicNoteCanvasContentSchema = z.object({
version: z.literal(2),
kind: z.literal('paged-canvas'),
pages: z.array(z.object({
id: z.string().min(1),
width: z.number().finite().positive().max(3000),
height: z.number().finite().positive().max(3000),
background: z.union([
z.object({ type: z.literal('template'), template: z.enum(['blank', 'ruled', 'grid', 'dot']) }).strict(),
z.object({ type: z.literal('pdf'), assetId: z.string().min(1), pageNumber: z.number().int().positive(), text: z.string().optional() }).strict()
]),
objects: z.array(z.record(z.string(), z.unknown())),
flowAuto: z.boolean().optional()
}).strict()).min(1).max(50),
flow: z.object({ version: z.literal(1).optional(), ops: z.array(z.object({
insert: z.union([z.string(), z.record(z.string(), z.unknown())]),
attributes: z.record(z.string(), z.unknown()).optional()
}).strict()).max(5000) }).strict().superRefine((flow, context) => {
const length = flow.ops.reduce((total, op) => total + (typeof op.insert === 'string' ? op.insert.length : 0), 0)
const last = flow.ops.at(-1)?.insert
const terminalNewline = typeof last === 'string' && last.endsWith('\n') ? 1 : 0
if (length - terminalNewline > 20_000) context.addIssue({ code: 'custom', message: '画布正文不能超过 20,000 字符(不含末尾换行)', path: ['ops'] })
}).optional(),
assets: z.array(z.object({
id: z.string().min(1), name: embeddedFileNameSchema,
mimeType: embeddedMimeTypeSchema, dataUrl: canvasDataUrlSchema
}).strict()),
preview: z.string().optional()
}).strict()
export type MagicNoteCanvasContent = z.infer<typeof magicNoteCanvasContentSchema>
export const magicNoteContentSchema = z.union([magicNoteRichContentSchema, magicNoteCanvasContentSchema])
export type MagicNoteContent = z.infer<typeof magicNoteContentSchema>
export type MagicNoteAnalysisInputMode = 'text' | 'images' | 'canvas-images' | 'text-fallback'
export const magicNoteSourceSchema = z.object({
kind: z.enum(['message', 'conversation']),
conversationId: magicNoteIdSchema,
messageIds: z.array(magicNoteIdSchema).min(1),
capturedAt: z.string().datetime(),
conversationTitle: z.string(),
projectId: magicNoteIdSchema.optional(),
projectName: z.string().optional()
}).strict().superRefine((source, context) => {
if (source.kind === 'message' && source.messageIds.length !== 1) {
context.addIssue({ code: 'custom', path: ['messageIds'], message: 'Message sources require exactly one message' })
}
if (new Set(source.messageIds).size !== source.messageIds.length) {
context.addIssue({ code: 'custom', path: ['messageIds'], message: 'Source message IDs must be unique' })
}
})
export type MagicNoteSource = z.infer<typeof magicNoteSourceSchema>
export const magicNoteSearchSchema = z.object({
query: z.string().trim().max(1000),
limit: z.number().int().min(1).max(200).optional()
}).strict()
export type MagicNoteSearchInput = z.infer<typeof magicNoteSearchSchema>
export const magicNoteCreateSchema = z
.object({
title: z.string().trim().min(1).max(100),
content: magicNoteContentSchema.optional(),
source: magicNoteSourceSchema.optional()
})
.strict()
.refine((input) => !input.source || !!input.content, {
path: ['content'], message: 'Source requires initial content'
})
export type MagicNoteCreateInput = z.infer<typeof magicNoteCreateSchema>
export const magicNoteUpdateSchema = z
.object({
noteId: magicNoteIdSchema,
title: z.string().trim().min(1).max(100).optional(),
pinned: z.boolean().optional(),
expectedRevision: z.number().int().nonnegative()
})
.strict()
.refine((input) => input.title !== undefined || input.pinned !== undefined, {
message: '没有可更新的笔记字段'
})
export type MagicNoteUpdateInput = z.infer<typeof magicNoteUpdateSchema>
export const magicNoteDeleteSchema = z
.object({
noteId: magicNoteIdSchema
})
.strict()
export const magicNoteEntryCreateSchema = z
.object({
noteId: magicNoteIdSchema,
content: magicNoteContentSchema,
source: magicNoteSourceSchema.optional()
})
.strict()
export type MagicNoteEntryCreateInput = z.infer<
typeof magicNoteEntryCreateSchema
>
export const magicNoteEntryUpdateSchema = z
.object({
entryId: magicNoteIdSchema,
content: magicNoteContentSchema,
expectedRevision: z.number().int().nonnegative()
})
.strict()
export type MagicNoteEntryUpdateInput = z.infer<
typeof magicNoteEntryUpdateSchema
>
export const magicNoteEntryDeleteSchema = z
.object({
entryId: magicNoteIdSchema
})
.strict()
export const magicNoteCommentDirectionSchema = z.enum([
'general',
'expand',
'polish',
'challenge',
'brainstorm'
])
export type MagicNoteCommentDirection = z.infer<
typeof magicNoteCommentDirectionSchema
>
export const magicNoteCommentFormatSchema = z.enum([
'combined',
'narrative',
'structured'
])
export type MagicNoteCommentFormat = z.infer<
typeof magicNoteCommentFormatSchema
>
export const magicNoteAnalysisOptionsSchema = z
.object({
requestId: z.string().uuid(),
direction: magicNoteCommentDirectionSchema,
format: magicNoteCommentFormatSchema,
canvasPageText: z.array(z.object({ pageId: z.string().min(1), text: z.string().max(20_001) }).strict()).max(8).optional(),
canvasImages: z.array(z.object({ pageId: z.string().min(1), dataUrl: z.string().regex(/^data:image\/(?:jpeg|png);base64,[A-Za-z0-9+/]+={0,2}$/) }).strict()).max(50).optional()
})
.strict()
export type MagicNoteAnalysisOptions = z.infer<
typeof magicNoteAnalysisOptionsSchema
>
export const magicNoteAnalyzeSchema = z
.object({
entryId: magicNoteIdSchema,
expectedRevision: z.number().int().nonnegative().optional(),
...magicNoteAnalysisOptionsSchema.shape
})
.strict()
.refine((input) => !(input.canvasImages?.length || input.canvasPageText?.length) || input.expectedRevision !== undefined, {
message: '画布截图或分页文字必须提供记录版本', path: ['expectedRevision']
})
export type MagicNoteEntryAnalysisOptions = MagicNoteAnalysisOptions & { expectedRevision?: number }
export const magicNoteDraftAnalyzeSchema = z
.object({
content: magicNoteContentSchema,
...magicNoteAnalysisOptionsSchema.shape
})
.strict()
export const magicTodoIdSchema = z
.object({
todoId: magicNoteIdSchema,
sourceEntryRevision: z.number().int().nonnegative().optional(),
...magicNoteAnalysisOptionsSchema.shape
})
.strict()
.refine((input) => !(input.canvasImages?.length || input.canvasPageText?.length) || input.sourceEntryRevision !== undefined, {
message: '画布截图或分页文字必须提供来源记录版本', path: ['sourceEntryRevision']
})
export type MagicTodoAnalysisOptions = MagicNoteAnalysisOptions & { sourceEntryRevision?: number }
export const magicTodoUpdateSchema = z
.object({
todoId: magicNoteIdSchema,
completed: z.boolean(),
expectedRevision: z.number().int().nonnegative()
})
.strict()
export type MagicTodoUpdateInput = z.infer<
typeof magicTodoUpdateSchema
>
export type MagicNoteCommentKind =
| 'narrative'
| 'summary'
| 'suggestion'
| 'warning'
export type MagicNoteComment = {
id: string
kind: MagicNoteCommentKind
content: string
direction?: MagicNoteCommentDirection
format?: MagicNoteCommentFormat
analyzedAt?: string
inputMode?: MagicNoteAnalysisInputMode
}
export type MagicNoteAnalysisStreamEvent = {
requestId: string
type: 'text'
delta: string
direction: MagicNoteCommentDirection
format: 'combined' | 'narrative'
}
export type MagicNoteEntry = {
id: string
noteId: string
content: MagicNoteContent
source?: MagicNoteSource
plainText: string
comments: MagicNoteComment[]
analyzedAt?: string
revision: number
createdAt: string
updatedAt: string
}
export type MagicNoteSummary = {
id: string
title: string
preview: string
entryCount: number
pinned: boolean
revision: number
createdAt: string
updatedAt: string
}
export type MagicNoteDetail = MagicNoteSummary & {
entries: MagicNoteEntry[]
}
export type MagicNoteEntryCreateResult = MagicNoteDetail & {
createdEntryId: string
}
export type MagicNoteCreateResult = MagicNoteDetail & {
createdEntryId?: string
}
export type MagicNotesSnapshot = {
notes: MagicNoteSummary[]
}
export type MagicTodoItem = {
id: string
noteId: string
entryId: string
noteTitle: string
sourceIndex: number
source: 'note'
title: string
instructions: string
completed: boolean
comments: MagicNoteComment[]
analyzedAt?: string
revision: number
createdAt: string
updatedAt: string
}
export type MagicTodoUpdateResult = {
todo: MagicTodoItem
note: MagicNoteDetail
}
export type MagicTodoStatus = {
incompleteCount: number
}
export type MagicTodosSnapshot = {
todos: MagicTodoItem[]
}
export type MagicNoteDraftAnalysis = {
id: string
comments: MagicNoteComment[]
analyzedAt: string
inputMode?: MagicNoteAnalysisInputMode
}
export type MagicNoteSearchResult = {
noteId: string
noteTitle: string
entryId?: string
content: string
updatedAt: string
}