normalize.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. import type {
  2. ChatAttachment,
  3. ChatMessage,
  4. CurrentUser,
  5. FileKind,
  6. FileLibraryItem,
  7. GroupAgent,
  8. GroupCapabilities,
  9. GroupInteraction,
  10. GroupMessage,
  11. GroupRoomDetail,
  12. GroupRoomActivity,
  13. GroupRoomSummary,
  14. GroupRun,
  15. GroupTopicSummary,
  16. JsonValue,
  17. ModelOption,
  18. Profile,
  19. SessionSummary,
  20. ToolCall,
  21. } from '@shared/types'
  22. export type UnknownRecord = Record<string, unknown>
  23. export function record(value: unknown): UnknownRecord {
  24. return value && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : {}
  25. }
  26. export function string(value: unknown, fallback = ''): string {
  27. if (typeof value === 'string') return value
  28. if (typeof value === 'number' && Number.isFinite(value)) return String(value)
  29. return fallback
  30. }
  31. export function number(value: unknown, fallback = 0): number {
  32. const parsed = typeof value === 'number' ? value : Number(value)
  33. return Number.isFinite(parsed) ? parsed : fallback
  34. }
  35. export function bool(value: unknown, fallback = false): boolean {
  36. if (typeof value === 'boolean') return value
  37. if (value === 1 || value === '1' || value === 'true') return true
  38. if (value === 0 || value === '0' || value === 'false') return false
  39. return fallback
  40. }
  41. export function values(value: unknown): unknown[] {
  42. return Array.isArray(value) ? value : []
  43. }
  44. function pick(source: UnknownRecord, ...keys: string[]): unknown {
  45. for (const key of keys) if (source[key] !== undefined && source[key] !== null) return source[key]
  46. return undefined
  47. }
  48. function jsonRecord(value: unknown): UnknownRecord {
  49. const direct = record(value)
  50. if (Object.keys(direct).length || typeof value !== 'string') return direct
  51. try { return record(JSON.parse(value)) } catch { return {} }
  52. }
  53. export function normalizeUser(value: unknown): CurrentUser {
  54. const source = record(value)
  55. const id = string(pick(source, 'id', 'user_id', 'userId')) || string(pick(source, 'email', 'username')) || 'local'
  56. return {
  57. id,
  58. username: string(pick(source, 'username', 'display_name', 'displayName', 'email', 'user_id', 'userId'), id),
  59. displayName: string(pick(source, 'display_name', 'displayName')) || undefined,
  60. email: string(source.email) || undefined,
  61. provider: string(source.provider) || undefined,
  62. role: string(source.role) || undefined,
  63. }
  64. }
  65. export function normalizeProfile(value: unknown): Profile {
  66. const source = record(value)
  67. const name = string(pick(source, 'name', 'profile', 'id'), 'default')
  68. return {
  69. name,
  70. agentName: string(pick(source, 'agentName', 'agent_name')) || undefined,
  71. displayName: string(pick(source, 'display_name', 'displayName', 'alias', 'description')) || undefined,
  72. description: string(source.description) || undefined,
  73. isDefault: bool(pick(source, 'is_default', 'isDefault', 'active')),
  74. model: string(source.model) || undefined,
  75. provider: string(source.provider) || undefined,
  76. gatewayRunning: source.gateway_running === undefined && source.gatewayRunning === undefined
  77. ? undefined
  78. : bool(pick(source, 'gateway_running', 'gatewayRunning')),
  79. avatar: string(source.avatar) || undefined,
  80. agentAvatar: string(pick(source, 'agentAvatar', 'agent_avatar')) || undefined,
  81. }
  82. }
  83. export function normalizeModel(value: unknown): ModelOption {
  84. const source = record(value)
  85. const provider = string(source.provider)
  86. const id = string(pick(source, 'id', 'model', 'value'))
  87. return {
  88. id,
  89. name: string(pick(source, 'name', 'display_name', 'displayName'), id),
  90. provider,
  91. supportsReasoning: source.supports_reasoning === undefined && source.supportsReasoning === undefined
  92. ? undefined
  93. : bool(pick(source, 'supports_reasoning', 'supportsReasoning')),
  94. reasoningEfforts: values(pick(source, 'reasoning_efforts', 'reasoningEfforts')).map(value => string(value)).filter(Boolean),
  95. isDefault: bool(pick(source, 'is_default', 'isDefault')),
  96. }
  97. }
  98. export function normalizeSession(value: unknown, fallbackProfile?: string): SessionSummary {
  99. const source = record(value)
  100. const modelConfig = jsonRecord(pick(source, 'model_config', 'modelConfig'))
  101. const startedAt = number(pick(source, 'started_at', 'startedAt', 'created_at', 'createdAt'))
  102. const updatedAt = number(pick(source, 'last_active', 'lastActive', 'updated_at', 'updatedAt'), startedAt)
  103. const id = string(pick(source, 'id', 'session_id', 'sessionId', 'stored_session_id', 'storedSessionId'))
  104. return {
  105. id,
  106. profile: string(source.profile, fallbackProfile) || undefined,
  107. source: string(source.source, 'cli'),
  108. title: string(source.title, '未命名会话'),
  109. preview: string(source.preview) || undefined,
  110. model: string(source.model ?? modelConfig.model) || undefined,
  111. provider: string(source.provider ?? modelConfig.provider) || undefined,
  112. agent: string(source.agent) || undefined,
  113. messageCount: number(pick(source, 'message_count', 'messageCount')),
  114. toolCallCount: number(pick(source, 'tool_call_count', 'toolCallCount')),
  115. inputTokens: number(pick(source, 'input_tokens', 'inputTokens')) || undefined,
  116. outputTokens: number(pick(source, 'output_tokens', 'outputTokens')) || undefined,
  117. startedAt,
  118. updatedAt,
  119. endedAt: pick(source, 'ended_at', 'endedAt') == null ? null : number(pick(source, 'ended_at', 'endedAt')),
  120. parentSessionId: string(pick(source, 'parent_session_id', 'parentSessionId')) || null,
  121. forkPointMessageId: string(pick(source, 'fork_point_message_id', 'forkPointMessageId')) || null,
  122. workspace: string(source.workspace) || null,
  123. archived: bool(pick(source, 'is_archived', 'isArchived', 'archived')),
  124. pinned: bool(pick(source, 'is_pinned', 'isPinned', 'pinned')),
  125. }
  126. }
  127. function normalizeToolCall(value: unknown, index: number): ToolCall {
  128. const source = record(value)
  129. const functionCall = record(source.function)
  130. const error = string(source.error)
  131. const result = pick(source, 'result', 'output') as JsonValue | undefined
  132. return {
  133. id: string(pick(source, 'id', 'tool_call_id', 'toolCallId', 'tool_id'), `tool-${index}`),
  134. name: string(pick(source, 'name', 'tool_name', 'toolName') ?? functionCall.name, '工具'),
  135. status: error ? 'failed' : result !== undefined ? 'completed' : 'running',
  136. arguments: (pick(source, 'arguments', 'args', 'context') ?? functionCall.arguments) as JsonValue | undefined,
  137. result,
  138. preview: string(pick(source, 'preview', 'summary', 'args_text')) || undefined,
  139. error: error || undefined,
  140. durationSeconds: number(pick(source, 'duration_seconds', 'durationSeconds', 'duration_s')) || undefined,
  141. }
  142. }
  143. function parseJsonValue(value: unknown): JsonValue | undefined {
  144. if (typeof value !== 'string') return value as JsonValue | undefined
  145. try { return JSON.parse(value) as JsonValue } catch { return undefined }
  146. }
  147. export function normalizeAttachment(value: unknown, index = 0): ChatAttachment {
  148. const source = record(value)
  149. const mimeType = string(pick(source, 'mime_type', 'mimeType', 'media_type', 'mediaType'), 'application/octet-stream')
  150. const name = string(pick(source, 'name', 'filename'), `附件 ${index + 1}`)
  151. return {
  152. id: string(source.id, `${name}:${index}`),
  153. name,
  154. mimeType,
  155. size: number(source.size),
  156. path: string(source.path) || undefined,
  157. url: string(source.url) || undefined,
  158. kind: mimeType.startsWith('image/') ? 'image' : mimeType === 'application/pdf' ? 'pdf' : 'file',
  159. }
  160. }
  161. function attachmentMime(name: string): { mimeType: string; kind: ChatAttachment['kind'] } {
  162. const extension = name.split('.').at(-1)?.toLocaleLowerCase() || ''
  163. if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'avif', 'bmp', 'tif', 'tiff'].includes(extension)) {
  164. return { mimeType: `image/${extension === 'jpg' ? 'jpeg' : extension}`, kind: 'image' }
  165. }
  166. if (extension === 'pdf') return { mimeType: 'application/pdf', kind: 'pdf' }
  167. return { mimeType: 'application/octet-stream', kind: 'file' }
  168. }
  169. function persistedAttachmentUrl(path: string): { path: string; url: string } | undefined {
  170. if (path.startsWith('/')) return { path, url: path }
  171. const segments = path.split('/')
  172. const normalizedSegments = segments.map(segment => {
  173. try { return decodeURIComponent(segment) } catch { return '' }
  174. })
  175. if (segments[0] !== 'attachments' || segments.length < 2 || normalizedSegments.some(segment => !segment || segment === '.' || segment === '..')) return undefined
  176. return { path, url: `/${segments.map(encodeURIComponent).join('/')}` }
  177. }
  178. function persistedImageUrl(path: string): { path: string; url: string } | undefined {
  179. if (!path.startsWith('/')) return undefined
  180. const segments = path.slice(1).split('/')
  181. const normalizedSegments = segments.map(segment => {
  182. try { return decodeURIComponent(segment) } catch { return '' }
  183. })
  184. if (segments.length < 5 || segments[0] !== 'Users' || normalizedSegments[2] !== '.hermes' || normalizedSegments[3] !== 'images'
  185. || normalizedSegments.some(segment => !segment || segment === '.' || segment === '..')) return undefined
  186. return { path, url: `/${segments.map(encodeURIComponent).join('/')}` }
  187. }
  188. function extractPersistedAttachments(content: string): { content: string; attachments: ChatAttachment[] } {
  189. const attachments: ChatAttachment[] = []
  190. const extracted = content.replace(
  191. /\[用户附加\s*(文件|图片|PDF)\s*:\s*([^\]\r\n]+)\]\s*(?:\r?\n)?@file:\s*(`[^`\r\n]+`|'[^'\r\n]+'|"[^"\r\n]+"|(?:\\?\/)[^\r\n]+)/g,
  192. (_match, _type: string, rawName: string, rawPath: string) => {
  193. const name = rawName.trim()
  194. const path = rawPath.trim().replace(/\\\//g, '/').replace(/^[`'"]|[`'"]$/g, '')
  195. const reference = persistedAttachmentUrl(path)
  196. if (!reference) return _match
  197. const media = attachmentMime(name)
  198. attachments.push({ id: `persisted:${reference.path}`, name, path: reference.path, url: reference.url, size: 0, ...media })
  199. return ''
  200. },
  201. )
  202. const cleaned = (attachments.length
  203. ? extracted.replace(/(?:^|\n)--- Attached Context ---[\s\S]*$/, '')
  204. : extracted
  205. ).replace(/\n{3,}/g, '\n\n').trim()
  206. return { content: cleaned, attachments }
  207. }
  208. function extractPersistedImages(content: string): { content: string; attachments: ChatAttachment[] } {
  209. const lines = content.split(/\r?\n/)
  210. const attachments: ChatAttachment[] = []
  211. const imageMarker = /^\[用户附加图片\s*:\s*([^\]\r\n]+)\]\s*$/
  212. const imageReference = /^@image:\s*(.+?)\s*$/
  213. const screenshot = /^\[screenshot\]\s*$/
  214. for (let index = 0; index < lines.length;) {
  215. const firstMarker = lines[index]?.match(imageMarker)
  216. if (!firstMarker) {
  217. index += 1
  218. continue
  219. }
  220. const start = index
  221. const names: string[] = []
  222. let cursor = index
  223. while (true) {
  224. const marker = lines[cursor]?.match(imageMarker)
  225. if (!marker) break
  226. names.push(marker[1]!.trim())
  227. cursor += 1
  228. while (lines[cursor]?.trim() === '') cursor += 1
  229. }
  230. const paths: string[] = []
  231. while (true) {
  232. const reference = lines[cursor]?.match(imageReference)
  233. if (!reference) break
  234. paths.push(reference[1]!.trim().replace(/^[`'"]|[`'"]$/g, ''))
  235. cursor += 1
  236. }
  237. const references = paths.map(persistedImageUrl)
  238. if (!paths.length || names.length !== paths.length || references.some(reference => !reference)) {
  239. index = start + 1
  240. continue
  241. }
  242. for (let position = 0; position < names.length; position += 1) {
  243. const reference = references[position]!
  244. const name = names[position]!
  245. attachments.push({ id: `persisted:${reference.path}`, name, path: reference.path, url: reference.url, size: 0, ...attachmentMime(name) })
  246. }
  247. while (screenshot.test(lines[cursor] || '')) cursor += 1
  248. lines.splice(start, cursor - start, ...Array(cursor - start).fill(''))
  249. index = cursor
  250. }
  251. return { content: lines.join('\n').replace(/\n{3,}/g, '\n\n').trim(), attachments }
  252. }
  253. export function normalizeChatMessage(value: unknown, sessionId: string, fallbackProfile?: string): ChatMessage {
  254. const source = record(value)
  255. const contentValue = pick(source, 'content', 'text', 'output')
  256. let content = typeof contentValue === 'string' ? contentValue : ''
  257. let attachments: ChatAttachment[] = values(pick(source, 'attachments', 'files')).map(normalizeAttachment)
  258. if (Array.isArray(contentValue)) {
  259. const textBlocks: string[] = []
  260. const attachmentBlocks: ChatAttachment[] = []
  261. contentValue.forEach((blockValue, index) => {
  262. const block = record(blockValue)
  263. const type = string(block.type)
  264. if (type === 'text') textBlocks.push(string(block.text))
  265. if (type === 'image' || type === 'file') attachmentBlocks.push(normalizeAttachment(block, index))
  266. })
  267. content = textBlocks.filter(Boolean).join('\n\n')
  268. attachments = [...attachments, ...attachmentBlocks]
  269. }
  270. const roleValue = string(source.role, 'assistant')
  271. const role = ['user', 'assistant', 'tool', 'system'].includes(roleValue) ? roleValue as ChatMessage['role'] : 'system'
  272. if (role === 'user' && (content.includes('@file:') || content.includes('@image:'))) {
  273. const persistedFiles = extractPersistedAttachments(content)
  274. const persistedImages = extractPersistedImages(persistedFiles.content)
  275. content = persistedImages.content
  276. const known = new Set(attachments.map(item => item.path || item.url || item.name))
  277. attachments.push(...[...persistedFiles.attachments, ...persistedImages.attachments]
  278. .filter(item => !known.has(item.path || item.url || item.name)))
  279. }
  280. const id = string(pick(source, 'id', 'message_id', 'messageId')) || `history-${sessionId}-${number(source.timestamp)}-${Math.random()}`
  281. const status = string(pick(source, 'status', 'finish_reason', 'finishReason'))
  282. return {
  283. id,
  284. serverMessageId: id,
  285. clientMessageId: string(pick(source, 'client_message_id', 'clientMessageId')) || undefined,
  286. sessionId,
  287. profile: string(source.profile, fallbackProfile) || undefined,
  288. role,
  289. content,
  290. reasoning: string(pick(source, 'reasoning', 'thinking', 'reasoning_content', 'reasoningContent')) || undefined,
  291. timestamp: number(pick(source, 'timestamp', 'created_at', 'createdAt', 'updated_at', 'updatedAt'), Date.now() / 1000),
  292. sequence: number(pick(source, 'seq', 'sequence')) || undefined,
  293. stage: status === 'error' || source.error ? 'failed' : 'settled',
  294. error: string(source.error) || undefined,
  295. attachments: attachments.length ? attachments : undefined,
  296. toolCalls: values(typeof pick(source, 'tool_calls', 'toolCalls', 'tools') === 'string'
  297. ? (() => { try { return JSON.parse(String(pick(source, 'tool_calls', 'toolCalls', 'tools'))) } catch { return [] } })()
  298. : pick(source, 'tool_calls', 'toolCalls', 'tools')).map(normalizeToolCall),
  299. toolCallId: string(pick(source, 'tool_call_id', 'toolCallId')) || undefined,
  300. toolName: string(pick(source, 'tool_name', 'toolName')) || undefined,
  301. displayKind: string(pick(source, 'display_kind', 'displayKind')) || undefined,
  302. displayMetadata: parseJsonValue(pick(source, 'display_metadata', 'displayMetadata')),
  303. raw: value as JsonValue,
  304. }
  305. }
  306. export function normalizeGroupCapabilities(value: unknown): GroupCapabilities {
  307. const source = record(value)
  308. const limits = record(source.limits)
  309. return {
  310. protocolVersion: number(pick(source, 'protocolVersion', 'protocol_version')),
  311. journalEpoch: string(pick(source, 'journalEpoch', 'journal_epoch')),
  312. latestCursor: number(pick(source, 'latestCursor', 'latest_cursor')),
  313. limits: {
  314. maxAgentsPerRoom: number(pick(limits, 'maxAgentsPerRoom', 'max_agents_per_room'), 8),
  315. maxMessageBytes: number(pick(limits, 'maxMessageBytes', 'max_message_bytes'), 64 * 1024),
  316. maxToolStateBytes: number(pick(limits, 'maxToolStateBytes', 'max_tool_state_bytes')) || undefined,
  317. maxInteractionPayloadBytes: number(pick(limits, 'maxInteractionPayloadBytes', 'max_interaction_payload_bytes')) || undefined,
  318. maxMessagePageSize: number(pick(limits, 'maxMessagePageSize', 'max_message_page_size'), 100),
  319. maxEventBatchSize: number(pick(limits, 'maxEventBatchSize', 'max_event_batch_size')) || undefined,
  320. maxAgentDepth: number(pick(limits, 'maxAgentDepth', 'max_agent_depth')) || undefined,
  321. maxRoomConcurrency: number(pick(limits, 'maxRoomConcurrency', 'max_room_concurrency')) || undefined,
  322. maxPluginConcurrency: number(pick(limits, 'maxPluginConcurrency', 'max_plugin_concurrency')) || undefined,
  323. defaultMaxReplyRounds: number(pick(limits, 'defaultMaxReplyRounds', 'default_max_reply_rounds'), 3),
  324. unlimitedReplyRoundsValue: number(pick(limits, 'unlimitedReplyRoundsValue', 'unlimited_reply_rounds_value'), -1),
  325. maxAgentDisplayNameLength: number(pick(limits, 'maxAgentDisplayNameLength', 'max_agent_display_name_length'), 100),
  326. },
  327. eventTypes: values(pick(source, 'eventTypes', 'event_types')).map(value => string(value)).filter(Boolean),
  328. features: values(source.features).map(value => string(value)).filter(Boolean),
  329. }
  330. }
  331. export function normalizeGroupAgent(value: unknown): GroupAgent {
  332. const source = record(value)
  333. const status = string(source.status, 'unknown') as GroupAgent['status']
  334. const rawFastMode = pick(source, 'fastMode', 'fast_mode')
  335. return {
  336. id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
  337. profile: string(source.profile),
  338. nodeId: string(pick(source, 'nodeId', 'node_id'), 'local'),
  339. nodeLabel: string(pick(source, 'nodeLabel', 'node_label')),
  340. displayName: string(pick(source, 'displayName', 'display_name'), string(source.profile)),
  341. description: string(source.description), storedSessionId: string(pick(source, 'storedSessionId', 'stored_session_id')) || null,
  342. lastContextMessageSeq: number(pick(source, 'lastContextMessageSeq', 'last_context_message_seq')),
  343. enabled: bool(source.enabled, true), replyWithoutMention: bool(pick(source, 'replyWithoutMention', 'reply_without_mention')),
  344. isHost: bool(pick(source, 'isHost', 'is_host')),
  345. model: string(source.model) || null, provider: string(source.provider) || null,
  346. reasoningEffort: string(pick(source, 'reasoningEffort', 'reasoning_effort')) || null,
  347. fastMode: rawFastMode == null ? null : bool(rawFastMode),
  348. createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
  349. status: ['idle', 'queued', 'running', 'awaiting_input'].includes(status) ? status : 'unknown',
  350. }
  351. }
  352. export function normalizeGroupMessage(value: unknown): GroupMessage {
  353. const source = record(value)
  354. const senderKind = string(pick(source, 'senderKind', 'sender_kind'), 'unknown') as GroupMessage['senderKind']
  355. const status = string(source.status, 'unknown') as GroupMessage['status']
  356. return {
  357. seq: number(source.seq), id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
  358. topicId: string(pick(source, 'topicId', 'topic_id')) || null,
  359. senderKind: ['human', 'agent', 'system'].includes(senderKind) ? senderKind : 'unknown',
  360. senderId: string(pick(source, 'senderId', 'sender_id')), senderName: string(pick(source, 'senderName', 'sender_name')),
  361. rootMessageId: string(pick(source, 'rootMessageId', 'root_message_id')), replyToMessageId: string(pick(source, 'replyToMessageId', 'reply_to_message_id')) || null,
  362. clientMessageId: string(pick(source, 'clientMessageId', 'client_message_id')) || null,
  363. content: string(source.content), reasoning: string(source.reasoning), toolState: values(pick(source, 'toolState', 'tool_state')) as JsonValue[],
  364. status: ['queued', 'streaming', 'completed', 'failed', 'interrupted'].includes(status) ? status : 'unknown',
  365. error: string(source.error), execution: source.execution ? record(source.execution) as GroupMessage['execution'] : null, createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
  366. }
  367. }
  368. export function normalizeGroupInteraction(value: unknown): GroupInteraction {
  369. const source = record(value)
  370. return {
  371. id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')), topicId: string(pick(source, 'topicId', 'topic_id')) || null,
  372. agentId: string(pick(source, 'agentId', 'agent_id')),
  373. runId: string(pick(source, 'runId', 'run_id')), kind: string(source.kind, 'unknown') as GroupInteraction['kind'],
  374. payload: (source.payload ?? null) as JsonValue, status: string(source.status, 'unknown') as GroupInteraction['status'],
  375. createdAt: number(pick(source, 'createdAt', 'created_at')), resolvedAt: pick(source, 'resolvedAt', 'resolved_at') == null ? null : number(pick(source, 'resolvedAt', 'resolved_at')),
  376. }
  377. }
  378. export function normalizeGroupRun(value: unknown): GroupRun {
  379. const source = record(value)
  380. const status = string(source.status, 'unknown') as GroupRun['status']
  381. const replyMode = string(pick(source, 'replyMode', 'reply_mode'))
  382. return {
  383. id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
  384. topicId: string(pick(source, 'topicId', 'topic_id')) || null,
  385. agentId: string(pick(source, 'agentId', 'agent_id')),
  386. triggerMessageId: string(pick(source, 'triggerMessageId', 'trigger_message_id')),
  387. responseMessageId: string(pick(source, 'responseMessageId', 'response_message_id')),
  388. rootMessageId: string(pick(source, 'rootMessageId', 'root_message_id')),
  389. depth: number(source.depth),
  390. status: ['queued', 'running', 'awaiting_input', 'completed', 'failed', 'interrupted'].includes(status) ? status : 'unknown',
  391. runtimeSessionId: string(pick(source, 'runtimeSessionId', 'runtime_session_id')) || null,
  392. error: string(source.error),
  393. replyMode: replyMode === 'mentioned' || replyMode === 'automatic' ? replyMode : null,
  394. createdAt: number(pick(source, 'createdAt', 'created_at')),
  395. updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
  396. }
  397. }
  398. export function normalizeGroupTopic(value: unknown): GroupTopicSummary {
  399. const source = record(value)
  400. return {
  401. id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
  402. title: string(source.title, '新话题'), preview: string(source.preview),
  403. messageCount: number(pick(source, 'messageCount', 'message_count')),
  404. unreadCount: number(pick(source, 'unreadCount', 'unread_count')),
  405. latestMessageSeq: number(pick(source, 'latestMessageSeq', 'latest_message_seq')),
  406. createdAt: number(pick(source, 'createdAt', 'created_at')),
  407. updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
  408. archived: bool(source.archived),
  409. pinned: bool(source.pinned),
  410. }
  411. }
  412. export function normalizeGroupRoom(value: unknown): GroupRoomSummary {
  413. const source = record(value)
  414. const rawAgents = source.agents
  415. const orchestrationMode = string(pick(source, 'orchestrationMode', 'orchestration_mode'))
  416. return {
  417. id: string(source.id), name: string(source.name, '未命名团队'), cwd: string(source.cwd), instructions: string(source.instructions),
  418. createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
  419. archived: bool(source.archived), agentCount: Array.isArray(rawAgents) ? rawAgents.length : number(pick(source, 'agentCount', 'agent_count')),
  420. lastMessage: source.lastMessage || source.last_message ? normalizeGroupMessage(pick(source, 'lastMessage', 'last_message')) : null,
  421. unreadCount: number(pick(source, 'unreadCount', 'unread_count')),
  422. activeRunCount: number(pick(source, 'activeRunCount', 'active_run_count')),
  423. maxReplyRounds: number(pick(source, 'maxReplyRounds', 'max_reply_rounds'), 3),
  424. orchestrationMode: orchestrationMode === 'host' ? 'host' : 'free',
  425. }
  426. }
  427. export function normalizeGroupRoomActivity(value: unknown): GroupRoomActivity {
  428. const source = record(value)
  429. const lastMessage = pick(source, 'lastMessage', 'last_message')
  430. return {
  431. roomId: string(pick(source, 'roomId', 'room_id')),
  432. activeRunCount: number(pick(source, 'activeRunCount', 'active_run_count')),
  433. unreadCount: number(pick(source, 'unreadCount', 'unread_count')),
  434. lastMessage: lastMessage == null ? null : normalizeGroupMessage(lastMessage),
  435. }
  436. }
  437. export function normalizeGroupRoomDetail(value: unknown): GroupRoomDetail {
  438. const source = record(value)
  439. const summary = normalizeGroupRoom(source)
  440. return {
  441. id: summary.id, name: summary.name, cwd: summary.cwd, instructions: summary.instructions, createdAt: summary.createdAt, updatedAt: summary.updatedAt,
  442. archived: summary.archived, maxReplyRounds: summary.maxReplyRounds, orchestrationMode: summary.orchestrationMode,
  443. agents: values(source.agents).map(normalizeGroupAgent), runs: values(source.runs).map(normalizeGroupRun),
  444. pendingInteractions: values(pick(source, 'pendingInteractions', 'pending_interactions')).map(normalizeGroupInteraction),
  445. latestCursor: number(pick(source, 'latestCursor', 'latest_cursor')),
  446. }
  447. }
  448. export function inferFileKind(mimeType: string, name: string): FileKind {
  449. const mime = mimeType.toLowerCase().split(';')[0]
  450. if (mime.startsWith('image/')) return 'image'
  451. if (mime.startsWith('video/')) return 'video'
  452. if (mime.startsWith('audio/')) return 'audio'
  453. if (mime.startsWith('text/') || /\.(pdf|docx?|xlsx?|pptx?|md|txt|csv|json|rtf|pages|numbers|key)$/i.test(name)) return 'document'
  454. return 'other'
  455. }
  456. export function normalizeFile(value: unknown, fallbackProfile?: string): FileLibraryItem {
  457. const source = record(value)
  458. const id = string(pick(source, 'id', 'fileId', 'file_id'))
  459. const mimeType = string(pick(source, 'mimeType', 'mime_type'), 'application/octet-stream')
  460. const name = string(source.name)
  461. const origins = values(source.origins).map(item => {
  462. const origin = record(item)
  463. return {
  464. id: origin.id as number | string | undefined,
  465. profile: string(origin.profile) || fallbackProfile || undefined,
  466. sessionId: string(pick(origin, 'sessionId', 'session_id')) || undefined,
  467. sessionTitle: string(pick(origin, 'sessionTitle', 'session_title')) || undefined,
  468. messageId: string(pick(origin, 'messageId', 'message_id')) || undefined,
  469. authorKind: string(pick(origin, 'authorKind', 'author_kind')) as FileLibraryItem['origins'][number]['authorKind'] || undefined,
  470. authorName: string(pick(origin, 'authorName', 'author_name')) || undefined,
  471. observedAt: number(pick(origin, 'observedAt', 'observed_at')) || undefined,
  472. originalPath: string(pick(origin, 'originalPath', 'original_path')) || undefined,
  473. referencePath: string(pick(origin, 'referencePath', 'reference_path')) || undefined,
  474. }
  475. })
  476. const profile = origins.find(origin => origin.profile)?.profile
  477. const profileQuery = profile ? `?profile=${encodeURIComponent(profile)}` : ''
  478. return {
  479. id, path: string(source.path), name, extension: string(pick(source, 'extension', 'extensionName', 'extension_name')),
  480. mimeType, size: number(source.size), modifiedAt: number(pick(source, 'modifiedAt', 'modified_at')),
  481. exists: bool(source.exists, true), availability: string(source.availability) as FileLibraryItem['availability'] || undefined,
  482. archiveStatus: string(pick(source, 'archiveStatus', 'archive_status')) || undefined,
  483. firstSeenAt: number(pick(source, 'firstSeenAt', 'first_seen_at')) || undefined,
  484. lastSeenAt: number(pick(source, 'lastSeenAt', 'last_seen_at')) || undefined,
  485. messageTimestamp: pick(source, 'messageTimestamp', 'message_timestamp') == null ? null : number(pick(source, 'messageTimestamp', 'message_timestamp')),
  486. origins, kind: inferFileKind(mimeType, name),
  487. previewUrl: string(pick(source, 'previewUrl', 'preview_url'))
  488. || `/api/app/files/${encodeURIComponent(id)}/preview${profileQuery}`,
  489. downloadUrl: string(pick(source, 'downloadUrl', 'download_url'))
  490. || `/api/app/files/${encodeURIComponent(id)}/download${profileQuery}`,
  491. }
  492. }