normalize.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. import type {
  2. ChatAttachment,
  3. ChatMessage,
  4. CurrentUser,
  5. FileKind,
  6. FileLibraryItem,
  7. GroupAgent,
  8. GroupCapabilities,
  9. GroupInteraction,
  10. GroupMessage,
  11. GroupRoomDetail,
  12. GroupRoomSummary,
  13. JsonValue,
  14. ModelOption,
  15. Profile,
  16. SessionSummary,
  17. ToolCall,
  18. } from '@shared/types'
  19. export type UnknownRecord = Record<string, unknown>
  20. export function record(value: unknown): UnknownRecord {
  21. return value && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : {}
  22. }
  23. export function string(value: unknown, fallback = ''): string {
  24. if (typeof value === 'string') return value
  25. if (typeof value === 'number' && Number.isFinite(value)) return String(value)
  26. return fallback
  27. }
  28. export function number(value: unknown, fallback = 0): number {
  29. const parsed = typeof value === 'number' ? value : Number(value)
  30. return Number.isFinite(parsed) ? parsed : fallback
  31. }
  32. export function bool(value: unknown, fallback = false): boolean {
  33. if (typeof value === 'boolean') return value
  34. if (value === 1 || value === '1' || value === 'true') return true
  35. if (value === 0 || value === '0' || value === 'false') return false
  36. return fallback
  37. }
  38. export function values(value: unknown): unknown[] {
  39. return Array.isArray(value) ? value : []
  40. }
  41. function pick(source: UnknownRecord, ...keys: string[]): unknown {
  42. for (const key of keys) if (source[key] !== undefined && source[key] !== null) return source[key]
  43. return undefined
  44. }
  45. export function normalizeUser(value: unknown): CurrentUser {
  46. const source = record(value)
  47. const id = string(pick(source, 'id', 'user_id', 'userId')) || string(pick(source, 'email', 'username')) || 'local'
  48. return {
  49. id,
  50. username: string(pick(source, 'username', 'display_name', 'displayName', 'email', 'user_id', 'userId'), id),
  51. displayName: string(pick(source, 'display_name', 'displayName')) || undefined,
  52. email: string(source.email) || undefined,
  53. provider: string(source.provider) || undefined,
  54. role: string(source.role) || undefined,
  55. }
  56. }
  57. export function normalizeProfile(value: unknown): Profile {
  58. const source = record(value)
  59. const name = string(pick(source, 'name', 'profile', 'id'), 'default')
  60. return {
  61. name,
  62. agentName: string(pick(source, 'agentName', 'agent_name')) || undefined,
  63. displayName: string(pick(source, 'display_name', 'displayName', 'alias', 'description')) || undefined,
  64. description: string(source.description) || undefined,
  65. isDefault: bool(pick(source, 'is_default', 'isDefault', 'active')),
  66. model: string(source.model) || undefined,
  67. provider: string(source.provider) || undefined,
  68. gatewayRunning: source.gateway_running === undefined && source.gatewayRunning === undefined
  69. ? undefined
  70. : bool(pick(source, 'gateway_running', 'gatewayRunning')),
  71. avatar: string(source.avatar) || undefined,
  72. }
  73. }
  74. export function normalizeModel(value: unknown): ModelOption {
  75. const source = record(value)
  76. const provider = string(source.provider)
  77. const id = string(pick(source, 'id', 'model', 'value'))
  78. return {
  79. id,
  80. name: string(pick(source, 'name', 'display_name', 'displayName'), id),
  81. provider,
  82. supportsReasoning: source.supports_reasoning === undefined && source.supportsReasoning === undefined
  83. ? undefined
  84. : bool(pick(source, 'supports_reasoning', 'supportsReasoning')),
  85. reasoningEfforts: values(pick(source, 'reasoning_efforts', 'reasoningEfforts')).map(value => string(value)).filter(Boolean),
  86. isDefault: bool(pick(source, 'is_default', 'isDefault')),
  87. }
  88. }
  89. export function normalizeSession(value: unknown, fallbackProfile?: string): SessionSummary {
  90. const source = record(value)
  91. const startedAt = number(pick(source, 'started_at', 'startedAt', 'created_at', 'createdAt'))
  92. const updatedAt = number(pick(source, 'last_active', 'lastActive', 'updated_at', 'updatedAt'), startedAt)
  93. const id = string(pick(source, 'id', 'session_id', 'sessionId', 'stored_session_id', 'storedSessionId'))
  94. return {
  95. id,
  96. profile: string(source.profile, fallbackProfile) || undefined,
  97. source: string(source.source, 'cli'),
  98. title: string(source.title, '未命名会话'),
  99. preview: string(source.preview) || undefined,
  100. model: string(source.model) || undefined,
  101. provider: string(source.provider) || undefined,
  102. agent: string(source.agent) || undefined,
  103. messageCount: number(pick(source, 'message_count', 'messageCount')),
  104. toolCallCount: number(pick(source, 'tool_call_count', 'toolCallCount')),
  105. inputTokens: number(pick(source, 'input_tokens', 'inputTokens')) || undefined,
  106. outputTokens: number(pick(source, 'output_tokens', 'outputTokens')) || undefined,
  107. startedAt,
  108. updatedAt,
  109. endedAt: pick(source, 'ended_at', 'endedAt') == null ? null : number(pick(source, 'ended_at', 'endedAt')),
  110. parentSessionId: string(pick(source, 'parent_session_id', 'parentSessionId')) || null,
  111. forkPointMessageId: string(pick(source, 'fork_point_message_id', 'forkPointMessageId')) || null,
  112. workspace: string(source.workspace) || null,
  113. archived: bool(pick(source, 'is_archived', 'isArchived', 'archived')),
  114. pinned: bool(pick(source, 'is_pinned', 'isPinned', 'pinned')),
  115. }
  116. }
  117. function normalizeToolCall(value: unknown, index: number): ToolCall {
  118. const source = record(value)
  119. const functionCall = record(source.function)
  120. const error = string(source.error)
  121. const result = pick(source, 'result', 'output') as JsonValue | undefined
  122. return {
  123. id: string(pick(source, 'id', 'tool_call_id', 'toolCallId', 'tool_id'), `tool-${index}`),
  124. name: string(pick(source, 'name', 'tool_name', 'toolName') ?? functionCall.name, '工具'),
  125. status: error ? 'failed' : result !== undefined ? 'completed' : 'running',
  126. arguments: (pick(source, 'arguments', 'args', 'context') ?? functionCall.arguments) as JsonValue | undefined,
  127. result,
  128. preview: string(pick(source, 'preview', 'summary', 'args_text')) || undefined,
  129. error: error || undefined,
  130. durationSeconds: number(pick(source, 'duration_seconds', 'durationSeconds', 'duration_s')) || undefined,
  131. }
  132. }
  133. export function normalizeAttachment(value: unknown, index = 0): ChatAttachment {
  134. const source = record(value)
  135. const mimeType = string(pick(source, 'mime_type', 'mimeType', 'media_type', 'mediaType'), 'application/octet-stream')
  136. const name = string(pick(source, 'name', 'filename'), `附件 ${index + 1}`)
  137. return {
  138. id: string(source.id, `${name}:${index}`),
  139. name,
  140. mimeType,
  141. size: number(source.size),
  142. path: string(source.path) || undefined,
  143. url: string(source.url) || undefined,
  144. kind: mimeType.startsWith('image/') ? 'image' : mimeType === 'application/pdf' ? 'pdf' : 'file',
  145. }
  146. }
  147. export function normalizeChatMessage(value: unknown, sessionId: string, fallbackProfile?: string): ChatMessage {
  148. const source = record(value)
  149. const contentValue = pick(source, 'content', 'text', 'output')
  150. let content = typeof contentValue === 'string' ? contentValue : ''
  151. let attachments: ChatAttachment[] = values(pick(source, 'attachments', 'files')).map(normalizeAttachment)
  152. if (Array.isArray(contentValue)) {
  153. const textBlocks: string[] = []
  154. const attachmentBlocks: ChatAttachment[] = []
  155. contentValue.forEach((blockValue, index) => {
  156. const block = record(blockValue)
  157. const type = string(block.type)
  158. if (type === 'text') textBlocks.push(string(block.text))
  159. if (type === 'image' || type === 'file') attachmentBlocks.push(normalizeAttachment(block, index))
  160. })
  161. content = textBlocks.filter(Boolean).join('\n\n')
  162. attachments = [...attachments, ...attachmentBlocks]
  163. }
  164. const roleValue = string(source.role, 'assistant')
  165. const role = ['user', 'assistant', 'tool', 'system'].includes(roleValue) ? roleValue as ChatMessage['role'] : 'system'
  166. const id = string(pick(source, 'id', 'message_id', 'messageId')) || `history-${sessionId}-${number(source.timestamp)}-${Math.random()}`
  167. const status = string(pick(source, 'status', 'finish_reason', 'finishReason'))
  168. return {
  169. id,
  170. serverMessageId: id,
  171. clientMessageId: string(pick(source, 'client_message_id', 'clientMessageId')) || undefined,
  172. sessionId,
  173. profile: string(source.profile, fallbackProfile) || undefined,
  174. role,
  175. content,
  176. reasoning: string(pick(source, 'reasoning', 'thinking')) || undefined,
  177. timestamp: number(pick(source, 'timestamp', 'created_at', 'createdAt', 'updated_at', 'updatedAt'), Date.now() / 1000),
  178. sequence: number(pick(source, 'seq', 'sequence')) || undefined,
  179. stage: status === 'error' || source.error ? 'failed' : 'settled',
  180. error: string(source.error) || undefined,
  181. attachments: attachments.length ? attachments : undefined,
  182. toolCalls: values(typeof pick(source, 'tool_calls', 'toolCalls', 'tools') === 'string'
  183. ? (() => { try { return JSON.parse(String(pick(source, 'tool_calls', 'toolCalls', 'tools'))) } catch { return [] } })()
  184. : pick(source, 'tool_calls', 'toolCalls', 'tools')).map(normalizeToolCall),
  185. toolCallId: string(pick(source, 'tool_call_id', 'toolCallId')) || undefined,
  186. toolName: string(pick(source, 'tool_name', 'toolName')) || undefined,
  187. raw: value as JsonValue,
  188. }
  189. }
  190. export function normalizeGroupCapabilities(value: unknown): GroupCapabilities {
  191. const source = record(value)
  192. const limits = record(source.limits)
  193. return {
  194. protocolVersion: number(pick(source, 'protocolVersion', 'protocol_version')),
  195. journalEpoch: string(pick(source, 'journalEpoch', 'journal_epoch')),
  196. latestCursor: number(pick(source, 'latestCursor', 'latest_cursor')),
  197. limits: {
  198. maxAgentsPerRoom: number(pick(limits, 'maxAgentsPerRoom', 'max_agents_per_room'), 8),
  199. maxMessageBytes: number(pick(limits, 'maxMessageBytes', 'max_message_bytes'), 64 * 1024),
  200. maxToolStateBytes: number(pick(limits, 'maxToolStateBytes', 'max_tool_state_bytes')) || undefined,
  201. maxInteractionPayloadBytes: number(pick(limits, 'maxInteractionPayloadBytes', 'max_interaction_payload_bytes')) || undefined,
  202. maxMessagePageSize: number(pick(limits, 'maxMessagePageSize', 'max_message_page_size'), 100),
  203. maxEventBatchSize: number(pick(limits, 'maxEventBatchSize', 'max_event_batch_size')) || undefined,
  204. maxAgentDepth: number(pick(limits, 'maxAgentDepth', 'max_agent_depth')) || undefined,
  205. maxRoomConcurrency: number(pick(limits, 'maxRoomConcurrency', 'max_room_concurrency')) || undefined,
  206. maxPluginConcurrency: number(pick(limits, 'maxPluginConcurrency', 'max_plugin_concurrency')) || undefined,
  207. defaultMaxReplyRounds: number(pick(limits, 'defaultMaxReplyRounds', 'default_max_reply_rounds'), 3),
  208. unlimitedReplyRoundsValue: number(pick(limits, 'unlimitedReplyRoundsValue', 'unlimited_reply_rounds_value'), -1),
  209. maxAgentDisplayNameLength: number(pick(limits, 'maxAgentDisplayNameLength', 'max_agent_display_name_length'), 100),
  210. },
  211. eventTypes: values(pick(source, 'eventTypes', 'event_types')).map(value => string(value)).filter(Boolean),
  212. }
  213. }
  214. export function normalizeGroupAgent(value: unknown): GroupAgent {
  215. const source = record(value)
  216. const status = string(source.status, 'unknown') as GroupAgent['status']
  217. return {
  218. id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
  219. profile: string(source.profile), displayName: string(pick(source, 'displayName', 'display_name'), string(source.profile)),
  220. description: string(source.description), storedSessionId: string(pick(source, 'storedSessionId', 'stored_session_id')) || null,
  221. lastContextMessageSeq: number(pick(source, 'lastContextMessageSeq', 'last_context_message_seq')),
  222. enabled: bool(source.enabled, true), replyWithoutMention: bool(pick(source, 'replyWithoutMention', 'reply_without_mention')),
  223. createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
  224. status: ['idle', 'queued', 'running', 'awaiting_input'].includes(status) ? status : 'unknown',
  225. }
  226. }
  227. export function normalizeGroupMessage(value: unknown): GroupMessage {
  228. const source = record(value)
  229. const senderKind = string(pick(source, 'senderKind', 'sender_kind'), 'unknown') as GroupMessage['senderKind']
  230. const status = string(source.status, 'unknown') as GroupMessage['status']
  231. return {
  232. seq: number(source.seq), id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
  233. senderKind: ['human', 'agent', 'system'].includes(senderKind) ? senderKind : 'unknown',
  234. senderId: string(pick(source, 'senderId', 'sender_id')), senderName: string(pick(source, 'senderName', 'sender_name')),
  235. rootMessageId: string(pick(source, 'rootMessageId', 'root_message_id')), replyToMessageId: string(pick(source, 'replyToMessageId', 'reply_to_message_id')) || null,
  236. clientMessageId: string(pick(source, 'clientMessageId', 'client_message_id')) || null,
  237. content: string(source.content), reasoning: string(source.reasoning), toolState: values(pick(source, 'toolState', 'tool_state')) as JsonValue[],
  238. status: ['queued', 'streaming', 'completed', 'failed', 'interrupted'].includes(status) ? status : 'unknown',
  239. error: string(source.error), createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
  240. }
  241. }
  242. export function normalizeGroupInteraction(value: unknown): GroupInteraction {
  243. const source = record(value)
  244. return {
  245. id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')), agentId: string(pick(source, 'agentId', 'agent_id')),
  246. runId: string(pick(source, 'runId', 'run_id')), kind: string(source.kind, 'unknown') as GroupInteraction['kind'],
  247. payload: (source.payload ?? null) as JsonValue, status: string(source.status, 'unknown') as GroupInteraction['status'],
  248. createdAt: number(pick(source, 'createdAt', 'created_at')), resolvedAt: pick(source, 'resolvedAt', 'resolved_at') == null ? null : number(pick(source, 'resolvedAt', 'resolved_at')),
  249. }
  250. }
  251. export function normalizeGroupRoom(value: unknown): GroupRoomSummary {
  252. const source = record(value)
  253. const rawAgents = source.agents
  254. return {
  255. id: string(source.id), name: string(source.name, '未命名群聊'), cwd: string(source.cwd),
  256. createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
  257. archived: bool(source.archived), agentCount: Array.isArray(rawAgents) ? rawAgents.length : number(pick(source, 'agentCount', 'agent_count')),
  258. lastMessage: source.lastMessage || source.last_message ? normalizeGroupMessage(pick(source, 'lastMessage', 'last_message')) : null,
  259. unreadCount: number(pick(source, 'unreadCount', 'unread_count')), maxReplyRounds: number(pick(source, 'maxReplyRounds', 'max_reply_rounds'), 3),
  260. }
  261. }
  262. export function normalizeGroupRoomDetail(value: unknown): GroupRoomDetail {
  263. const source = record(value)
  264. const summary = normalizeGroupRoom(source)
  265. return {
  266. id: summary.id, name: summary.name, cwd: summary.cwd, createdAt: summary.createdAt, updatedAt: summary.updatedAt,
  267. archived: summary.archived, maxReplyRounds: summary.maxReplyRounds,
  268. agents: values(source.agents).map(normalizeGroupAgent), runs: values(source.runs).map(run => record(run) as unknown as GroupRoomDetail['runs'][number]),
  269. pendingInteractions: values(pick(source, 'pendingInteractions', 'pending_interactions')).map(normalizeGroupInteraction),
  270. latestCursor: number(pick(source, 'latestCursor', 'latest_cursor')),
  271. }
  272. }
  273. export function inferFileKind(mimeType: string, name: string): FileKind {
  274. const mime = mimeType.toLowerCase().split(';')[0]
  275. if (mime.startsWith('image/')) return 'image'
  276. if (mime.startsWith('video/')) return 'video'
  277. if (mime.startsWith('audio/')) return 'audio'
  278. if (mime.startsWith('text/') || /\.(pdf|docx?|xlsx?|pptx?|md|txt|csv|json|rtf|pages|numbers|key)$/i.test(name)) return 'document'
  279. return 'other'
  280. }
  281. export function normalizeFile(value: unknown, fallbackProfile?: string): FileLibraryItem {
  282. const source = record(value)
  283. const id = string(pick(source, 'id', 'fileId', 'file_id'))
  284. const mimeType = string(pick(source, 'mimeType', 'mime_type'), 'application/octet-stream')
  285. const name = string(source.name)
  286. const origins = values(source.origins).map(item => {
  287. const origin = record(item)
  288. return {
  289. id: origin.id as number | string | undefined,
  290. profile: string(origin.profile) || fallbackProfile || undefined,
  291. sessionId: string(pick(origin, 'sessionId', 'session_id')) || undefined,
  292. sessionTitle: string(pick(origin, 'sessionTitle', 'session_title')) || undefined,
  293. messageId: string(pick(origin, 'messageId', 'message_id')) || undefined,
  294. authorKind: string(pick(origin, 'authorKind', 'author_kind')) as FileLibraryItem['origins'][number]['authorKind'] || undefined,
  295. authorName: string(pick(origin, 'authorName', 'author_name')) || undefined,
  296. observedAt: number(pick(origin, 'observedAt', 'observed_at')) || undefined,
  297. originalPath: string(pick(origin, 'originalPath', 'original_path')) || undefined,
  298. referencePath: string(pick(origin, 'referencePath', 'reference_path')) || undefined,
  299. }
  300. })
  301. const profile = origins.find(origin => origin.profile)?.profile
  302. const profileQuery = profile ? `?profile=${encodeURIComponent(profile)}` : ''
  303. return {
  304. id, path: string(source.path), name, extension: string(pick(source, 'extension', 'extensionName', 'extension_name')),
  305. mimeType, size: number(source.size), modifiedAt: number(pick(source, 'modifiedAt', 'modified_at')),
  306. exists: bool(source.exists, true), availability: string(source.availability) as FileLibraryItem['availability'] || undefined,
  307. archiveStatus: string(pick(source, 'archiveStatus', 'archive_status')) || undefined,
  308. firstSeenAt: number(pick(source, 'firstSeenAt', 'first_seen_at')) || undefined,
  309. lastSeenAt: number(pick(source, 'lastSeenAt', 'last_seen_at')) || undefined,
  310. messageTimestamp: pick(source, 'messageTimestamp', 'message_timestamp') == null ? null : number(pick(source, 'messageTimestamp', 'message_timestamp')),
  311. origins, kind: inferFileKind(mimeType, name),
  312. previewUrl: string(pick(source, 'previewUrl', 'preview_url'))
  313. || `/api/app/files/${encodeURIComponent(id)}/preview${profileQuery}`,
  314. downloadUrl: string(pick(source, 'downloadUrl', 'download_url'))
  315. || `/api/app/files/${encodeURIComponent(id)}/download${profileQuery}`,
  316. }
  317. }