chat.svelte.ts 48 KB

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