mattrguerra/cortexmainPrivate on GitHub, published here in full

mockClient.ts

Language TypeScript19.9 KB588 lines

src/ipc/mock/mockClient.ts

  1. // An in-memory stand-in for the Rust shell, seeded with invented chats.
  2. // Streaming is simulated word by word; cost uses the same arithmetic as
  3. // cortex-core's cost.rs, so the numbers shown are the numbers Rust would store.
  4. import type { AppEvent, IpcClient } from '../client'
  5. import type { AttachmentMeta } from '../gen/AttachmentMeta'
  6. import type { Conversation } from '../gen/Conversation'
  7. import type { Message } from '../gen/Message'
  8. import type { Preset } from '../gen/Preset'
  9. import type { Prices } from '../gen/Prices'
  10. import type { Provider } from '../gen/Provider'
  11. import type { Settings } from '../gen/Settings'
  12. import type { Usage } from '../gen/Usage'
  13. import { IMAGE_EXT, NANO } from '../../lib/format'
  14. import { cannedReply, chats, models, PLANS_SVG, providers as seedProviders } from './fixtures'
  15. export function costNano(u: Usage, p: Prices): number {
  16. const cached = Math.min(Math.max(u.cached_tokens, 0), Math.max(u.prompt_tokens, 0))
  17. const uncached = Math.max(u.prompt_tokens - cached, 0)
  18. const cachePrice = p.cache_read_per_m ?? p.input_per_m
  19. return Math.round(
  20. uncached * p.input_per_m * 1000 +
  21. cached * cachePrice * 1000 +
  22. Math.max(u.completion_tokens, 0) * p.output_per_m * 1000,
  23. )
  24. }
  25. export function estimateTokens(text: string): number {
  26. let cjk = 0
  27. let other = 0
  28. for (const ch of text) {
  29. const c = ch.codePointAt(0) ?? 0
  30. if (
  31. (c >= 0x3040 && c <= 0x30ff) ||
  32. (c >= 0x3400 && c <= 0x9fff) ||
  33. (c >= 0xac00 && c <= 0xd7af) ||
  34. (c >= 0xf900 && c <= 0xfaff)
  35. )
  36. cjk++
  37. else other++
  38. }
  39. return cjk + Math.floor((other + 3) / 4)
  40. }
  41. const defaultSettings: Settings = {
  42. theme: 'system',
  43. reduce_transparency: false,
  44. material: 'sidebar',
  45. glass_tint: 100,
  46. shortcut: 'CommandOrControl+Shift+Space',
  47. default_provider_id: 'deepinfra',
  48. default_model_id: 'Qwen/Qwen3-235B-A22B-Instruct-2507',
  49. quick_provider_id: 'deepinfra',
  50. quick_model_id: 'google/gemma-3-12b-it',
  51. monthly_budget_usd: 10,
  52. confirm_over_budget: false,
  53. vault_dir: '~/Vault/Chats',
  54. image_links: 'wiki',
  55. favorite_models: ['Qwen/Qwen3-235B-A22B-Instruct-2507', 'google/gemma-3-27b-it'],
  56. vision_models: [],
  57. send_on_enter: true,
  58. }
  59. let seq = 0
  60. const id = (p: string) => `${p}-${(++seq).toString(36).padStart(4, '0')}`
  61. export interface MockOptions {
  62. /** Milliseconds between streamed words. */
  63. delay?: number
  64. empty?: boolean
  65. hasKey?: boolean
  66. }
  67. export function createMockClient(opts: MockOptions = {}): IpcClient {
  68. const delay = opts.delay ?? 22
  69. const now = Date.now()
  70. const providers: Provider[] = seedProviders.map((p) => ({ ...p }))
  71. const conversations = new Map<string, Conversation>()
  72. const messages = new Map<string, Message[]>()
  73. const attachmentData = new Map<string, { meta: AttachmentMeta; bytes: Uint8Array }>()
  74. const presets: Preset[] = [
  75. {
  76. id: 'preset-editor',
  77. name: 'Editor',
  78. system_prompt: "Edit for brevity. Keep the writer's voice. Prefer short sentences.",
  79. provider_id: null,
  80. model_id: null,
  81. temperature: 0.3,
  82. max_tokens: null,
  83. },
  84. {
  85. id: 'preset-shell',
  86. name: 'Shell reviewer',
  87. system_prompt: 'You are a careful shell reviewer. Prefer small, safe changes.',
  88. provider_id: 'deepinfra',
  89. model_id: 'Qwen/Qwen3-235B-A22B-Instruct-2507',
  90. temperature: null,
  91. max_tokens: null,
  92. },
  93. ]
  94. let settings: Settings = { ...defaultSettings }
  95. const keys = new Set<string>(opts.hasKey === false ? [] : ['deepinfra'])
  96. const listeners = new Map<AppEvent, Set<(p: unknown) => void>>()
  97. const streams = new Map<string, { cancelled: boolean }>()
  98. const enc = new TextEncoder()
  99. const priceOf = (provider: string, model: string): Prices | null =>
  100. models[provider]?.find((x) => x.id === model)?.prices ?? null
  101. const msg = (
  102. conversation_id: string,
  103. role: string,
  104. content: string,
  105. created_at: number,
  106. extra: Partial<Message> = {},
  107. ): Message => ({
  108. id: id('m'),
  109. conversation_id,
  110. role,
  111. content,
  112. reasoning: null,
  113. status: 'complete',
  114. error_kind: null,
  115. error_message: null,
  116. provider_id: null,
  117. model_id: null,
  118. usage: { prompt_tokens: 0, completion_tokens: 0, cached_tokens: 0 },
  119. usage_source: 'none',
  120. prices: null,
  121. cost_nanousd: 0,
  122. finish_reason: null,
  123. ttft_ms: null,
  124. duration_ms: null,
  125. created_at,
  126. attachments: [],
  127. ...extra,
  128. })
  129. if (!opts.empty) {
  130. for (const c of chats) {
  131. const cid = id('c')
  132. const created = now - c.ageMs
  133. conversations.set(cid, {
  134. id: cid,
  135. title: c.title,
  136. provider_id: c.provider,
  137. model_id: c.model,
  138. preset_id: null,
  139. system_prompt: c.system,
  140. origin: 'main',
  141. pinned: !!c.pinned,
  142. archived: false,
  143. created_at: created,
  144. updated_at: created + 60_000 * c.turns.length,
  145. })
  146. const list: Message[] = []
  147. c.turns.forEach((t, i) => {
  148. const at = created + i * 60_000
  149. const user = msg(cid, 'user', t.user, at)
  150. if (t.image) {
  151. const aid = id('a')
  152. const meta: AttachmentMeta = {
  153. id: aid,
  154. kind: 'image',
  155. filename: t.image.filename,
  156. mime: 'image/svg+xml',
  157. byte_size: PLANS_SVG.length,
  158. width: t.image.width,
  159. height: t.image.height,
  160. }
  161. attachmentData.set(aid, { meta, bytes: enc.encode(PLANS_SVG) })
  162. user.attachments.push(meta)
  163. }
  164. list.push(user)
  165. const prices = priceOf(c.provider, c.model)
  166. const usage = { prompt_tokens: t.prompt, completion_tokens: t.completion, cached_tokens: 0 }
  167. list.push(
  168. msg(cid, 'assistant', t.assistant, at + 4000, {
  169. reasoning: t.reasoning ?? null,
  170. provider_id: c.provider,
  171. model_id: c.model,
  172. usage,
  173. usage_source: t.estimated ? 'estimated' : 'reported',
  174. prices,
  175. cost_nanousd: prices ? costNano(usage, prices) : 0,
  176. finish_reason: 'stop',
  177. ttft_ms: 380 + i * 40,
  178. duration_ms: 1800 + t.completion * 9,
  179. }),
  180. )
  181. })
  182. messages.set(cid, list)
  183. }
  184. }
  185. const emit = (event: AppEvent, payload: unknown = null) => listeners.get(event)?.forEach((h) => h(payload))
  186. const summaries = (includeArchived: boolean) =>
  187. [...conversations.values()]
  188. .filter((c) => includeArchived || !c.archived)
  189. .map((c) => {
  190. const ms = messages.get(c.id) ?? []
  191. return {
  192. id: c.id,
  193. title: c.title,
  194. model_id: c.model_id,
  195. pinned: c.pinned,
  196. origin: c.origin,
  197. updated_at: c.updated_at,
  198. cost_nanousd: ms.reduce((s, m) => s + m.cost_nanousd, 0),
  199. cost_estimated: ms.some((m) => m.usage_source === 'estimated'),
  200. message_count: ms.length,
  201. has_usage: ms.some((m) => m.role === 'assistant' && m.usage_source !== 'none'),
  202. }
  203. })
  204. .sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updated_at - a.updated_at)
  205. const monthStart = () => {
  206. const d = new Date()
  207. return new Date(d.getFullYear(), d.getMonth(), 1).getTime()
  208. }
  209. const allAssistant = () => [...messages.values()].flat().filter((m) => m.role === 'assistant')
  210. const historyTokens = (cid: string | null) => {
  211. if (!cid) return 0
  212. const c = conversations.get(cid)
  213. const ms = messages.get(cid) ?? []
  214. return (
  215. (c?.system_prompt ? estimateTokens(c.system_prompt) + 4 : 0) +
  216. ms
  217. .filter((m) => m.status === 'complete')
  218. .reduce(
  219. (s, m) => s + estimateTokens(m.content) + 4 + m.attachments.filter((a) => a.kind === 'image').length * 765,
  220. 0,
  221. )
  222. )
  223. }
  224. const client: IpcClient = {
  225. appInfo: async () => ({
  226. version: '0.1.0',
  227. platform: 'browser',
  228. vibrancy: false,
  229. data_dir: '(sample data, in memory)',
  230. }),
  231. windowReady: async () => {},
  232. showMain: async () => {},
  233. listProviders: async () => providers.map((p) => ({ ...p })),
  234. saveProvider: async (p) => {
  235. const saved = { ...p, id: p.id || id('p'), kind: p.id === 'deepinfra' ? 'deepinfra' : 'openai_compatible' }
  236. const i = providers.findIndex((x) => x.id === saved.id)
  237. if (i >= 0) providers[i] = saved
  238. else providers.push(saved)
  239. models[saved.id] ??= []
  240. return saved
  241. },
  242. deleteProvider: async (pid) => {
  243. if (pid === 'deepinfra') return
  244. const i = providers.findIndex((x) => x.id === pid)
  245. if (i >= 0) providers.splice(i, 1)
  246. },
  247. setApiKey: async (pid, key) => {
  248. if (!key.trim()) throw new Error('The key is empty.')
  249. if (key.trim() === 'bad-key')
  250. throw new Error('DeepInfra rejected this key, so it was not saved. Copy the whole key again and paste it.')
  251. keys.add(pid)
  252. return { accepted: true, detail: null }
  253. },
  254. clearApiKey: async (pid) => {
  255. keys.delete(pid)
  256. },
  257. hasApiKey: async (pid) => keys.has(pid),
  258. listModels: async (pid) => ({
  259. models: models[pid] ?? [],
  260. source: pid === 'deepinfra' ? 'live' : 'local',
  261. fetched_at: now - 3 * 3600_000,
  262. snapshot_date: null,
  263. warning: null,
  264. }),
  265. refreshModels: async (pid) => ({
  266. models: models[pid] ?? [],
  267. source: pid === 'deepinfra' ? 'live' : 'local',
  268. fetched_at: Date.now(),
  269. snapshot_date: null,
  270. warning: null,
  271. }),
  272. listConversations: async (inc) => summaries(inc),
  273. getConversation: async (cid) => {
  274. const c = conversations.get(cid)
  275. if (!c) throw new Error(`conversation ${cid} not found`)
  276. return { conversation: { ...c }, messages: (messages.get(cid) ?? []).map((m) => ({ ...m })) }
  277. },
  278. createConversation: async (provider_id, model_id, preset_id, origin) => {
  279. const preset = presets.find((p) => p.id === preset_id)
  280. const t = Date.now()
  281. const c: Conversation = {
  282. id: id('c'),
  283. title: 'New chat',
  284. provider_id: preset?.provider_id ?? provider_id,
  285. model_id: preset?.model_id ?? model_id,
  286. preset_id,
  287. system_prompt: preset?.system_prompt ?? '',
  288. origin,
  289. pinned: false,
  290. archived: false,
  291. created_at: t,
  292. updated_at: t,
  293. }
  294. conversations.set(c.id, c)
  295. messages.set(c.id, [])
  296. return { ...c }
  297. },
  298. renameConversation: async (cid, title) => {
  299. const c = conversations.get(cid)
  300. if (c) c.title = title.trim() || 'New chat'
  301. },
  302. setPinned: async (cid, pinned) => {
  303. const c = conversations.get(cid)
  304. if (c) c.pinned = pinned
  305. },
  306. setArchived: async (cid, archived) => {
  307. const c = conversations.get(cid)
  308. if (c) c.archived = archived
  309. },
  310. deleteConversation: async (cid) => {
  311. conversations.delete(cid)
  312. messages.delete(cid)
  313. },
  314. setConversationModel: async (cid, provider_id, model_id) => {
  315. const c = conversations.get(cid)
  316. if (c) Object.assign(c, { provider_id, model_id })
  317. },
  318. setSystemPrompt: async (cid, system_prompt, preset_id) => {
  319. const c = conversations.get(cid)
  320. if (c) Object.assign(c, { system_prompt, preset_id })
  321. },
  322. sendMessage: async (req, onEvent) => {
  323. const c = conversations.get(req.conversation_id)
  324. if (!c) throw new Error('conversation not found')
  325. const provider = providers.find((p) => p.id === c.provider_id)
  326. if (provider?.requires_key && !keys.has(provider.id))
  327. throw new Error(`Add your ${provider.name} API key in Settings first.`)
  328. const list = messages.get(c.id)!
  329. const t = Date.now()
  330. let user: Message | null = null
  331. if (req.text.trim() || req.attachment_ids.length) {
  332. user = msg(c.id, 'user', req.text, t)
  333. user.attachments = req.attachment_ids
  334. .map((a) => attachmentData.get(a)?.meta)
  335. .filter((a): a is AttachmentMeta => !!a)
  336. list.push(user)
  337. if (c.title === 'New chat')
  338. c.title =
  339. req.text
  340. .split('\n')
  341. .find((l) => l.trim())
  342. ?.trim()
  343. .slice(0, 60) || 'New chat'
  344. }
  345. const lastUser = [...list].reverse().find((m) => m.role === 'user')
  346. const images = lastUser?.attachments.filter((a) => a.kind === 'image').length ?? 0
  347. const assistant = msg(c.id, 'assistant', '', t + 1, {
  348. status: 'streaming',
  349. provider_id: c.provider_id,
  350. model_id: c.model_id,
  351. })
  352. list.push(assistant)
  353. c.updated_at = t
  354. const streamId = id('s')
  355. const stream = { cancelled: false }
  356. streams.set(streamId, stream)
  357. const reply = cannedReply(lastUser?.content ?? '', images)
  358. const words = reply.split(/(?<= )/)
  359. const prompt = historyTokens(c.id)
  360. ;(async () => {
  361. let text = ''
  362. const started = Date.now()
  363. for (const w of words) {
  364. if (stream.cancelled) break
  365. await new Promise((r) => setTimeout(r, delay))
  366. if (stream.cancelled) break
  367. text += w
  368. onEvent({ type: 'delta', text: w })
  369. }
  370. const local = !provider?.requires_key
  371. const usage = { prompt_tokens: prompt, completion_tokens: estimateTokens(text), cached_tokens: 0 }
  372. const source = local || stream.cancelled ? 'estimated' : 'reported'
  373. if (source === 'reported') onEvent({ type: 'usage', usage })
  374. const prices = priceOf(c.provider_id, c.model_id)
  375. Object.assign(assistant, {
  376. content: text,
  377. status: stream.cancelled ? 'cancelled' : 'complete',
  378. usage,
  379. usage_source: source,
  380. prices,
  381. cost_nanousd: prices ? costNano(usage, prices) : 0,
  382. finish_reason: stream.cancelled ? null : 'stop',
  383. ttft_ms: delay + 300,
  384. duration_ms: Date.now() - started,
  385. } satisfies Partial<Message>)
  386. streams.delete(streamId)
  387. onEvent({ type: 'done', message: { ...assistant } })
  388. emit('conversations-changed')
  389. })()
  390. return { stream_id: streamId, user_message: user ? { ...user } : null, assistant_message: { ...assistant } }
  391. },
  392. cancelStream: async (sid) => {
  393. const s = streams.get(sid)
  394. if (s) s.cancelled = true
  395. },
  396. truncateFrom: async (cid, mid) => {
  397. const list = messages.get(cid) ?? []
  398. const i = list.findIndex((m) => m.id === mid)
  399. if (i >= 0) list.splice(i)
  400. },
  401. estimatePrompt: async (a) => {
  402. const prompt_tokens =
  403. historyTokens(a.conversationId) +
  404. estimateTokens(a.text) +
  405. estimateTokens(a.extraText ?? '') +
  406. 4 +
  407. a.imageCount * 765
  408. const model = models[a.providerId]?.find((x) => x.id === a.modelId)
  409. return {
  410. prompt_tokens,
  411. prompt_cost_nanousd: model
  412. ? costNano({ prompt_tokens, completion_tokens: 0, cached_tokens: 0 }, model.prices)
  413. : 0,
  414. context_length: model?.context_length ?? null,
  415. }
  416. },
  417. search: async (q) => {
  418. const words = q.toLowerCase().split(/\W+/).filter(Boolean)
  419. if (!words.length) return []
  420. const hits = []
  421. for (const c of conversations.values()) {
  422. if (c.archived) continue
  423. for (const m of messages.get(c.id) ?? []) {
  424. const low = m.content.toLowerCase()
  425. if (!words.every((w) => low.includes(w))) continue
  426. const at = low.indexOf(words[0])
  427. const start = Math.max(0, at - 40)
  428. const raw = m.content.slice(start, at + 80)
  429. const marked = raw.replace(
  430. new RegExp(`(${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi'),
  431. '\u0001$1\u0002',
  432. )
  433. hits.push({
  434. conversation_id: c.id,
  435. title: c.title,
  436. message_id: m.id,
  437. snippet: (start > 0 ? '...' : '') + marked + '...',
  438. created_at: m.created_at,
  439. })
  440. }
  441. }
  442. return hits.slice(0, 40)
  443. },
  444. spendSummary: async () => {
  445. const since = monthStart()
  446. const ms = allAssistant().filter((m) => m.created_at >= since)
  447. const month = ms.reduce((s, m) => s + m.cost_nanousd, 0)
  448. const budget = settings.monthly_budget_usd
  449. const spent = month / NANO
  450. return {
  451. month_nanousd: month,
  452. month_estimated: ms.some((m) => m.usage_source === 'estimated'),
  453. budget_usd: budget,
  454. budget_level: budget && budget > 0 ? (spent >= budget ? 2 : spent >= budget * 0.8 ? 1 : 0) : 0,
  455. }
  456. },
  457. usageReport: async (days) => {
  458. const since = days ? Date.now() - days * 86_400_000 : monthStart()
  459. const ms = allAssistant().filter((m) => m.created_at >= since)
  460. const group = (key: (m: Message) => string) => {
  461. const g = new Map<
  462. string,
  463. {
  464. key: string
  465. cost_nanousd: number
  466. prompt_tokens: number
  467. completion_tokens: number
  468. messages: number
  469. estimated: boolean
  470. }
  471. >()
  472. for (const m of ms) {
  473. const k = key(m)
  474. const r = g.get(k) ?? {
  475. key: k,
  476. cost_nanousd: 0,
  477. prompt_tokens: 0,
  478. completion_tokens: 0,
  479. messages: 0,
  480. estimated: false,
  481. }
  482. r.cost_nanousd += m.cost_nanousd
  483. r.prompt_tokens += m.usage.prompt_tokens
  484. r.completion_tokens += m.usage.completion_tokens
  485. r.messages += 1
  486. r.estimated ||= m.usage_source === 'estimated'
  487. g.set(k, r)
  488. }
  489. return [...g.values()].sort((a, b) => b.cost_nanousd - a.cost_nanousd)
  490. }
  491. const day = (m: Message) => {
  492. const d = new Date(m.created_at)
  493. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
  494. }
  495. return {
  496. by_model: group((m) => m.model_id ?? 'unknown'),
  497. by_day: group(day).sort((a, b) => a.key.localeCompare(b.key)),
  498. since_ms: since,
  499. }
  500. },
  501. listPresets: async () => presets.map((p) => ({ ...p })),
  502. savePreset: async (p) => {
  503. if (!p.name.trim()) throw new Error('Give the preset a name.')
  504. if (presets.some((x) => x.name === p.name.trim() && x.id !== p.id))
  505. throw new Error('A preset with that name already exists.')
  506. const saved = { ...p, id: p.id || id('preset') }
  507. const i = presets.findIndex((x) => x.id === saved.id)
  508. if (i >= 0) presets[i] = saved
  509. else presets.push(saved)
  510. return saved
  511. },
  512. deletePreset: async (pid) => {
  513. const i = presets.findIndex((x) => x.id === pid)
  514. if (i >= 0) presets.splice(i, 1)
  515. },
  516. getSettings: async () => ({ ...settings }),
  517. saveSettings: async (s) => {
  518. settings = { ...s }
  519. emit('settings-changed', settings)
  520. return { settings: { ...settings }, shortcut_error: null }
  521. },
  522. pickAttachments: async () => [],
  523. attachBytes: async (filename, bytes) => {
  524. const isImage = IMAGE_EXT.test(filename)
  525. const meta: AttachmentMeta = {
  526. id: id('a'),
  527. kind: isImage ? 'image' : 'text',
  528. filename,
  529. mime: isImage ? 'image/png' : 'text/plain',
  530. byte_size: bytes.length,
  531. width: isImage ? 800 : null,
  532. height: isImage ? 600 : null,
  533. }
  534. attachmentData.set(meta.id, { meta, bytes })
  535. return meta
  536. },
  537. attachmentBytes: async (aid) => {
  538. const b = attachmentData.get(aid)?.bytes ?? new Uint8Array()
  539. return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer
  540. },
  541. discardAttachment: async (aid) => {
  542. attachmentData.delete(aid)
  543. },
  544. exportConversation: async (cid) => ({
  545. path: `${settings.vault_dir ?? '~/Vault'}/${conversations.get(cid)?.title ?? 'Chat'}.md`,
  546. files_copied: 0,
  547. }),
  548. chooseVaultFolder: async () => settings.vault_dir,
  549. quickOpenInMain: async () => {},
  550. hideQuick: async () => {},
  551. resizeQuick: async () => {},
  552. openUrl: async (url) => {
  553. window.open(url, '_blank', 'noopener')
  554. },
  555. on: async (event, handler) => {
  556. const set = listeners.get(event) ?? new Set()
  557. set.add(handler)
  558. listeners.set(event, set)
  559. return () => set.delete(handler)
  560. },
  561. }
  562. return client
  563. }