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.
553 lines
16 KiB
TypeScript
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
|
|
}
|