feat: add remote channels and richer notes

This commit is contained in:
lofyer
2026-08-09 15:48:52 +08:00
parent 417a9fccb6
commit 6c891f3522
69 changed files with 13012 additions and 499 deletions
+24
View File
@@ -4,9 +4,23 @@ import { agentRuntimeSelectionSchema } from './runtime-selection-contracts'
export const assistantIdSchema = z.string().uuid()
export const workModeSchema = z.enum(['ask', 'plan', 'execute'])
export const interactiveWorkModes = ['ask', 'execute'] as const
export const projectKindSchema = z.enum(['user', 'channel'])
export const projectChannels = [
'weixin',
'wecom',
'dingtalk'
] as const
export const projectChannelSchema = z.enum(projectChannels)
export const projectChannelLabels: Record<ProjectChannel, string> = {
weixin: '微信 ClawBot',
wecom: '企业微信',
dingtalk: '钉钉'
}
export type WorkMode = z.infer<typeof workModeSchema>
export type InteractiveWorkMode = (typeof interactiveWorkModes)[number]
export type ProjectKind = z.infer<typeof projectKindSchema>
export type ProjectChannel = z.infer<typeof projectChannelSchema>
export function normalizeInteractiveWorkMode(
workMode: WorkMode | undefined
@@ -121,6 +135,14 @@ export const conversationSnapshotSchema = z
id: assistantIdSchema,
projectId: assistantIdSchema.optional(),
runtimeSelection: agentRuntimeSelectionSchema.optional(),
remote: z
.object({
channel: projectChannelSchema,
accountDisplay: z.string().trim().min(1).max(200),
conversationType: z.enum(['direct', 'group'])
})
.strict()
.optional(),
title: z.string().trim().min(1).max(200),
updatedAt: z.number().int().nonnegative(),
messages: z
@@ -184,6 +206,8 @@ export const conversationSnapshotsSchema = z
export type AssistantProject = ProjectCreateInput & {
id: string
kind: ProjectKind
channel?: ProjectChannel
status: 'active' | 'archived'
createdAt: string
updatedAt: string
@@ -11,6 +11,9 @@ describe('channel settings contracts', () => {
it('accepts bounded strict WeCom and DingTalk updates', () => {
expect(
channelSettingsApplySchema.parse({
weixin: {
enabled: false
},
wecom: {
enabled: true,
botId: ' bot-id ',
@@ -27,6 +30,9 @@ describe('channel settings contracts', () => {
}
})
).toEqual({
weixin: {
enabled: false
},
wecom: {
enabled: true,
botId: 'bot-id',
@@ -69,6 +75,13 @@ describe('channel settings contracts', () => {
it('models public credential source and runtime status without secrets', () => {
const snapshot = channelSettingsSnapshotSchema.parse({
weixin: {
enabled: true,
bindingConfigured: true,
source: 'encrypted',
accountDisplay: '微信用户 ****1234',
status: { state: 'running' }
},
wecom: {
enabled: true,
botId: 'bot-id',
+38 -3
View File
@@ -1,4 +1,8 @@
import { z } from 'zod'
import {
projectChannelSchema,
type ProjectChannel
} from './assistant-contracts'
export const CHANNEL_SETTINGS_LIMITS = {
maximumIdentifierLength: 256,
@@ -8,8 +12,12 @@ export const CHANNEL_SETTINGS_LIMITS = {
maximumWarningLength: 500
} as const
export const managedChannelSchema = z.enum(['wecom', 'dingtalk'])
export type ManagedChannel = z.infer<typeof managedChannelSchema>
export const managedChannelSchema = projectChannelSchema
export type ManagedChannel = ProjectChannel
export const credentialChannelSchema = z.enum(['wecom', 'dingtalk'])
export type CredentialChannel = z.infer<
typeof credentialChannelSchema
>
const identifierSchema = z
.string()
@@ -68,14 +76,27 @@ export type DingTalkChannelSettingsInput = z.infer<
typeof dingTalkChannelSettingsInputSchema
>
export const weixinChannelSettingsInputSchema = z
.object({
enabled: z.boolean()
})
.strict()
export type WeixinChannelSettingsInput = z.infer<
typeof weixinChannelSettingsInputSchema
>
export const channelSettingsApplySchema = z
.object({
weixin: weixinChannelSettingsInputSchema.optional(),
wecom: weComChannelSettingsInputSchema.optional(),
dingtalk: dingTalkChannelSettingsInputSchema.optional()
})
.strict()
.refine(
(input) => input.wecom !== undefined || input.dingtalk !== undefined,
(input) =>
input.weixin !== undefined ||
input.wecom !== undefined ||
input.dingtalk !== undefined,
'至少需要提供一个通道设置'
)
export type ChannelSettingsApply = z.infer<
@@ -147,8 +168,22 @@ export type DingTalkChannelSettings = z.infer<
typeof dingTalkChannelSettingsSchema
>
export const weixinChannelSettingsSchema = z
.object({
enabled: z.boolean(),
bindingConfigured: z.boolean(),
source: z.enum(['none', 'encrypted']),
accountDisplay: z.string().trim().min(1).max(64).optional(),
status: channelRuntimeStatusSchema
})
.strict()
export type WeixinChannelSettings = z.infer<
typeof weixinChannelSettingsSchema
>
export const channelSettingsSnapshotSchema = z
.object({
weixin: weixinChannelSettingsSchema,
wecom: weComChannelSettingsSchema,
dingtalk: dingTalkChannelSettingsSchema,
warning: z
+63 -2
View File
@@ -36,12 +36,24 @@ import {
type ExpertCreateInput,
type ExpertUpdateInput
} from './assistant-contracts'
import type {
MagicNoteDetail,
MagicNoteCreateInput,
MagicNoteEntryCreateInput,
MagicNoteEntryUpdateInput,
MagicNotesSnapshot,
MagicNoteUpdateInput,
MagicTodoCreateInput,
MagicTodoItem,
MagicTodosSnapshot,
MagicTodoUpdateInput
} from './magic-notes-contracts'
import type {
ChannelConnectionTestResult,
ChannelSettingsApply,
ChannelSettingsSnapshot,
CredentialChannel,
DingTalkChannelSettingsInput,
ManagedChannel,
WeComChannelSettingsInput
} from './channel-settings-contracts'
import type {
@@ -58,6 +70,12 @@ import type {
EmbeddingIndexStatus,
EmbeddingSettingsSnapshot
} from './embedding-contracts'
import type { WeixinBindingSnapshot } from './weixin-channel-contracts'
import type {
RemoteChannelActivity,
RemoteChannelApproval,
RemoteChannelApprovalDecision
} from './remote-channel-contracts'
import {
agentRuntimeSelectionSchema,
type AgentRuntimeSelection
@@ -948,9 +966,31 @@ export type DesktopApi = {
getSnapshot: () => Promise<ChannelSettingsSnapshot>
apply: (input: ChannelSettingsApply) => Promise<ChannelSettingsSnapshot>
testConnection: (
channel: ManagedChannel,
channel: CredentialChannel,
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
) => Promise<ChannelConnectionTestResult>
getWeixinBinding: () => Promise<WeixinBindingSnapshot>
startWeixinBinding: () => Promise<WeixinBindingSnapshot>
submitWeixinVerification: (
code: string
) => Promise<WeixinBindingSnapshot>
disconnectWeixin: () => Promise<WeixinBindingSnapshot>
onWeixinBindingChanged: (
listener: (snapshot: WeixinBindingSnapshot) => void
) => () => void
respondRemoteApproval: (
approvalId: string,
decision: RemoteChannelApprovalDecision
) => Promise<boolean>
getPendingRemoteApprovals: () => Promise<
RemoteChannelApproval[]
>
onRemoteApproval: (
listener: (approval: RemoteChannelApproval) => void
) => () => void
onRemoteActivity: (
listener: (activity: RemoteChannelActivity) => void
) => () => void
}
updates?: {
getSettings: () => Promise<ApplicationSettings>
@@ -1003,6 +1043,7 @@ export type DesktopApi = {
conversations: {
list: () => Promise<ConversationSnapshot[]>
replace: (conversations: ConversationSnapshot[]) => Promise<void>
onChanged: (listener: () => void) => () => void
}
workspace: {
getChanges: (projectId: string) => Promise<WorkspaceChanges>
@@ -1129,6 +1170,26 @@ export type DesktopApi = {
readClipboard: () => Promise<ContextAttachment>
remove: (contextId: string) => Promise<void>
}
magicNotes: {
list: (projectId?: string) => Promise<MagicNotesSnapshot>
get: (noteId: string) => Promise<MagicNoteDetail>
create: (input: MagicNoteCreateInput) => Promise<MagicNoteDetail>
update: (input: MagicNoteUpdateInput) => Promise<MagicNoteDetail>
remove: (noteId: string) => Promise<void>
createEntry: (
input: MagicNoteEntryCreateInput
) => Promise<MagicNoteDetail>
updateEntry: (
input: MagicNoteEntryUpdateInput
) => Promise<MagicNoteDetail>
removeEntry: (entryId: string) => Promise<MagicNoteDetail>
analyze: (entryId: string) => Promise<MagicNoteDetail>
listTodos: (projectId?: string) => Promise<MagicTodosSnapshot>
createTodo: (input: MagicTodoCreateInput) => Promise<MagicTodoItem>
updateTodo: (input: MagicTodoUpdateInput) => Promise<MagicTodoItem>
removeTodo: (todoId: string) => Promise<void>
analyzeTodo: (todoId: string) => Promise<MagicTodoItem>
}
knowledge: {
getSnapshot: (libraryId?: string) => Promise<KnowledgeSnapshot>
createLibrary: (
+26
View File
@@ -30,6 +30,17 @@ export const ipcChannels = {
channelSettingsGet: 'settings:channels:get',
channelSettingsApply: 'settings:channels:apply',
channelSettingsTest: 'settings:channels:test',
weixinBindingGet: 'settings:channels:weixin:binding:get',
weixinBindingStart: 'settings:channels:weixin:binding:start',
weixinBindingVerify: 'settings:channels:weixin:binding:verify',
weixinBindingDisconnect:
'settings:channels:weixin:binding:disconnect',
weixinBindingChanged:
'settings:channels:weixin:binding:changed',
remoteChannelApprovalRespond: 'channels:remote-approval:respond',
remoteChannelApprovalList: 'channels:remote-approval:list',
remoteChannelApprovalRequested: 'channels:remote-approval:requested',
remoteChannelActivity: 'channels:remote-activity',
applicationSettingsGet: 'settings:application:get',
applicationSettingsUpdate: 'settings:application:update',
versionCheck: 'application:update:check',
@@ -57,6 +68,7 @@ export const ipcChannels = {
projectsDelete: 'projects:delete',
conversationsList: 'conversations:list',
conversationsReplace: 'conversations:replace',
conversationsChanged: 'conversations:changed',
workspaceChangesGet: 'workspace:changes:get',
workspaceDirectoryList: 'workspace:directory:list',
workspaceFileRead: 'workspace:file:read',
@@ -108,6 +120,20 @@ export const ipcChannels = {
contextCaptureWindow: 'context:capture-window',
contextReadClipboard: 'context:read-clipboard',
contextRemove: 'context:remove',
magicNotesList: 'magic-notes:list',
magicNotesGet: 'magic-notes:get',
magicNotesCreate: 'magic-notes:create',
magicNotesUpdate: 'magic-notes:update',
magicNotesDelete: 'magic-notes:delete',
magicNotesCreateEntry: 'magic-notes:create-entry',
magicNotesUpdateEntry: 'magic-notes:update-entry',
magicNotesDeleteEntry: 'magic-notes:delete-entry',
magicNotesAnalyze: 'magic-notes:analyze',
magicTodosList: 'magic-todos:list',
magicTodosCreate: 'magic-todos:create',
magicTodosUpdate: 'magic-todos:update',
magicTodosDelete: 'magic-todos:delete',
magicTodosAnalyze: 'magic-todos:analyze',
knowledgeSnapshot: 'knowledge:snapshot',
knowledgeCreateLibrary: 'knowledge:library:create',
knowledgeUpdateLibrary: 'knowledge:library:update',
+262
View File
@@ -0,0 +1,262 @@
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_TEXT_BYTES = 500 * 1024
export function magicNoteImageDataBytes(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
}
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 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()
})
.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()
]),
attributes: magicNoteAttributesSchema.optional()
})
.strict()
)
.min(1)
.max(10_000)
})
.strict()
.superRefine((content, context) => {
const encoder = new TextEncoder()
let textBytes = 0
const imageData: string[] = []
for (const operation of content.ops) {
if (typeof operation.insert === 'string') {
textBytes += encoder.encode(operation.insert).byteLength
} else {
imageData.push(operation.insert.image)
}
}
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'
})
}
})
export type MagicNoteRichContent = z.infer<
typeof magicNoteRichContentSchema
>
export const magicNoteScopeSchema = z
.object({
projectId: magicNoteIdSchema.optional()
})
.strict()
export const magicNoteCreateSchema = z
.object({
projectId: magicNoteIdSchema.optional(),
title: z.string().trim().min(1).max(100)
})
.strict()
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: magicNoteRichContentSchema
})
.strict()
export type MagicNoteEntryCreateInput = z.infer<
typeof magicNoteEntryCreateSchema
>
export const magicNoteEntryUpdateSchema = z
.object({
entryId: magicNoteIdSchema,
content: magicNoteRichContentSchema,
expectedRevision: z.number().int().nonnegative()
})
.strict()
export type MagicNoteEntryUpdateInput = z.infer<
typeof magicNoteEntryUpdateSchema
>
export const magicNoteEntryDeleteSchema = z
.object({
entryId: magicNoteIdSchema
})
.strict()
export const magicNoteAnalyzeSchema = z
.object({
entryId: magicNoteIdSchema
})
.strict()
export const magicTodoCreateSchema = z
.object({
projectId: magicNoteIdSchema.optional(),
title: z.string().trim().min(1).max(120),
instructions: z.string().trim().max(20_000)
})
.strict()
export type MagicTodoCreateInput = z.infer<typeof magicTodoCreateSchema>
export const magicTodoUpdateSchema = z
.object({
todoId: magicNoteIdSchema,
title: z.string().trim().min(1).max(120).optional(),
instructions: z.string().trim().max(20_000).optional(),
completed: z.boolean().optional(),
expectedRevision: z.number().int().nonnegative()
})
.strict()
.refine(
(input) =>
input.title !== undefined ||
input.instructions !== undefined ||
input.completed !== undefined,
{ message: '没有可更新的待办字段' }
)
export type MagicTodoUpdateInput = z.infer<typeof magicTodoUpdateSchema>
export const magicTodoIdSchema = z
.object({
todoId: magicNoteIdSchema
})
.strict()
export type MagicNoteCommentKind = 'summary' | 'suggestion' | 'warning'
export type MagicNoteComment = {
id: string
kind: MagicNoteCommentKind
content: string
}
export type MagicNoteEntry = {
id: string
noteId: string
content: MagicNoteRichContent
plainText: string
comments: MagicNoteComment[]
analyzedAt?: string
revision: number
createdAt: string
updatedAt: string
}
export type MagicNoteSummary = {
id: string
projectId?: string
title: string
preview: string
entryCount: number
pinned: boolean
revision: number
createdAt: string
updatedAt: string
}
export type MagicNoteDetail = MagicNoteSummary & {
entries: MagicNoteEntry[]
}
export type MagicNotesSnapshot = {
notes: MagicNoteSummary[]
}
export type MagicTodoItem = {
id: string
projectId?: string
noteId?: string
entryId?: string
noteTitle?: string
sourceIndex?: number
source: 'note' | 'manual'
title: string
instructions: string
completed: boolean
comments: MagicNoteComment[]
analyzedAt?: string
revision: number
createdAt: string
updatedAt: string
}
export type MagicTodosSnapshot = {
todos: MagicTodoItem[]
}
+61
View File
@@ -0,0 +1,61 @@
import { z } from 'zod'
import { projectChannelSchema } from './assistant-contracts'
export const remoteChannelApprovalDecisionSchema = z.enum([
'deny',
'once'
])
export type RemoteChannelApprovalDecision = z.infer<
typeof remoteChannelApprovalDecisionSchema
>
export const remoteChannelApprovalSchema = z
.object({
approvalId: z.string().uuid(),
requestId: z.string().uuid(),
kind: z.enum(['request', 'tool']),
channel: projectChannelSchema,
channelLabel: z.string().trim().min(1).max(64),
senderDisplay: z.string().trim().min(1).max(200),
projectName: z.string().trim().min(1).max(120),
rootPath: z.string().max(4_096),
title: z.string().trim().min(1).max(300),
description: z.string().trim().min(1).max(8_000),
toolName: z.string().trim().min(1).max(200).optional(),
argumentSummary: z.string().max(4_000).optional(),
expiresAt: z.string().datetime({ offset: true })
})
.strict()
export type RemoteChannelApproval = z.infer<
typeof remoteChannelApprovalSchema
>
export const remoteChannelApprovalResponseSchema = z
.object({
approvalId: z.string().uuid(),
decision: remoteChannelApprovalDecisionSchema
})
.strict()
export const remoteChannelActivitySchema = z
.object({
requestId: z.string().uuid(),
conversationId: z.string().uuid(),
channel: projectChannelSchema,
kind: z.enum(['request', 'approval', 'tool', 'result']),
title: z.string().trim().min(1).max(240),
detail: z.string().max(4_000),
status: z.enum([
'pending',
'running',
'completed',
'failed',
'denied',
'cancelled'
]),
callId: z.string().min(1).max(256).optional()
})
.strict()
export type RemoteChannelActivity = z.infer<
typeof remoteChannelActivitySchema
>
+49
View File
@@ -0,0 +1,49 @@
import { z } from 'zod'
export const weixinBindingStatusSchema = z.enum([
'stopped',
'starting',
'pending',
'scanned',
'verification_required',
'connected',
'expired',
'failed'
])
export type WeixinBindingStatus = z.infer<
typeof weixinBindingStatusSchema
>
export const weixinBindingSnapshotSchema = z
.object({
status: weixinBindingStatusSchema,
qrPayload: z.string().min(1).max(4_096).optional(),
qrExpiresAt: z.string().datetime({ offset: true }).optional(),
accountDisplay: z.string().trim().min(1).max(64).optional(),
detail: z.string().trim().min(1).max(512).optional()
})
.strict()
export type WeixinBindingSnapshot = z.infer<
typeof weixinBindingSnapshotSchema
>
export const weixinVerificationInputSchema = z
.object({
code: z
.string()
.trim()
.min(1)
.max(32)
.regex(/^[0-9]+$/u, '')
})
.strict()
export type WeixinVerificationInput = z.infer<
typeof weixinVerificationInputSchema
>
export function weixinAccountDisplay(value: string): string | undefined {
const normalized = value.trim()
return normalized
? `微信用户 ****${normalized.slice(-4)}`
: undefined
}