chat.svelte.ts 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765
  1. import { DatabaseStore } from '$lib/stores/database';
  2. import { chatService, slotsService } from '$lib/services';
  3. import { config } from '$lib/stores/settings.svelte';
  4. import { serverStore } from '$lib/stores/server.svelte';
  5. import { normalizeModelName } from '$lib/utils/model-names';
  6. import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/utils/branching';
  7. import { browser } from '$app/environment';
  8. import { goto } from '$app/navigation';
  9. import { toast } from 'svelte-sonner';
  10. import { SvelteMap } from 'svelte/reactivity';
  11. import type { ExportedConversations } from '$lib/types/database';
  12. /**
  13. * ChatStore - Central state management for chat conversations and AI interactions
  14. *
  15. * This store manages the complete chat experience including:
  16. * - Conversation lifecycle (create, load, delete, update)
  17. * - Message management with branching support for conversation trees
  18. * - Real-time AI response streaming with reasoning content support
  19. * - File attachment handling and processing
  20. * - Context error management and recovery
  21. * - Database persistence through DatabaseStore integration
  22. *
  23. * **Architecture & Relationships:**
  24. * - **ChatService**: Handles low-level API communication with AI models
  25. * - ChatStore orchestrates ChatService for streaming responses
  26. * - ChatService provides abort capabilities and error handling
  27. * - ChatStore manages the UI state while ChatService handles network layer
  28. *
  29. * - **DatabaseStore**: Provides persistent storage for conversations and messages
  30. * - ChatStore uses DatabaseStore for all CRUD operations
  31. * - Maintains referential integrity for conversation trees
  32. * - Handles message branching and parent-child relationships
  33. *
  34. * - **SlotsService**: Monitors server resource usage during AI generation
  35. * - ChatStore coordinates slots polling during streaming
  36. * - Provides real-time feedback on server capacity
  37. *
  38. * **Key Features:**
  39. * - Reactive state management using Svelte 5 runes ($state)
  40. * - Conversation branching for exploring different response paths
  41. * - Streaming AI responses with real-time content updates
  42. * - File attachment support (images, PDFs, text files, audio)
  43. * - Partial response saving when generation is interrupted
  44. * - Message editing with automatic response regeneration
  45. */
  46. class ChatStore {
  47. activeConversation = $state<DatabaseConversation | null>(null);
  48. activeMessages = $state<DatabaseMessage[]>([]);
  49. conversations = $state<DatabaseConversation[]>([]);
  50. currentResponse = $state('');
  51. errorDialogState = $state<{ type: 'timeout' | 'server'; message: string } | null>(null);
  52. isInitialized = $state(false);
  53. isLoading = $state(false);
  54. conversationLoadingStates = new SvelteMap<string, boolean>();
  55. conversationStreamingStates = new SvelteMap<string, { response: string; messageId: string }>();
  56. titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
  57. constructor() {
  58. if (browser) {
  59. this.initialize();
  60. }
  61. }
  62. /**
  63. * Initializes the chat store by loading conversations from the database
  64. * Sets up the initial state and loads existing conversations
  65. */
  66. async initialize(): Promise<void> {
  67. try {
  68. await this.loadConversations();
  69. this.isInitialized = true;
  70. } catch (error) {
  71. console.error('Failed to initialize chat store:', error);
  72. }
  73. }
  74. /**
  75. * Loads all conversations from the database
  76. * Refreshes the conversations list from persistent storage
  77. */
  78. async loadConversations(): Promise<void> {
  79. this.conversations = await DatabaseStore.getAllConversations();
  80. }
  81. /**
  82. * Creates a new conversation and navigates to it
  83. * @param name - Optional name for the conversation, defaults to timestamped name
  84. * @returns The ID of the created conversation
  85. */
  86. async createConversation(name?: string): Promise<string> {
  87. const conversationName = name || `Chat ${new Date().toLocaleString()}`;
  88. const conversation = await DatabaseStore.createConversation(conversationName);
  89. this.conversations.unshift(conversation);
  90. this.activeConversation = conversation;
  91. this.activeMessages = [];
  92. slotsService.setActiveConversation(conversation.id);
  93. const isConvLoading = this.isConversationLoading(conversation.id);
  94. this.isLoading = isConvLoading;
  95. this.currentResponse = '';
  96. await goto(`#/chat/${conversation.id}`);
  97. return conversation.id;
  98. }
  99. /**
  100. * Loads a specific conversation and its messages
  101. * @param convId - The conversation ID to load
  102. * @returns True if conversation was loaded successfully, false otherwise
  103. */
  104. async loadConversation(convId: string): Promise<boolean> {
  105. try {
  106. const conversation = await DatabaseStore.getConversation(convId);
  107. if (!conversation) {
  108. return false;
  109. }
  110. this.activeConversation = conversation;
  111. slotsService.setActiveConversation(convId);
  112. const isConvLoading = this.isConversationLoading(convId);
  113. this.isLoading = isConvLoading;
  114. const streamingState = this.getConversationStreaming(convId);
  115. this.currentResponse = streamingState?.response || '';
  116. if (conversation.currNode) {
  117. const allMessages = await DatabaseStore.getConversationMessages(convId);
  118. this.activeMessages = filterByLeafNodeId(
  119. allMessages,
  120. conversation.currNode,
  121. false
  122. ) as DatabaseMessage[];
  123. } else {
  124. // Load all messages for conversations without currNode (backward compatibility)
  125. this.activeMessages = await DatabaseStore.getConversationMessages(convId);
  126. }
  127. return true;
  128. } catch (error) {
  129. console.error('Failed to load conversation:', error);
  130. return false;
  131. }
  132. }
  133. /**
  134. * Adds a new message to the active conversation
  135. * @param role - The role of the message sender (user/assistant)
  136. * @param content - The message content
  137. * @param type - The message type, defaults to 'text'
  138. * @param parent - Parent message ID, defaults to '-1' for auto-detection
  139. * @param extras - Optional extra data (files, attachments, etc.)
  140. * @returns The created message or null if failed
  141. */
  142. async addMessage(
  143. role: ChatRole,
  144. content: string,
  145. type: ChatMessageType = 'text',
  146. parent: string = '-1',
  147. extras?: DatabaseMessageExtra[]
  148. ): Promise<DatabaseMessage | null> {
  149. if (!this.activeConversation) {
  150. console.error('No active conversation when trying to add message');
  151. return null;
  152. }
  153. try {
  154. let parentId: string | null = null;
  155. if (parent === '-1') {
  156. if (this.activeMessages.length > 0) {
  157. parentId = this.activeMessages[this.activeMessages.length - 1].id;
  158. } else {
  159. const allMessages = await DatabaseStore.getConversationMessages(
  160. this.activeConversation.id
  161. );
  162. const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root');
  163. if (!rootMessage) {
  164. const rootId = await DatabaseStore.createRootMessage(this.activeConversation.id);
  165. parentId = rootId;
  166. } else {
  167. parentId = rootMessage.id;
  168. }
  169. }
  170. } else {
  171. parentId = parent;
  172. }
  173. const message = await DatabaseStore.createMessageBranch(
  174. {
  175. convId: this.activeConversation.id,
  176. role,
  177. content,
  178. type,
  179. timestamp: Date.now(),
  180. thinking: '',
  181. toolCalls: '',
  182. children: [],
  183. extra: extras
  184. },
  185. parentId
  186. );
  187. this.activeMessages.push(message);
  188. await DatabaseStore.updateCurrentNode(this.activeConversation.id, message.id);
  189. this.activeConversation.currNode = message.id;
  190. this.updateConversationTimestamp();
  191. return message;
  192. } catch (error) {
  193. console.error('Failed to add message:', error);
  194. return null;
  195. }
  196. }
  197. /**
  198. * Gets API options from current configuration settings
  199. * Converts settings store values to API-compatible format
  200. * @returns API options object for chat completion requests
  201. */
  202. private getApiOptions(): Record<string, unknown> {
  203. const currentConfig = config();
  204. const hasValue = (value: unknown): boolean =>
  205. value !== undefined && value !== null && value !== '';
  206. const apiOptions: Record<string, unknown> = {
  207. stream: true,
  208. timings_per_token: true
  209. };
  210. if (hasValue(currentConfig.temperature)) {
  211. apiOptions.temperature = Number(currentConfig.temperature);
  212. }
  213. if (hasValue(currentConfig.max_tokens)) {
  214. apiOptions.max_tokens = Number(currentConfig.max_tokens);
  215. }
  216. if (hasValue(currentConfig.dynatemp_range)) {
  217. apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range);
  218. }
  219. if (hasValue(currentConfig.dynatemp_exponent)) {
  220. apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent);
  221. }
  222. if (hasValue(currentConfig.top_k)) {
  223. apiOptions.top_k = Number(currentConfig.top_k);
  224. }
  225. if (hasValue(currentConfig.top_p)) {
  226. apiOptions.top_p = Number(currentConfig.top_p);
  227. }
  228. if (hasValue(currentConfig.min_p)) {
  229. apiOptions.min_p = Number(currentConfig.min_p);
  230. }
  231. if (hasValue(currentConfig.xtc_probability)) {
  232. apiOptions.xtc_probability = Number(currentConfig.xtc_probability);
  233. }
  234. if (hasValue(currentConfig.xtc_threshold)) {
  235. apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold);
  236. }
  237. if (hasValue(currentConfig.typ_p)) {
  238. apiOptions.typ_p = Number(currentConfig.typ_p);
  239. }
  240. if (hasValue(currentConfig.repeat_last_n)) {
  241. apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n);
  242. }
  243. if (hasValue(currentConfig.repeat_penalty)) {
  244. apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty);
  245. }
  246. if (hasValue(currentConfig.presence_penalty)) {
  247. apiOptions.presence_penalty = Number(currentConfig.presence_penalty);
  248. }
  249. if (hasValue(currentConfig.frequency_penalty)) {
  250. apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty);
  251. }
  252. if (hasValue(currentConfig.dry_multiplier)) {
  253. apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier);
  254. }
  255. if (hasValue(currentConfig.dry_base)) {
  256. apiOptions.dry_base = Number(currentConfig.dry_base);
  257. }
  258. if (hasValue(currentConfig.dry_allowed_length)) {
  259. apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length);
  260. }
  261. if (hasValue(currentConfig.dry_penalty_last_n)) {
  262. apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n);
  263. }
  264. if (currentConfig.samplers) {
  265. apiOptions.samplers = currentConfig.samplers;
  266. }
  267. if (currentConfig.custom) {
  268. apiOptions.custom = currentConfig.custom;
  269. }
  270. return apiOptions;
  271. }
  272. /**
  273. * Helper methods for per-conversation loading state management
  274. */
  275. private setConversationLoading(convId: string, loading: boolean): void {
  276. if (loading) {
  277. this.conversationLoadingStates.set(convId, true);
  278. if (this.activeConversation?.id === convId) {
  279. this.isLoading = true;
  280. }
  281. } else {
  282. this.conversationLoadingStates.delete(convId);
  283. if (this.activeConversation?.id === convId) {
  284. this.isLoading = false;
  285. }
  286. }
  287. }
  288. private isConversationLoading(convId: string): boolean {
  289. return this.conversationLoadingStates.get(convId) || false;
  290. }
  291. private setConversationStreaming(convId: string, response: string, messageId: string): void {
  292. this.conversationStreamingStates.set(convId, { response, messageId });
  293. if (this.activeConversation?.id === convId) {
  294. this.currentResponse = response;
  295. }
  296. }
  297. private clearConversationStreaming(convId: string): void {
  298. this.conversationStreamingStates.delete(convId);
  299. if (this.activeConversation?.id === convId) {
  300. this.currentResponse = '';
  301. }
  302. }
  303. private getConversationStreaming(
  304. convId: string
  305. ): { response: string; messageId: string } | undefined {
  306. return this.conversationStreamingStates.get(convId);
  307. }
  308. /**
  309. * Handles streaming chat completion with the AI model
  310. * @param allMessages - All messages in the conversation
  311. * @param assistantMessage - The assistant message to stream content into
  312. * @param onComplete - Optional callback when streaming completes
  313. * @param onError - Optional callback when an error occurs
  314. */
  315. private async streamChatCompletion(
  316. allMessages: DatabaseMessage[],
  317. assistantMessage: DatabaseMessage,
  318. onComplete?: (content: string) => Promise<void>,
  319. onError?: (error: Error) => void
  320. ): Promise<void> {
  321. let streamedContent = '';
  322. let streamedReasoningContent = '';
  323. let streamedToolCallContent = '';
  324. let resolvedModel: string | null = null;
  325. let modelPersisted = false;
  326. const currentConfig = config();
  327. const preferServerPropsModel = !currentConfig.modelSelectorEnabled;
  328. let serverPropsRefreshed = false;
  329. let updateModelFromServerProps: ((persistImmediately?: boolean) => void) | null = null;
  330. const refreshServerPropsOnce = () => {
  331. if (serverPropsRefreshed) {
  332. return;
  333. }
  334. serverPropsRefreshed = true;
  335. const hasExistingProps = serverStore.serverProps !== null;
  336. serverStore
  337. .fetchServerProps({ silent: hasExistingProps })
  338. .then(() => {
  339. updateModelFromServerProps?.(true);
  340. })
  341. .catch((error) => {
  342. console.warn('Failed to refresh server props after streaming started:', error);
  343. });
  344. };
  345. const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
  346. const serverModelName = serverStore.modelName;
  347. const preferredModelSource = preferServerPropsModel
  348. ? (serverModelName ?? modelName ?? null)
  349. : (modelName ?? serverModelName ?? null);
  350. if (!preferredModelSource) {
  351. return;
  352. }
  353. const normalizedModel = normalizeModelName(preferredModelSource);
  354. if (!normalizedModel || normalizedModel === resolvedModel) {
  355. return;
  356. }
  357. resolvedModel = normalizedModel;
  358. const messageIndex = this.findMessageIndex(assistantMessage.id);
  359. this.updateMessageAtIndex(messageIndex, { model: normalizedModel });
  360. if (persistImmediately && !modelPersisted) {
  361. modelPersisted = true;
  362. DatabaseStore.updateMessage(assistantMessage.id, { model: normalizedModel }).catch(
  363. (error) => {
  364. console.error('Failed to persist model name:', error);
  365. modelPersisted = false;
  366. resolvedModel = null;
  367. }
  368. );
  369. }
  370. };
  371. if (preferServerPropsModel) {
  372. updateModelFromServerProps = (persistImmediately = true) => {
  373. const currentServerModel = serverStore.modelName;
  374. if (!currentServerModel) {
  375. return;
  376. }
  377. recordModel(currentServerModel, persistImmediately);
  378. };
  379. updateModelFromServerProps(false);
  380. }
  381. slotsService.startStreaming();
  382. slotsService.setActiveConversation(assistantMessage.convId);
  383. await chatService.sendMessage(
  384. allMessages,
  385. {
  386. ...this.getApiOptions(),
  387. onFirstValidChunk: () => {
  388. refreshServerPropsOnce();
  389. },
  390. onChunk: (chunk: string) => {
  391. streamedContent += chunk;
  392. this.setConversationStreaming(
  393. assistantMessage.convId,
  394. streamedContent,
  395. assistantMessage.id
  396. );
  397. const messageIndex = this.findMessageIndex(assistantMessage.id);
  398. this.updateMessageAtIndex(messageIndex, {
  399. content: streamedContent
  400. });
  401. },
  402. onReasoningChunk: (reasoningChunk: string) => {
  403. streamedReasoningContent += reasoningChunk;
  404. const messageIndex = this.findMessageIndex(assistantMessage.id);
  405. this.updateMessageAtIndex(messageIndex, { thinking: streamedReasoningContent });
  406. },
  407. onToolCallChunk: (toolCallChunk: string) => {
  408. const chunk = toolCallChunk.trim();
  409. if (!chunk) {
  410. return;
  411. }
  412. streamedToolCallContent = chunk;
  413. const messageIndex = this.findMessageIndex(assistantMessage.id);
  414. this.updateMessageAtIndex(messageIndex, { toolCalls: streamedToolCallContent });
  415. },
  416. onModel: (modelName: string) => {
  417. recordModel(modelName);
  418. },
  419. onComplete: async (
  420. finalContent?: string,
  421. reasoningContent?: string,
  422. timings?: ChatMessageTimings,
  423. toolCallContent?: string
  424. ) => {
  425. slotsService.stopStreaming();
  426. const updateData: {
  427. content: string;
  428. thinking: string;
  429. toolCalls: string;
  430. timings?: ChatMessageTimings;
  431. model?: string;
  432. } = {
  433. content: finalContent || streamedContent,
  434. thinking: reasoningContent || streamedReasoningContent,
  435. toolCalls: toolCallContent || streamedToolCallContent,
  436. timings: timings
  437. };
  438. if (resolvedModel && !modelPersisted) {
  439. updateData.model = resolvedModel;
  440. modelPersisted = true;
  441. }
  442. await DatabaseStore.updateMessage(assistantMessage.id, updateData);
  443. const messageIndex = this.findMessageIndex(assistantMessage.id);
  444. const localUpdateData: {
  445. timings?: ChatMessageTimings;
  446. model?: string;
  447. toolCalls?: string;
  448. } = {
  449. timings: timings
  450. };
  451. if (updateData.model) {
  452. localUpdateData.model = updateData.model;
  453. }
  454. if (updateData.toolCalls !== undefined) {
  455. localUpdateData.toolCalls = updateData.toolCalls;
  456. }
  457. this.updateMessageAtIndex(messageIndex, localUpdateData);
  458. await DatabaseStore.updateCurrentNode(assistantMessage.convId, assistantMessage.id);
  459. if (this.activeConversation?.id === assistantMessage.convId) {
  460. this.activeConversation.currNode = assistantMessage.id;
  461. await this.refreshActiveMessages();
  462. }
  463. if (onComplete) {
  464. await onComplete(streamedContent);
  465. }
  466. this.setConversationLoading(assistantMessage.convId, false);
  467. this.clearConversationStreaming(assistantMessage.convId);
  468. slotsService.clearConversationState(assistantMessage.convId);
  469. },
  470. onError: (error: Error) => {
  471. slotsService.stopStreaming();
  472. if (this.isAbortError(error)) {
  473. this.setConversationLoading(assistantMessage.convId, false);
  474. this.clearConversationStreaming(assistantMessage.convId);
  475. slotsService.clearConversationState(assistantMessage.convId);
  476. return;
  477. }
  478. console.error('Streaming error:', error);
  479. this.setConversationLoading(assistantMessage.convId, false);
  480. this.clearConversationStreaming(assistantMessage.convId);
  481. slotsService.clearConversationState(assistantMessage.convId);
  482. const messageIndex = this.activeMessages.findIndex(
  483. (m: DatabaseMessage) => m.id === assistantMessage.id
  484. );
  485. if (messageIndex !== -1) {
  486. const [failedMessage] = this.activeMessages.splice(messageIndex, 1);
  487. if (failedMessage) {
  488. DatabaseStore.deleteMessage(failedMessage.id).catch((cleanupError) => {
  489. console.error('Failed to remove assistant message after error:', cleanupError);
  490. });
  491. }
  492. }
  493. const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server';
  494. this.showErrorDialog(dialogType, error.message);
  495. if (onError) {
  496. onError(error);
  497. }
  498. }
  499. },
  500. assistantMessage.convId
  501. );
  502. }
  503. /**
  504. * Checks if an error is an abort error (user cancelled operation)
  505. * @param error - The error to check
  506. * @returns True if the error is an abort error
  507. */
  508. private isAbortError(error: unknown): boolean {
  509. return error instanceof Error && (error.name === 'AbortError' || error instanceof DOMException);
  510. }
  511. private showErrorDialog(type: 'timeout' | 'server', message: string): void {
  512. this.errorDialogState = { type, message };
  513. }
  514. dismissErrorDialog(): void {
  515. this.errorDialogState = null;
  516. }
  517. /**
  518. * Finds the index of a message in the active messages array
  519. * @param messageId - The message ID to find
  520. * @returns The index of the message, or -1 if not found
  521. */
  522. private findMessageIndex(messageId: string): number {
  523. return this.activeMessages.findIndex((m) => m.id === messageId);
  524. }
  525. /**
  526. * Updates a message at a specific index with partial data
  527. * @param index - The index of the message to update
  528. * @param updates - Partial message data to update
  529. */
  530. private updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
  531. if (index !== -1) {
  532. Object.assign(this.activeMessages[index], updates);
  533. }
  534. }
  535. /**
  536. * Creates a new assistant message in the database
  537. * @param parentId - Optional parent message ID, defaults to '-1'
  538. * @returns The created assistant message or null if failed
  539. */
  540. private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage | null> {
  541. if (!this.activeConversation) return null;
  542. return await DatabaseStore.createMessageBranch(
  543. {
  544. convId: this.activeConversation.id,
  545. type: 'text',
  546. role: 'assistant',
  547. content: '',
  548. timestamp: Date.now(),
  549. thinking: '',
  550. toolCalls: '',
  551. children: [],
  552. model: null
  553. },
  554. parentId || null
  555. );
  556. }
  557. /**
  558. * Updates conversation lastModified timestamp and moves it to top of list
  559. * Ensures recently active conversations appear first in the sidebar
  560. */
  561. private updateConversationTimestamp(): void {
  562. if (!this.activeConversation) return;
  563. const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
  564. if (chatIndex !== -1) {
  565. this.conversations[chatIndex].lastModified = Date.now();
  566. const updatedConv = this.conversations.splice(chatIndex, 1)[0];
  567. this.conversations.unshift(updatedConv);
  568. }
  569. }
  570. /**
  571. * Sends a new message and generates AI response
  572. * @param content - The message content to send
  573. * @param extras - Optional extra data (files, attachments, etc.)
  574. */
  575. async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
  576. if (!content.trim() && (!extras || extras.length === 0)) return;
  577. if (this.activeConversation && this.isConversationLoading(this.activeConversation.id)) {
  578. console.log('Cannot send message: current conversation is already processing a message');
  579. return;
  580. }
  581. let isNewConversation = false;
  582. if (!this.activeConversation) {
  583. await this.createConversation();
  584. isNewConversation = true;
  585. }
  586. if (!this.activeConversation) {
  587. console.error('No active conversation available for sending message');
  588. return;
  589. }
  590. this.errorDialogState = null;
  591. this.setConversationLoading(this.activeConversation.id, true);
  592. this.clearConversationStreaming(this.activeConversation.id);
  593. let userMessage: DatabaseMessage | null = null;
  594. try {
  595. userMessage = await this.addMessage('user', content, 'text', '-1', extras);
  596. if (!userMessage) {
  597. throw new Error('Failed to add user message');
  598. }
  599. if (isNewConversation && content) {
  600. const title = content.trim();
  601. await this.updateConversationName(this.activeConversation.id, title);
  602. }
  603. const assistantMessage = await this.createAssistantMessage(userMessage.id);
  604. if (!assistantMessage) {
  605. throw new Error('Failed to create assistant message');
  606. }
  607. this.activeMessages.push(assistantMessage);
  608. const conversationContext = this.activeMessages.slice(0, -1);
  609. await this.streamChatCompletion(conversationContext, assistantMessage);
  610. } catch (error) {
  611. if (this.isAbortError(error)) {
  612. this.setConversationLoading(this.activeConversation!.id, false);
  613. return;
  614. }
  615. console.error('Failed to send message:', error);
  616. this.setConversationLoading(this.activeConversation!.id, false);
  617. if (!this.errorDialogState) {
  618. if (error instanceof Error) {
  619. const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server';
  620. this.showErrorDialog(dialogType, error.message);
  621. } else {
  622. this.showErrorDialog('server', 'Unknown error occurred while sending message');
  623. }
  624. }
  625. }
  626. }
  627. /**
  628. * Stops the current message generation
  629. * Aborts ongoing requests and saves partial response if available
  630. */
  631. async stopGeneration(): Promise<void> {
  632. if (!this.activeConversation) return;
  633. const convId = this.activeConversation.id;
  634. await this.savePartialResponseIfNeeded(convId);
  635. slotsService.stopStreaming();
  636. chatService.abort(convId);
  637. this.setConversationLoading(convId, false);
  638. this.clearConversationStreaming(convId);
  639. slotsService.clearConversationState(convId);
  640. }
  641. /**
  642. * Gracefully stops generation and saves partial response
  643. */
  644. async gracefulStop(): Promise<void> {
  645. if (!this.isLoading) return;
  646. slotsService.stopStreaming();
  647. chatService.abort();
  648. await this.savePartialResponseIfNeeded();
  649. this.conversationLoadingStates.clear();
  650. this.conversationStreamingStates.clear();
  651. this.isLoading = false;
  652. this.currentResponse = '';
  653. }
  654. /**
  655. * Saves partial response if generation was interrupted
  656. * Preserves user's partial content and timing data when generation is stopped early
  657. */
  658. private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
  659. const conversationId = convId || this.activeConversation?.id;
  660. if (!conversationId) return;
  661. const streamingState = this.conversationStreamingStates.get(conversationId);
  662. if (!streamingState || !streamingState.response.trim()) {
  663. return;
  664. }
  665. const messages =
  666. conversationId === this.activeConversation?.id
  667. ? this.activeMessages
  668. : await DatabaseStore.getConversationMessages(conversationId);
  669. if (!messages.length) return;
  670. const lastMessage = messages[messages.length - 1];
  671. if (lastMessage && lastMessage.role === 'assistant') {
  672. try {
  673. const updateData: {
  674. content: string;
  675. thinking?: string;
  676. timings?: ChatMessageTimings;
  677. } = {
  678. content: streamingState.response
  679. };
  680. if (lastMessage.thinking?.trim()) {
  681. updateData.thinking = lastMessage.thinking;
  682. }
  683. const lastKnownState = await slotsService.getCurrentState();
  684. if (lastKnownState) {
  685. updateData.timings = {
  686. prompt_n: lastKnownState.promptTokens || 0,
  687. predicted_n: lastKnownState.tokensDecoded || 0,
  688. cache_n: lastKnownState.cacheTokens || 0,
  689. predicted_ms:
  690. lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded
  691. ? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000
  692. : undefined
  693. };
  694. }
  695. await DatabaseStore.updateMessage(lastMessage.id, updateData);
  696. lastMessage.content = this.currentResponse;
  697. if (updateData.thinking !== undefined) {
  698. lastMessage.thinking = updateData.thinking;
  699. }
  700. if (updateData.timings) {
  701. lastMessage.timings = updateData.timings;
  702. }
  703. } catch (error) {
  704. lastMessage.content = this.currentResponse;
  705. console.error('Failed to save partial response:', error);
  706. }
  707. } else {
  708. console.error('Last message is not an assistant message');
  709. }
  710. }
  711. /**
  712. * Updates a user message and regenerates the assistant response
  713. * @param messageId - The ID of the message to update
  714. * @param newContent - The new content for the message
  715. */
  716. async updateMessage(messageId: string, newContent: string): Promise<void> {
  717. if (!this.activeConversation) return;
  718. if (this.isLoading) {
  719. this.stopGeneration();
  720. }
  721. try {
  722. const messageIndex = this.findMessageIndex(messageId);
  723. if (messageIndex === -1) {
  724. console.error('Message not found for update');
  725. return;
  726. }
  727. const messageToUpdate = this.activeMessages[messageIndex];
  728. const originalContent = messageToUpdate.content;
  729. if (messageToUpdate.role !== 'user') {
  730. console.error('Only user messages can be edited');
  731. return;
  732. }
  733. const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
  734. const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
  735. const isFirstUserMessage =
  736. rootMessage && messageToUpdate.parent === rootMessage.id && messageToUpdate.role === 'user';
  737. this.updateMessageAtIndex(messageIndex, { content: newContent });
  738. await DatabaseStore.updateMessage(messageId, { content: newContent });
  739. if (isFirstUserMessage && newContent.trim()) {
  740. await this.updateConversationTitleWithConfirmation(
  741. this.activeConversation.id,
  742. newContent.trim(),
  743. this.titleUpdateConfirmationCallback
  744. );
  745. }
  746. const messagesToRemove = this.activeMessages.slice(messageIndex + 1);
  747. for (const message of messagesToRemove) {
  748. await DatabaseStore.deleteMessage(message.id);
  749. }
  750. this.activeMessages = this.activeMessages.slice(0, messageIndex + 1);
  751. this.updateConversationTimestamp();
  752. this.setConversationLoading(this.activeConversation.id, true);
  753. this.clearConversationStreaming(this.activeConversation.id);
  754. try {
  755. const assistantMessage = await this.createAssistantMessage();
  756. if (!assistantMessage) {
  757. throw new Error('Failed to create assistant message');
  758. }
  759. this.activeMessages.push(assistantMessage);
  760. await DatabaseStore.updateCurrentNode(this.activeConversation.id, assistantMessage.id);
  761. this.activeConversation.currNode = assistantMessage.id;
  762. await this.streamChatCompletion(
  763. this.activeMessages.slice(0, -1),
  764. assistantMessage,
  765. undefined,
  766. () => {
  767. const editedMessageIndex = this.findMessageIndex(messageId);
  768. this.updateMessageAtIndex(editedMessageIndex, { content: originalContent });
  769. }
  770. );
  771. } catch (regenerateError) {
  772. console.error('Failed to regenerate response:', regenerateError);
  773. this.setConversationLoading(this.activeConversation!.id, false);
  774. const messageIndex = this.findMessageIndex(messageId);
  775. this.updateMessageAtIndex(messageIndex, { content: originalContent });
  776. }
  777. } catch (error) {
  778. if (this.isAbortError(error)) {
  779. return;
  780. }
  781. console.error('Failed to update message:', error);
  782. }
  783. }
  784. /**
  785. * Regenerates an assistant message with a new response
  786. * @param messageId - The ID of the assistant message to regenerate
  787. */
  788. async regenerateMessage(messageId: string): Promise<void> {
  789. if (!this.activeConversation || this.isLoading) return;
  790. try {
  791. const messageIndex = this.findMessageIndex(messageId);
  792. if (messageIndex === -1) {
  793. console.error('Message not found for regeneration');
  794. return;
  795. }
  796. const messageToRegenerate = this.activeMessages[messageIndex];
  797. if (messageToRegenerate.role !== 'assistant') {
  798. console.error('Only assistant messages can be regenerated');
  799. return;
  800. }
  801. const messagesToRemove = this.activeMessages.slice(messageIndex);
  802. for (const message of messagesToRemove) {
  803. await DatabaseStore.deleteMessage(message.id);
  804. }
  805. this.activeMessages = this.activeMessages.slice(0, messageIndex);
  806. this.updateConversationTimestamp();
  807. this.setConversationLoading(this.activeConversation.id, true);
  808. this.clearConversationStreaming(this.activeConversation.id);
  809. try {
  810. const parentMessageId =
  811. this.activeMessages.length > 0
  812. ? this.activeMessages[this.activeMessages.length - 1].id
  813. : null;
  814. const assistantMessage = await this.createAssistantMessage(parentMessageId);
  815. if (!assistantMessage) {
  816. throw new Error('Failed to create assistant message');
  817. }
  818. this.activeMessages.push(assistantMessage);
  819. const conversationContext = this.activeMessages.slice(0, -1);
  820. await this.streamChatCompletion(conversationContext, assistantMessage);
  821. } catch (regenerateError) {
  822. console.error('Failed to regenerate response:', regenerateError);
  823. this.setConversationLoading(this.activeConversation!.id, false);
  824. }
  825. } catch (error) {
  826. if (this.isAbortError(error)) return;
  827. console.error('Failed to regenerate message:', error);
  828. }
  829. }
  830. /**
  831. * Updates the name of a conversation
  832. * @param convId - The conversation ID to update
  833. * @param name - The new name for the conversation
  834. */
  835. async updateConversationName(convId: string, name: string): Promise<void> {
  836. try {
  837. await DatabaseStore.updateConversation(convId, { name });
  838. const convIndex = this.conversations.findIndex((c) => c.id === convId);
  839. if (convIndex !== -1) {
  840. this.conversations[convIndex].name = name;
  841. }
  842. if (this.activeConversation?.id === convId) {
  843. this.activeConversation.name = name;
  844. }
  845. } catch (error) {
  846. console.error('Failed to update conversation name:', error);
  847. }
  848. }
  849. /**
  850. * Sets the callback function for title update confirmations
  851. * @param callback - Function to call when confirmation is needed
  852. */
  853. setTitleUpdateConfirmationCallback(
  854. callback: (currentTitle: string, newTitle: string) => Promise<boolean>
  855. ): void {
  856. this.titleUpdateConfirmationCallback = callback;
  857. }
  858. /**
  859. * Updates conversation title with optional confirmation dialog based on settings
  860. * @param convId - The conversation ID to update
  861. * @param newTitle - The new title content
  862. * @param onConfirmationNeeded - Callback when user confirmation is needed
  863. * @returns Promise<boolean> - True if title was updated, false if cancelled
  864. */
  865. async updateConversationTitleWithConfirmation(
  866. convId: string,
  867. newTitle: string,
  868. onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise<boolean>
  869. ): Promise<boolean> {
  870. try {
  871. const currentConfig = config();
  872. if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
  873. const conversation = await DatabaseStore.getConversation(convId);
  874. if (!conversation) return false;
  875. const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle);
  876. if (!shouldUpdate) return false;
  877. }
  878. await this.updateConversationName(convId, newTitle);
  879. return true;
  880. } catch (error) {
  881. console.error('Failed to update conversation title with confirmation:', error);
  882. return false;
  883. }
  884. }
  885. /**
  886. * Downloads a conversation as JSON file
  887. * @param convId - The conversation ID to download
  888. */
  889. async downloadConversation(convId: string): Promise<void> {
  890. if (!this.activeConversation || this.activeConversation.id !== convId) {
  891. // Load the conversation if not currently active
  892. const conversation = await DatabaseStore.getConversation(convId);
  893. if (!conversation) return;
  894. const messages = await DatabaseStore.getConversationMessages(convId);
  895. const conversationData = {
  896. conv: conversation,
  897. messages
  898. };
  899. this.triggerDownload(conversationData);
  900. } else {
  901. // Use current active conversation data
  902. const conversationData: ExportedConversations = {
  903. conv: this.activeConversation!,
  904. messages: this.activeMessages
  905. };
  906. this.triggerDownload(conversationData);
  907. }
  908. }
  909. /**
  910. * Triggers file download in browser
  911. * @param data - Data to download (expected: { conv: DatabaseConversation, messages: DatabaseMessage[] })
  912. * @param filename - Optional filename
  913. */
  914. private triggerDownload(data: ExportedConversations, filename?: string): void {
  915. const conversation =
  916. 'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
  917. if (!conversation) {
  918. console.error('Invalid data: missing conversation');
  919. return;
  920. }
  921. const conversationName = conversation.name ? conversation.name.trim() : '';
  922. const convId = conversation.id || 'unknown';
  923. const truncatedSuffix = conversationName
  924. .toLowerCase()
  925. .replace(/[^a-z0-9]/gi, '_')
  926. .replace(/_+/g, '_')
  927. .substring(0, 20);
  928. const downloadFilename = filename || `conversation_${convId}_${truncatedSuffix}.json`;
  929. const conversationJson = JSON.stringify(data, null, 2);
  930. const blob = new Blob([conversationJson], {
  931. type: 'application/json'
  932. });
  933. const url = URL.createObjectURL(blob);
  934. const a = document.createElement('a');
  935. a.href = url;
  936. a.download = downloadFilename;
  937. document.body.appendChild(a);
  938. a.click();
  939. document.body.removeChild(a);
  940. URL.revokeObjectURL(url);
  941. }
  942. /**
  943. * Exports all conversations with their messages as a JSON file
  944. * Returns the list of exported conversations
  945. */
  946. async exportAllConversations(): Promise<DatabaseConversation[]> {
  947. try {
  948. const allConversations = await DatabaseStore.getAllConversations();
  949. if (allConversations.length === 0) {
  950. throw new Error('No conversations to export');
  951. }
  952. const allData: ExportedConversations = await Promise.all(
  953. allConversations.map(async (conv) => {
  954. const messages = await DatabaseStore.getConversationMessages(conv.id);
  955. return { conv, messages };
  956. })
  957. );
  958. const blob = new Blob([JSON.stringify(allData, null, 2)], {
  959. type: 'application/json'
  960. });
  961. const url = URL.createObjectURL(blob);
  962. const a = document.createElement('a');
  963. a.href = url;
  964. a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
  965. document.body.appendChild(a);
  966. a.click();
  967. document.body.removeChild(a);
  968. URL.revokeObjectURL(url);
  969. toast.success(`All conversations (${allConversations.length}) prepared for download`);
  970. return allConversations;
  971. } catch (err) {
  972. console.error('Failed to export conversations:', err);
  973. throw err;
  974. }
  975. }
  976. /**
  977. * Imports conversations from a JSON file.
  978. * Supports both single conversation (object) and multiple conversations (array).
  979. * Uses DatabaseStore for safe, encapsulated data access
  980. * Returns the list of imported conversations
  981. */
  982. async importConversations(): Promise<DatabaseConversation[]> {
  983. return new Promise((resolve, reject) => {
  984. const input = document.createElement('input');
  985. input.type = 'file';
  986. input.accept = '.json';
  987. input.onchange = async (e) => {
  988. const file = (e.target as HTMLInputElement)?.files?.[0];
  989. if (!file) {
  990. reject(new Error('No file selected'));
  991. return;
  992. }
  993. try {
  994. const text = await file.text();
  995. const parsedData = JSON.parse(text);
  996. let importedData: ExportedConversations;
  997. if (Array.isArray(parsedData)) {
  998. importedData = parsedData;
  999. } else if (
  1000. parsedData &&
  1001. typeof parsedData === 'object' &&
  1002. 'conv' in parsedData &&
  1003. 'messages' in parsedData
  1004. ) {
  1005. // Single conversation object
  1006. importedData = [parsedData];
  1007. } else {
  1008. throw new Error(
  1009. 'Invalid file format: expected array of conversations or single conversation object'
  1010. );
  1011. }
  1012. const result = await DatabaseStore.importConversations(importedData);
  1013. // Refresh UI
  1014. await this.loadConversations();
  1015. toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
  1016. // Extract the conversation objects from imported data
  1017. const importedConversations = importedData.map((item) => item.conv);
  1018. resolve(importedConversations);
  1019. } catch (err: unknown) {
  1020. const message = err instanceof Error ? err.message : 'Unknown error';
  1021. console.error('Failed to import conversations:', err);
  1022. toast.error('Import failed', {
  1023. description: message
  1024. });
  1025. reject(new Error(`Import failed: ${message}`));
  1026. }
  1027. };
  1028. input.click();
  1029. });
  1030. }
  1031. /**
  1032. * Deletes a conversation and all its messages
  1033. * @param convId - The conversation ID to delete
  1034. */
  1035. async deleteConversation(convId: string): Promise<void> {
  1036. try {
  1037. await DatabaseStore.deleteConversation(convId);
  1038. this.conversations = this.conversations.filter((c) => c.id !== convId);
  1039. if (this.activeConversation?.id === convId) {
  1040. this.activeConversation = null;
  1041. this.activeMessages = [];
  1042. await goto(`?new_chat=true#/`);
  1043. }
  1044. } catch (error) {
  1045. console.error('Failed to delete conversation:', error);
  1046. }
  1047. }
  1048. /**
  1049. * Gets information about what messages will be deleted when deleting a specific message
  1050. * @param messageId - The ID of the message to be deleted
  1051. * @returns Object with deletion info including count and types of messages
  1052. */
  1053. async getDeletionInfo(messageId: string): Promise<{
  1054. totalCount: number;
  1055. userMessages: number;
  1056. assistantMessages: number;
  1057. messageTypes: string[];
  1058. }> {
  1059. if (!this.activeConversation) {
  1060. return { totalCount: 0, userMessages: 0, assistantMessages: 0, messageTypes: [] };
  1061. }
  1062. const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
  1063. const descendants = findDescendantMessages(allMessages, messageId);
  1064. const allToDelete = [messageId, ...descendants];
  1065. const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id));
  1066. let userMessages = 0;
  1067. let assistantMessages = 0;
  1068. const messageTypes: string[] = [];
  1069. for (const msg of messagesToDelete) {
  1070. if (msg.role === 'user') {
  1071. userMessages++;
  1072. if (!messageTypes.includes('user message')) messageTypes.push('user message');
  1073. } else if (msg.role === 'assistant') {
  1074. assistantMessages++;
  1075. if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
  1076. }
  1077. }
  1078. return {
  1079. totalCount: allToDelete.length,
  1080. userMessages,
  1081. assistantMessages,
  1082. messageTypes
  1083. };
  1084. }
  1085. /**
  1086. * Deletes a message and all its descendants, updating conversation path if needed
  1087. * @param messageId - The ID of the message to delete
  1088. */
  1089. async deleteMessage(messageId: string): Promise<void> {
  1090. try {
  1091. if (!this.activeConversation) return;
  1092. // Get all messages to find siblings before deletion
  1093. const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
  1094. const messageToDelete = allMessages.find((m) => m.id === messageId);
  1095. if (!messageToDelete) {
  1096. console.error('Message to delete not found');
  1097. return;
  1098. }
  1099. // Check if the deleted message is in the current conversation path
  1100. const currentPath = filterByLeafNodeId(
  1101. allMessages,
  1102. this.activeConversation.currNode || '',
  1103. false
  1104. );
  1105. const isInCurrentPath = currentPath.some((m) => m.id === messageId);
  1106. // If the deleted message is in the current path, we need to update currNode
  1107. if (isInCurrentPath && messageToDelete.parent) {
  1108. // Find all siblings (messages with same parent)
  1109. const siblings = allMessages.filter(
  1110. (m) => m.parent === messageToDelete.parent && m.id !== messageId
  1111. );
  1112. if (siblings.length > 0) {
  1113. // Find the latest sibling (highest timestamp)
  1114. const latestSibling = siblings.reduce((latest, sibling) =>
  1115. sibling.timestamp > latest.timestamp ? sibling : latest
  1116. );
  1117. // Find the leaf node for this sibling branch to get the complete conversation path
  1118. const leafNodeId = findLeafNode(allMessages, latestSibling.id);
  1119. // Update conversation to use the leaf node of the latest remaining sibling
  1120. await DatabaseStore.updateCurrentNode(this.activeConversation.id, leafNodeId);
  1121. this.activeConversation.currNode = leafNodeId;
  1122. } else {
  1123. // No siblings left, navigate to parent if it exists
  1124. if (messageToDelete.parent) {
  1125. const parentLeafId = findLeafNode(allMessages, messageToDelete.parent);
  1126. await DatabaseStore.updateCurrentNode(this.activeConversation.id, parentLeafId);
  1127. this.activeConversation.currNode = parentLeafId;
  1128. }
  1129. }
  1130. }
  1131. // Use cascading deletion to remove the message and all its descendants
  1132. await DatabaseStore.deleteMessageCascading(this.activeConversation.id, messageId);
  1133. // Refresh active messages to show the updated branch
  1134. await this.refreshActiveMessages();
  1135. // Update conversation timestamp
  1136. this.updateConversationTimestamp();
  1137. } catch (error) {
  1138. console.error('Failed to delete message:', error);
  1139. }
  1140. }
  1141. /**
  1142. * Clears the active conversation and messages
  1143. * Used when navigating away from chat or starting fresh
  1144. * Note: Does not stop ongoing streaming to allow background completion
  1145. */
  1146. clearActiveConversation(): void {
  1147. this.activeConversation = null;
  1148. this.activeMessages = [];
  1149. this.isLoading = false;
  1150. this.currentResponse = '';
  1151. slotsService.setActiveConversation(null);
  1152. }
  1153. /** Refreshes active messages based on currNode after branch navigation */
  1154. async refreshActiveMessages(): Promise<void> {
  1155. if (!this.activeConversation) return;
  1156. const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
  1157. if (allMessages.length === 0) {
  1158. this.activeMessages = [];
  1159. return;
  1160. }
  1161. const leafNodeId =
  1162. this.activeConversation.currNode ||
  1163. allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
  1164. const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
  1165. this.activeMessages.length = 0;
  1166. this.activeMessages.push(...currentPath);
  1167. }
  1168. /**
  1169. * Navigates to a specific sibling branch by updating currNode and refreshing messages
  1170. * @param siblingId - The sibling message ID to navigate to
  1171. */
  1172. async navigateToSibling(siblingId: string): Promise<void> {
  1173. if (!this.activeConversation) return;
  1174. // Get the current first user message before navigation
  1175. const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
  1176. const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
  1177. const currentFirstUserMessage = this.activeMessages.find(
  1178. (m) => m.role === 'user' && m.parent === rootMessage?.id
  1179. );
  1180. const currentLeafNodeId = findLeafNode(allMessages, siblingId);
  1181. await DatabaseStore.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
  1182. this.activeConversation.currNode = currentLeafNodeId;
  1183. await this.refreshActiveMessages();
  1184. // Only show title dialog if we're navigating between different first user message siblings
  1185. if (rootMessage && this.activeMessages.length > 0) {
  1186. // Find the first user message in the new active path
  1187. const newFirstUserMessage = this.activeMessages.find(
  1188. (m) => m.role === 'user' && m.parent === rootMessage.id
  1189. );
  1190. // Only show dialog if:
  1191. // 1. We have a new first user message
  1192. // 2. It's different from the previous one (different ID or content)
  1193. // 3. The new message has content
  1194. if (
  1195. newFirstUserMessage &&
  1196. newFirstUserMessage.content.trim() &&
  1197. (!currentFirstUserMessage ||
  1198. newFirstUserMessage.id !== currentFirstUserMessage.id ||
  1199. newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
  1200. ) {
  1201. await this.updateConversationTitleWithConfirmation(
  1202. this.activeConversation.id,
  1203. newFirstUserMessage.content.trim(),
  1204. this.titleUpdateConfirmationCallback
  1205. );
  1206. }
  1207. }
  1208. }
  1209. /**
  1210. * Edits an assistant message with optional branching
  1211. * @param messageId - The ID of the assistant message to edit
  1212. * @param newContent - The new content for the message
  1213. * @param shouldBranch - Whether to create a branch or replace in-place
  1214. */
  1215. async editAssistantMessage(
  1216. messageId: string,
  1217. newContent: string,
  1218. shouldBranch: boolean
  1219. ): Promise<void> {
  1220. if (!this.activeConversation || this.isLoading) return;
  1221. try {
  1222. const messageIndex = this.findMessageIndex(messageId);
  1223. if (messageIndex === -1) {
  1224. console.error('Message not found for editing');
  1225. return;
  1226. }
  1227. const messageToEdit = this.activeMessages[messageIndex];
  1228. if (messageToEdit.role !== 'assistant') {
  1229. console.error('Only assistant messages can be edited with this method');
  1230. return;
  1231. }
  1232. if (shouldBranch) {
  1233. const newMessage = await DatabaseStore.createMessageBranch(
  1234. {
  1235. convId: messageToEdit.convId,
  1236. type: messageToEdit.type,
  1237. timestamp: Date.now(),
  1238. role: messageToEdit.role,
  1239. content: newContent,
  1240. thinking: messageToEdit.thinking || '',
  1241. toolCalls: messageToEdit.toolCalls || '',
  1242. children: [],
  1243. model: messageToEdit.model // Preserve original model info when branching
  1244. },
  1245. messageToEdit.parent!
  1246. );
  1247. await DatabaseStore.updateCurrentNode(this.activeConversation.id, newMessage.id);
  1248. this.activeConversation.currNode = newMessage.id;
  1249. } else {
  1250. await DatabaseStore.updateMessage(messageToEdit.id, {
  1251. content: newContent,
  1252. timestamp: Date.now()
  1253. });
  1254. this.updateMessageAtIndex(messageIndex, {
  1255. content: newContent,
  1256. timestamp: Date.now()
  1257. });
  1258. }
  1259. this.updateConversationTimestamp();
  1260. await this.refreshActiveMessages();
  1261. } catch (error) {
  1262. console.error('Failed to edit assistant message:', error);
  1263. }
  1264. }
  1265. /**
  1266. * Edits a message by creating a new branch with the edited content
  1267. * @param messageId - The ID of the message to edit
  1268. * @param newContent - The new content for the message
  1269. */
  1270. async editMessageWithBranching(messageId: string, newContent: string): Promise<void> {
  1271. if (!this.activeConversation || this.isLoading) return;
  1272. try {
  1273. const messageIndex = this.findMessageIndex(messageId);
  1274. if (messageIndex === -1) {
  1275. console.error('Message not found for editing');
  1276. return;
  1277. }
  1278. const messageToEdit = this.activeMessages[messageIndex];
  1279. if (messageToEdit.role !== 'user') {
  1280. console.error('Only user messages can be edited');
  1281. return;
  1282. }
  1283. // Check if this is the first user message in the conversation
  1284. // First user message is one that has the root message as its parent
  1285. const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
  1286. const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
  1287. const isFirstUserMessage =
  1288. rootMessage && messageToEdit.parent === rootMessage.id && messageToEdit.role === 'user';
  1289. let parentId = messageToEdit.parent;
  1290. if (parentId === undefined || parentId === null) {
  1291. const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
  1292. if (rootMessage) {
  1293. parentId = rootMessage.id;
  1294. } else {
  1295. console.error('No root message found for editing');
  1296. return;
  1297. }
  1298. }
  1299. const newMessage = await DatabaseStore.createMessageBranch(
  1300. {
  1301. convId: messageToEdit.convId,
  1302. type: messageToEdit.type,
  1303. timestamp: Date.now(),
  1304. role: messageToEdit.role,
  1305. content: newContent,
  1306. thinking: messageToEdit.thinking || '',
  1307. toolCalls: messageToEdit.toolCalls || '',
  1308. children: [],
  1309. extra: messageToEdit.extra ? JSON.parse(JSON.stringify(messageToEdit.extra)) : undefined,
  1310. model: messageToEdit.model // Preserve original model info when branching
  1311. },
  1312. parentId
  1313. );
  1314. await DatabaseStore.updateCurrentNode(this.activeConversation.id, newMessage.id);
  1315. this.activeConversation.currNode = newMessage.id;
  1316. this.updateConversationTimestamp();
  1317. // If this is the first user message, update the conversation title with confirmation if needed
  1318. if (isFirstUserMessage && newContent.trim()) {
  1319. await this.updateConversationTitleWithConfirmation(
  1320. this.activeConversation.id,
  1321. newContent.trim(),
  1322. this.titleUpdateConfirmationCallback
  1323. );
  1324. }
  1325. await this.refreshActiveMessages();
  1326. if (messageToEdit.role === 'user') {
  1327. await this.generateResponseForMessage(newMessage.id);
  1328. }
  1329. } catch (error) {
  1330. console.error('Failed to edit message with branching:', error);
  1331. }
  1332. }
  1333. /**
  1334. * Regenerates an assistant message by creating a new branch with a new response
  1335. * @param messageId - The ID of the assistant message to regenerate
  1336. */
  1337. async regenerateMessageWithBranching(messageId: string): Promise<void> {
  1338. if (!this.activeConversation || this.isLoading) return;
  1339. try {
  1340. const messageIndex = this.findMessageIndex(messageId);
  1341. if (messageIndex === -1) {
  1342. console.error('Message not found for regeneration');
  1343. return;
  1344. }
  1345. const messageToRegenerate = this.activeMessages[messageIndex];
  1346. if (messageToRegenerate.role !== 'assistant') {
  1347. console.error('Only assistant messages can be regenerated');
  1348. return;
  1349. }
  1350. // Find parent message in all conversation messages, not just active path
  1351. const conversationMessages = await DatabaseStore.getConversationMessages(
  1352. this.activeConversation.id
  1353. );
  1354. const parentMessage = conversationMessages.find((m) => m.id === messageToRegenerate.parent);
  1355. if (!parentMessage) {
  1356. console.error('Parent message not found for regeneration');
  1357. return;
  1358. }
  1359. this.setConversationLoading(this.activeConversation.id, true);
  1360. this.clearConversationStreaming(this.activeConversation.id);
  1361. const newAssistantMessage = await DatabaseStore.createMessageBranch(
  1362. {
  1363. convId: this.activeConversation.id,
  1364. type: 'text',
  1365. timestamp: Date.now(),
  1366. role: 'assistant',
  1367. content: '',
  1368. thinking: '',
  1369. toolCalls: '',
  1370. children: [],
  1371. model: null
  1372. },
  1373. parentMessage.id
  1374. );
  1375. await DatabaseStore.updateCurrentNode(this.activeConversation.id, newAssistantMessage.id);
  1376. this.activeConversation.currNode = newAssistantMessage.id;
  1377. this.updateConversationTimestamp();
  1378. await this.refreshActiveMessages();
  1379. const allConversationMessages = await DatabaseStore.getConversationMessages(
  1380. this.activeConversation.id
  1381. );
  1382. const conversationPath = filterByLeafNodeId(
  1383. allConversationMessages,
  1384. parentMessage.id,
  1385. false
  1386. ) as DatabaseMessage[];
  1387. await this.streamChatCompletion(conversationPath, newAssistantMessage);
  1388. } catch (error) {
  1389. if (this.isAbortError(error)) return;
  1390. console.error('Failed to regenerate message with branching:', error);
  1391. this.setConversationLoading(this.activeConversation!.id, false);
  1392. }
  1393. }
  1394. /**
  1395. * Generates a new assistant response for a given user message
  1396. * @param userMessageId - ID of user message to respond to
  1397. */
  1398. private async generateResponseForMessage(userMessageId: string): Promise<void> {
  1399. if (!this.activeConversation) return;
  1400. this.errorDialogState = null;
  1401. this.setConversationLoading(this.activeConversation.id, true);
  1402. this.clearConversationStreaming(this.activeConversation.id);
  1403. try {
  1404. // Get conversation path up to the user message
  1405. const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id);
  1406. const conversationPath = filterByLeafNodeId(
  1407. allMessages,
  1408. userMessageId,
  1409. false
  1410. ) as DatabaseMessage[];
  1411. // Create new assistant message branch
  1412. const assistantMessage = await DatabaseStore.createMessageBranch(
  1413. {
  1414. convId: this.activeConversation.id,
  1415. type: 'text',
  1416. timestamp: Date.now(),
  1417. role: 'assistant',
  1418. content: '',
  1419. thinking: '',
  1420. toolCalls: '',
  1421. children: [],
  1422. model: null
  1423. },
  1424. userMessageId
  1425. );
  1426. // Add assistant message to active messages immediately for UI reactivity
  1427. this.activeMessages.push(assistantMessage);
  1428. // Stream response to new assistant message
  1429. await this.streamChatCompletion(conversationPath, assistantMessage);
  1430. } catch (error) {
  1431. console.error('Failed to generate response:', error);
  1432. this.setConversationLoading(this.activeConversation!.id, false);
  1433. }
  1434. }
  1435. /**
  1436. * Public methods for accessing per-conversation states
  1437. */
  1438. public isConversationLoadingPublic(convId: string): boolean {
  1439. return this.isConversationLoading(convId);
  1440. }
  1441. public getConversationStreamingPublic(
  1442. convId: string
  1443. ): { response: string; messageId: string } | undefined {
  1444. return this.getConversationStreaming(convId);
  1445. }
  1446. public getAllLoadingConversations(): string[] {
  1447. return Array.from(this.conversationLoadingStates.keys());
  1448. }
  1449. public getAllStreamingConversations(): string[] {
  1450. return Array.from(this.conversationStreamingStates.keys());
  1451. }
  1452. }
  1453. export const chatStore = new ChatStore();
  1454. export const conversations = () => chatStore.conversations;
  1455. export const activeConversation = () => chatStore.activeConversation;
  1456. export const activeMessages = () => chatStore.activeMessages;
  1457. export const isLoading = () => chatStore.isLoading;
  1458. export const currentResponse = () => chatStore.currentResponse;
  1459. export const isInitialized = () => chatStore.isInitialized;
  1460. export const errorDialog = () => chatStore.errorDialogState;
  1461. export const createConversation = chatStore.createConversation.bind(chatStore);
  1462. export const downloadConversation = chatStore.downloadConversation.bind(chatStore);
  1463. export const exportAllConversations = chatStore.exportAllConversations.bind(chatStore);
  1464. export const importConversations = chatStore.importConversations.bind(chatStore);
  1465. export const deleteConversation = chatStore.deleteConversation.bind(chatStore);
  1466. export const sendMessage = chatStore.sendMessage.bind(chatStore);
  1467. export const dismissErrorDialog = chatStore.dismissErrorDialog.bind(chatStore);
  1468. export const gracefulStop = chatStore.gracefulStop.bind(chatStore);
  1469. // Branching operations
  1470. export const refreshActiveMessages = chatStore.refreshActiveMessages.bind(chatStore);
  1471. export const navigateToSibling = chatStore.navigateToSibling.bind(chatStore);
  1472. export const editAssistantMessage = chatStore.editAssistantMessage.bind(chatStore);
  1473. export const editMessageWithBranching = chatStore.editMessageWithBranching.bind(chatStore);
  1474. export const regenerateMessageWithBranching =
  1475. chatStore.regenerateMessageWithBranching.bind(chatStore);
  1476. export const deleteMessage = chatStore.deleteMessage.bind(chatStore);
  1477. export const getDeletionInfo = chatStore.getDeletionInfo.bind(chatStore);
  1478. export const updateConversationName = chatStore.updateConversationName.bind(chatStore);
  1479. export const setTitleUpdateConfirmationCallback =
  1480. chatStore.setTitleUpdateConfirmationCallback.bind(chatStore);
  1481. export function stopGeneration() {
  1482. chatStore.stopGeneration();
  1483. }
  1484. export const messages = () => chatStore.activeMessages;
  1485. // Per-conversation state access
  1486. export const isConversationLoading = (convId: string) =>
  1487. chatStore.isConversationLoadingPublic(convId);
  1488. export const getConversationStreaming = (convId: string) =>
  1489. chatStore.getConversationStreamingPublic(convId);
  1490. export const getAllLoadingConversations = () => chatStore.getAllLoadingConversations();
  1491. export const getAllStreamingConversations = () => chatStore.getAllStreamingConversations();