Files
goodbuddy/src/shared/magic-notes-contracts.ts
T
mesalogo 6edfc0046b feat: add magic note tags and a two-pane notes layout
Keep the notes list beside the open note, compact the detail view with a full-width sticky toolbar, and make Back to overview return to a full-width all-notes grid. Add manual note tags with AND filtering, a tag manager for rename, merge and delete, schema 51 tag tables, and tag support in the note Agent tools.
2026-09-30 19:04:47 +08:00

618 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 MAGIC_NOTE_TAG_MAX_LENGTH = 24
export const MAGIC_NOTE_MAX_TAGS = 10
/** Tags compare case-insensitively after trimming and NFC normalization. */
export function magicNoteTagKey(name: string): string {
return name.normalize('NFC').trim().toLowerCase()
}
export const magicNoteTagNameSchema = z
.string()
.transform((value) => value.normalize('NFC').trim().replace(/\s+/g, ' '))
.pipe(
z
.string()
.min(1, '标签不能为空')
.max(MAGIC_NOTE_TAG_MAX_LENGTH, `标签最长 ${MAGIC_NOTE_TAG_MAX_LENGTH} 个字`)
.refine((value) => !/[\p{Cc},,#]/u.test(value), '标签不能包含逗号、# 或控制字符')
)
/** Deduplicates case-insensitively and keeps the first spelling and order. */
export const magicNoteTagListSchema = z
.array(magicNoteTagNameSchema)
.transform((tags) => {
const seen = new Set<string>()
return tags.filter((tag) => {
const key = magicNoteTagKey(tag)
if (seen.has(key)) return false
seen.add(key)
return true
})
})
.pipe(z.array(z.string()).max(MAGIC_NOTE_MAX_TAGS, `每篇笔记最多 ${MAGIC_NOTE_MAX_TAGS} 个标签`))
export const magicNoteCreateSchema = z
.object({
title: z.string().trim().min(1).max(100),
content: magicNoteContentSchema.optional(),
source: magicNoteSourceSchema.optional(),
tags: magicNoteTagListSchema.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(),
tags: magicNoteTagListSchema.optional(),
expectedRevision: z.number().int().nonnegative()
})
.strict()
.refine(
(input) => input.title !== undefined || input.pinned !== undefined || input.tags !== undefined,
{ message: '没有可更新的笔记字段' }
)
export type MagicNoteUpdateInput = z.infer<typeof magicNoteUpdateSchema>
export const magicNoteTagIdSchema = magicNoteIdSchema
export const magicNoteTagRenameSchema = z
.object({
tagId: magicNoteTagIdSchema,
name: magicNoteTagNameSchema
})
.strict()
export type MagicNoteTagRenameInput = z.infer<typeof magicNoteTagRenameSchema>
export const magicNoteTagDeleteSchema = z
.object({ tagId: magicNoteTagIdSchema })
.strict()
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
/** Display names in the order the user added them. */
tags: string[]
revision: number
createdAt: string
updatedAt: string
}
export type MagicNoteTag = {
id: string
name: string
noteCount: number
}
export type MagicNoteTagRenameResult = {
tag: MagicNoteTag
/** True when the new name matched another tag and the two were merged. */
merged: boolean
}
export type MagicNoteDetail = MagicNoteSummary & {
entries: MagicNoteEntry[]
}
export type MagicNoteEntryCreateResult = MagicNoteDetail & {
createdEntryId: string
}
export type MagicNoteCreateResult = MagicNoteDetail & {
createdEntryId?: string
}
export type MagicNotesSnapshot = {
notes: MagicNoteSummary[]
tags: MagicNoteTag[]
}
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
}