chat.svelte.ts 53 KB

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