diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 22090bf2..3450e62c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -7,6 +7,11 @@ on: description: 'Tag to build and publish' required: false type: string + skip_ci: + description: 'Skip CI' + required: false + type: boolean + default: false workflow_dispatch: inputs: tag: @@ -20,7 +25,7 @@ env: jobs: ci: - if: github.event_name != 'workflow_call' + if: github.event_name != 'workflow_call' && inputs.skip_ci != true uses: ./.github/workflows/ci.yml permissions: contents: read diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index dadd6413..e597319d 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -7,6 +7,11 @@ on: description: 'Tag to build and publish' required: false type: string + skip_ci: + description: 'Skip CI' + required: false + type: boolean + default: false workflow_dispatch: inputs: tag: @@ -19,7 +24,7 @@ permissions: jobs: ci: - if: github.event_name != 'workflow_call' + if: github.event_name != 'workflow_call' && inputs.skip_ci != true uses: ./.github/workflows/ci.yml build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12a481b7..b2d3e8df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,7 @@ jobs: uses: ./.github/workflows/docker-publish.yml with: tag: ${{ inputs.tag }} + skip_ci: true secrets: inherit permissions: contents: read @@ -32,6 +33,7 @@ jobs: uses: ./.github/workflows/electron-release.yml with: tag: ${{ inputs.tag }} + skip_ci: true secrets: inherit permissions: contents: write diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..2312dc58 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/backend/Dockerfile b/backend/Dockerfile index bebc0d23..e8b4d1ff 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -28,6 +28,7 @@ COPY --chown=node:node package*.json ./ RUN apt-get update && apt-get install -y --no-install-recommends \ gosu \ libsqlite3-0 \ + sqlite3 \ ca-certificates \ && rm -rf /var/lib/apt/lists/* diff --git a/backend/__tests__/conversations.test.js b/backend/__tests__/conversations.test.js index 9d4aec1a..f5451e3d 100644 --- a/backend/__tests__/conversations.test.js +++ b/backend/__tests__/conversations.test.js @@ -170,6 +170,23 @@ describe('GET /v1/conversations', () => { assert.equal(items[1].id, 'c2'); assert.equal(items[1].provider_id, 'p2'); }); + + test('excludes linked comparison conversations from list results', async () => { + createConversation({ id: 'parent', sessionId, userId: testUser.id, title: 'parent' }); + createConversation({ + id: 'linked', + sessionId, + userId: testUser.id, + title: 'linked', + parentConversationId: 'parent', + }); + createConversation({ id: 'standalone', sessionId, userId: testUser.id, title: 'standalone' }); + + const app = makeApp(); + const res = await request(app).get('/v1/conversations').set('x-session-id', sessionId); + const ids = res.body.items.map((item) => item.id).sort(); + assert.deepEqual(ids, ['parent', 'standalone']); + }); }); // --- GET /v1/conversations/:id --- @@ -235,6 +252,76 @@ describe('GET /v1/conversations/:id', () => { }); }); +// --- GET /v1/conversations/:id/linked --- +describe('GET /v1/conversations/:id/linked', () => { + test('returns linked comparison conversations for the parent', async () => { + createConversation({ id: 'parent', sessionId, userId: testUser.id, title: 'parent' }); + createConversation({ + id: 'child-1', + sessionId, + userId: testUser.id, + title: 'child 1', + parentConversationId: 'parent', + }); + createConversation({ + id: 'child-2', + sessionId, + userId: testUser.id, + title: 'child 2', + parentConversationId: 'parent', + }); + + const app = makeApp(); + const res = await request(app) + .get('/v1/conversations/parent/linked') + .set('x-session-id', sessionId); + + assert.equal(res.status, 200); + const ids = res.body.conversations.map((item) => item.id).sort(); + assert.deepEqual(ids, ['child-1', 'child-2']); + }); + + test('returns 404 when parent conversation is not owned by the user', async () => { + const otherUser = createUser({ + email: 'other@example.com', + passwordHash: 'pw', + displayName: 'Other User', + }); + createConversation({ id: 'other-parent', sessionId, userId: otherUser.id, title: 'other' }); + + const app = makeApp(); + const res = await request(app) + .get('/v1/conversations/other-parent/linked') + .set('x-session-id', sessionId); + + assert.equal(res.status, 404); + }); +}); + +// --- DELETE /v1/conversations/:id --- +describe('DELETE /v1/conversations/:id', () => { + test('soft deletes linked comparison conversations with the parent', async () => { + createConversation({ id: 'parent', sessionId, userId: testUser.id, title: 'parent' }); + createConversation({ + id: 'linked', + sessionId, + userId: testUser.id, + title: 'linked', + parentConversationId: 'parent', + }); + + const app = makeApp(); + const res = await request(app).delete('/v1/conversations/parent').set('x-session-id', sessionId); + assert.equal(res.status, 204); + + const db = getDb(); + const parent = db.prepare('SELECT deleted_at FROM conversations WHERE id = ?').get('parent'); + const linked = db.prepare('SELECT deleted_at FROM conversations WHERE id = ?').get('linked'); + assert.ok(parent.deleted_at); + assert.ok(linked.deleted_at); + }); +}); + // --- DELETE /v1/conversations/:id --- describe('DELETE /v1/conversations/:id', () => { test('soft deletes an existing conversation and returns 204', async () => { diff --git a/backend/package.json b/backend/package.json index ca0242ba..38dba73f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,7 @@ "start": "NODE_ENV=production node src/index.js", "test": "NODE_OPTIONS=--experimental-vm-modules jest", "lint": "eslint .", + "lint:fix": "eslint . --fix", "format": "prettier --write .", "migrate": "node scripts/migrate.js", "backfill:message-events": "node scripts/backfill-message-events.js" diff --git a/backend/src/db/conversations.js b/backend/src/db/conversations.js index c5a50b29..928427d2 100644 --- a/backend/src/db/conversations.js +++ b/backend/src/db/conversations.js @@ -15,12 +15,13 @@ export function createConversation({ reasoningEffort = null, verbosity = null, metadata = {}, + parentConversationId = null, }) { const db = getDb(); const now = new Date().toISOString(); db.prepare( - `INSERT INTO conversations (id, session_id, user_id, title, provider_id, model, metadata, streaming_enabled, tools_enabled, quality_level, reasoning_effort, verbosity, created_at, updated_at) - VALUES (@id, @session_id, @user_id, @title, @provider_id, @model, @metadata, @streaming_enabled, @tools_enabled, @quality_level, @reasoning_effort, @verbosity, @now, @now)` + `INSERT INTO conversations (id, session_id, user_id, title, provider_id, model, metadata, streaming_enabled, tools_enabled, quality_level, reasoning_effort, verbosity, parent_conversation_id, created_at, updated_at) + VALUES (@id, @session_id, @user_id, @title, @provider_id, @model, @metadata, @streaming_enabled, @tools_enabled, @quality_level, @reasoning_effort, @verbosity, @parent_conversation_id, @now, @now)` ).run({ id, session_id: sessionId, @@ -34,6 +35,7 @@ export function createConversation({ quality_level: qualityLevel, reasoning_effort: reasoningEffort, verbosity, + parent_conversation_id: parentConversationId || null, now, }); } @@ -44,7 +46,7 @@ export function getConversationById({ id, userId }) { } const db = getDb(); - const query = `SELECT id, title, provider_id, model, metadata, streaming_enabled, tools_enabled, quality_level, reasoning_effort, verbosity, created_at FROM conversations + const query = `SELECT id, title, provider_id, model, metadata, streaming_enabled, tools_enabled, quality_level, reasoning_effort, verbosity, parent_conversation_id, created_at FROM conversations WHERE id=@id AND user_id=@user_id AND deleted_at IS NULL`; const result = db.prepare(query).get({ id, user_id: userId }); @@ -190,8 +192,9 @@ export function listConversations({ userId, cursor, limit }) { const safeLimit = clampLimit(limit, { fallback: 20, min: 1, max: 100 }); const { cursorCreatedAt, cursorId } = parseCreatedAtCursor(cursor); + // Exclude linked/comparison conversations (those with parent_conversation_id) let sql = `SELECT id, title, provider_id, model, created_at FROM conversations - WHERE user_id=@userId AND deleted_at IS NULL`; + WHERE user_id=@userId AND deleted_at IS NULL AND parent_conversation_id IS NULL`; const params = { userId, cursorCreatedAt, cursorId, limit: safeLimit + 1 }; sql = appendCreatedAtCursor(sql, { cursorCreatedAt, cursorId }); @@ -204,6 +207,25 @@ export function listConversations({ userId, cursor, limit }) { return { items, next_cursor }; } +/** + * Get linked/comparison conversations for a parent conversation + * @param {string} parentId - Parent conversation ID + * @param {string} userId - User ID + * @returns {Array} Array of linked conversation metadata + */ +export function getLinkedConversations({ parentId, userId }) { + if (!userId) { + throw new Error('userId is required'); + } + + const db = getDb(); + const query = `SELECT id, title, provider_id, model, created_at, updated_at FROM conversations + WHERE parent_conversation_id=@parentId AND user_id=@userId AND deleted_at IS NULL + ORDER BY datetime(created_at) ASC`; + + return db.prepare(query).all({ parentId, userId }); +} + export function softDeleteConversation({ id, userId }) { if (!userId) { throw new Error('userId is required'); @@ -211,6 +233,14 @@ export function softDeleteConversation({ id, userId }) { const db = getDb(); const now = new Date().toISOString(); + + // Also soft-delete any linked comparison conversations + db.prepare( + `UPDATE conversations SET deleted_at=@now, updated_at=@now + WHERE parent_conversation_id=@id AND user_id=@userId AND deleted_at IS NULL` + ).run({ id, userId, now }); + + // Delete the parent conversation const query = `UPDATE conversations SET deleted_at=@now, updated_at=@now WHERE id=@id AND user_id=@userId AND deleted_at IS NULL`; const info = db.prepare(query).run({ id, userId, now }); return info.changes > 0; diff --git a/backend/src/db/migrations/023-parent-conversation-id.js b/backend/src/db/migrations/023-parent-conversation-id.js new file mode 100644 index 00000000..7833af33 --- /dev/null +++ b/backend/src/db/migrations/023-parent-conversation-id.js @@ -0,0 +1,22 @@ +/** + * Migration: Add parent_conversation_id column for linked comparison conversations + * + * This enables storing comparison/secondary model responses as separate conversations + * that are linked to a primary conversation. Conversations with a non-null parent_conversation_id + * are excluded from the main conversation list. + */ + +export default { + version: 23, + up: ` + -- Add parent_conversation_id column for linked comparison conversations + ALTER TABLE conversations ADD COLUMN parent_conversation_id TEXT DEFAULT NULL; + + -- Create index for efficient lookups of child conversations + CREATE INDEX IF NOT EXISTS idx_conversations_parent_id ON conversations(parent_conversation_id); + `, + down: ` + -- Note: SQLite doesn't support DROP COLUMN, so the column will remain but be unused + DROP INDEX IF EXISTS idx_conversations_parent_id; + ` +}; diff --git a/backend/src/lib/openaiProxy.js b/backend/src/lib/openaiProxy.js index 271927e3..251b2a81 100644 --- a/backend/src/lib/openaiProxy.js +++ b/backend/src/lib/openaiProxy.js @@ -337,6 +337,24 @@ function handleProxyError(error, req, res, persistence) { async function handleRequest(context, req, res) { const { body, bodyIn, flags, provider, providerId, persistence, userId, abortContext } = context; + if (bodyIn?.parent_conversation_id && Array.isArray(bodyIn.messages)) { + const summarized = bodyIn.messages.map((msg) => ({ + role: msg?.role, + id: msg?.id, + contentLen: + typeof msg?.content === 'string' + ? msg.content.length + : Array.isArray(msg?.content) + ? msg.content.length + : 0, + })); + logger.debug('[openaiProxy] comparison request message history', { + parentConversationId: bodyIn.parent_conversation_id, + count: summarized.length, + summary: summarized, + }); + } + if (flags.hasTools) { // Tool orchestration path if (flags.streamToFrontend) { diff --git a/backend/src/lib/persistence/ConversationManager.js b/backend/src/lib/persistence/ConversationManager.js index 08f48257..bd0886e9 100644 --- a/backend/src/lib/persistence/ConversationManager.js +++ b/backend/src/lib/persistence/ConversationManager.js @@ -81,7 +81,8 @@ export class ConversationManager { qualityLevel: params.qualityLevel || null, reasoningEffort: params.reasoningEffort || null, verbosity: params.verbosity || null, - metadata: params.metadata || {} + metadata: params.metadata || {}, + parentConversationId: params.parentConversationId || null, }); return conversationId; diff --git a/backend/src/lib/retryUtils.js b/backend/src/lib/retryUtils.js index 3cd000ba..36249e9e 100644 --- a/backend/src/lib/retryUtils.js +++ b/backend/src/lib/retryUtils.js @@ -20,7 +20,7 @@ const DEFAULT_RETRY_CONFIG = { maxDelayMs: 60000, backoffMultiplier: 2, jitterFactor: 0.1, - shouldRetry: (error, attempt) => { + shouldRetry: (error, _attempt) => { // Only retry on 429 (rate limit) and 5xx server errors if (error?.status === 429) return true; if (error?.status >= 500 && error?.status < 600) return true; @@ -99,7 +99,7 @@ export async function retryWithBackoff(fn, userConfig = {}) { try { const clonedResponse = result.clone(); errorBody = await clonedResponse.text(); - } catch (e) { + } catch (_e) { errorBody = 'Could not read error body'; } diff --git a/backend/src/lib/simplifiedPersistence.js b/backend/src/lib/simplifiedPersistence.js index 5c6d9bd0..bdd1db7c 100644 --- a/backend/src/lib/simplifiedPersistence.js +++ b/backend/src/lib/simplifiedPersistence.js @@ -157,10 +157,13 @@ export class SimplifiedPersistence { // Create new conversation if needed if (isNewConversation) { const settings = await this.persistenceConfig.extractRequestSettingsAsync(bodyIn, userId); + // Support linked comparison conversations via parent_conversation_id + const parentConversationId = bodyIn.parent_conversation_id || null; conversationId = this.conversationManager.createNewConversation({ sessionId, userId, providerId: this.providerId, + parentConversationId, ...settings }); convo = this.conversationManager.getConversation(conversationId, userId); @@ -176,7 +179,23 @@ export class SimplifiedPersistence { * @private */ async _processMessageHistory(sessionId, userId, bodyIn, isNewConversation) { - const messages = this.persistenceConfig.filterNonSystemMessages(bodyIn.messages || []); + let messages = this.persistenceConfig.filterNonSystemMessages(bodyIn.messages || []); + const emptyAssistantMessages = messages.filter( + (msg) => + msg?.role === 'assistant' && + (msg.content === '' || (Array.isArray(msg.content) && msg.content.length === 0)) && + (!Array.isArray(msg.tool_calls) || msg.tool_calls.length === 0) && + (!Array.isArray(msg.tool_outputs) || msg.tool_outputs.length === 0) + ); + if (emptyAssistantMessages.length > 0) { + logger.debug('[SimplifiedPersistence] Dropping empty assistant messages from client history', { + conversationId: this.conversationId, + parentConversationId: bodyIn?.parent_conversation_id ?? null, + count: emptyAssistantMessages.length, + ids: emptyAssistantMessages.map((msg) => msg?.id).filter(Boolean), + }); + messages = messages.filter((msg) => !emptyAssistantMessages.includes(msg)); + } const maxSeq = messages .map(msg => msg.seq) .filter(seq => typeof seq === 'number' && seq > 0) @@ -192,9 +211,10 @@ export class SimplifiedPersistence { const latestUserMapping = [...this._latestSyncMappings].reverse().find(mapping => mapping.role === 'user'); this.userMessageId = latestUserMapping?.persistedId != null ? String(latestUserMapping.persistedId) : null; - // Generate title only if this is the first message in a new conversation - // Fire-and-forget to avoid blocking the response - if (isNewConversation) { + // Generate title when the conversation is new or still lacks a title. + // Fire-and-forget to avoid blocking the response. + const needsTitle = isNewConversation || !this.conversationMeta?.title; + if (needsTitle) { const lastUser = ConversationTitleService.findLastUserMessage(messages); if (lastUser) { // Extract the model being used for the chat to use the same model for title generation diff --git a/backend/src/lib/streamUtils.js b/backend/src/lib/streamUtils.js index f0da9d19..fad14f40 100644 --- a/backend/src/lib/streamUtils.js +++ b/backend/src/lib/streamUtils.js @@ -158,7 +158,7 @@ export function teeStreamWithPreview(response, options = {}) { try { out.write(chunk); capture.write(chunk); - } catch (e) { + } catch (_e) { // best-effort capture; ignore } }); @@ -167,7 +167,7 @@ export function teeStreamWithPreview(response, options = {}) { try { out.end(); capture.end(); - } catch (e) { + } catch (_e) { // ignore } }); @@ -176,7 +176,7 @@ export function teeStreamWithPreview(response, options = {}) { try { out.destroy(err); capture.destroy(err); - } catch (e) { + } catch (_e) { // ignore } }); diff --git a/backend/src/lib/toolOrchestrationUtils.js b/backend/src/lib/toolOrchestrationUtils.js index 2d70a17c..c42b5502 100644 --- a/backend/src/lib/toolOrchestrationUtils.js +++ b/backend/src/lib/toolOrchestrationUtils.js @@ -747,7 +747,7 @@ export async function executeToolCallsParallel(toolCalls, userId = null, options let pLimit; try { pLimit = (await import('p-limit')).default; - } catch (err) { + } catch (_err) { // If p-limit is not available, fall back to Promise.allSettled (unbounded) pLimit = null; } @@ -767,7 +767,7 @@ export async function executeToolCallsParallel(toolCalls, userId = null, options const out = { ...result, tool_call_id: call.id, index, duration_ms: duration, status: 'success' }; try { if (typeof onToolComplete === 'function') onToolComplete(out, index); - } catch (e) { + } catch (_e) { // Swallow callback errors } return out; @@ -783,7 +783,7 @@ export async function executeToolCallsParallel(toolCalls, userId = null, options }; try { if (typeof onToolComplete === 'function') onToolComplete(out, index); - } catch (e) {} + } catch (_e) {} return out; } }; diff --git a/backend/src/middleware/exceptionHandler.js b/backend/src/middleware/exceptionHandler.js index 8076f4bd..9c0d4f72 100644 --- a/backend/src/middleware/exceptionHandler.js +++ b/backend/src/middleware/exceptionHandler.js @@ -1,6 +1,6 @@ import { logger } from '../logger.js'; -export function exceptionHandler(err, req, res, next) { +export function exceptionHandler(err, req, res, _next) { const statusCode = err.statusCode || 500; const message = err.message || 'Internal Server Error'; diff --git a/backend/src/routes/conversations.js b/backend/src/routes/conversations.js index 976d282b..3388769d 100644 --- a/backend/src/routes/conversations.js +++ b/backend/src/routes/conversations.js @@ -12,6 +12,7 @@ import { softDeleteConversation, listConversationsIncludingDeleted, forkConversationFromMessage, + getLinkedConversations, } from '../db/conversations.js'; import { getMessagesPage, @@ -206,6 +207,25 @@ conversationsRouter.delete('/v1/conversations/:id', (req, res) => { } }); +// GET /v1/conversations/:id/linked (get linked comparison conversations) +conversationsRouter.get('/v1/conversations/:id/linked', (req, res) => { + if (!config.persistence.enabled) return notImplemented(res); + try { + const userId = req.user.id; // Guaranteed by authenticateToken middleware + + getDb(); + // First verify the parent conversation exists and belongs to the user + const parentConvo = getConversationById({ id: req.params.id, userId }); + if (!parentConvo) return res.status(404).json({ error: 'not_found' }); + + const linkedConversations = getLinkedConversations({ parentId: req.params.id, userId }); + return res.json({ conversations: linkedConversations }); + } catch (e) { + logger.error('[conversations] get linked error', e); + return res.status(500).json({ error: 'internal_error' }); + } +}); + // PUT /v1/conversations/:id/messages/:messageId/edit (edit message and fork conversation) conversationsRouter.put('/v1/conversations/:id/messages/:messageId/edit', (req, res) => { if (!config.persistence.enabled) return notImplemented(res); diff --git a/frontend/__tests__/hooks.useChat.test.tsx b/frontend/__tests__/hooks.useChat.test.tsx index c267823d..1c835273 100644 --- a/frontend/__tests__/hooks.useChat.test.tsx +++ b/frontend/__tests__/hooks.useChat.test.tsx @@ -1,7 +1,7 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import type { ChatOptionsExtended } from '../lib/types'; import { useChat } from '../hooks/useChat'; -import { APIError } from '../lib/streaming'; +import { APIError, StreamingNotSupportedError } from '../lib/streaming'; jest.mock('../lib/api', () => ({ conversations: { @@ -187,6 +187,7 @@ describe('useChat hook', () => { }); test('sendMessage streams tokens, tool events, and finalizes assistant message', async () => { + // chat.sendMessage receives unqualified model IDs (without provider prefix) mockChat.sendMessage.mockImplementation(async (options: ChatOptionsExtended) => { options.onToken?.('Hello'); options.onEvent?.({ @@ -586,4 +587,199 @@ describe('useChat hook', () => { expect(result.current.modelToProvider['provider1::model-1']).toBe('provider1'); expect(result.current.modelToProvider['provider2::model-2']).toBe('provider2'); }); + + test('normalizes comparison models using provider mapping', async () => { + const { result } = renderUseChat(); + + await waitFor(() => expect(result.current.modelOptions.length).toBeGreaterThan(0)); + + act(() => { + result.current.setCompareModels(['gpt-4o', 'openai::gpt-4o']); + }); + + await waitFor(() => { + expect(result.current.compareModels).toEqual(['openai::gpt-4o']); + }); + }); + + test('sendMessage executes comparison models and stores their results', async () => { + const now = new Date().toISOString(); + mockConversations.create.mockResolvedValue({ + id: 'conv-primary', + title: 'Primary', + created_at: now, + }); + + mockChat.sendMessage.mockImplementation(async (options: ChatOptionsExtended) => { + options.onToken?.('X'); + const convId = options.conversationId || `conv-${options.model}`; + return { + content: `reply-${options.model}`, + conversation: { + id: convId, + title: `title-${options.model}`, + created_at: now, + }, + }; + }); + + const { result } = renderUseChat(); + + await waitFor(() => expect(result.current.providerId).toBe('openai')); + + act(() => { + result.current.setModel('openai::gpt-4o'); + result.current.setCompareModels(['openai::gpt-4o-mini']); + result.current.setInput('Compare models'); + }); + + await act(async () => { + await result.current.sendMessage(); + }); + + expect(mockConversations.create).toHaveBeenCalled(); + expect(mockChat.sendMessage).toHaveBeenCalledTimes(2); + expect(mockChat.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ model: 'gpt-4o' })); + expect(mockChat.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ model: 'gpt-4o-mini' }) + ); + + const assistantMessage = result.current.messages[1]; + expect(assistantMessage.comparisonResults?.['openai::gpt-4o-mini']).toMatchObject({ + content: 'reply-gpt-4o-mini', + status: 'complete', + }); + expect(result.current.linkedConversations['openai::gpt-4o-mini']).toBe('conv-gpt-4o-mini'); + }); + + test('restores saved draft on mount after user profile loads', async () => { + jest.useFakeTimers(); + const draftKey = 'chatforge_draft_user-123_new'; + window.localStorage.setItem(draftKey, 'Saved draft text'); + + const { result } = renderUseChat(); + + await waitFor(() => expect(result.current.user?.id).toBe('user-123')); + + await act(async () => { + jest.advanceTimersByTime(150); + }); + + expect(result.current.input).toBe('Saved draft text'); + jest.useRealTimers(); + }); + + test('debounces draft saving while typing', async () => { + jest.useFakeTimers(); + const draftKey = 'chatforge_draft_user-123_new'; + const { result } = renderUseChat(); + + await waitFor(() => expect(result.current.user?.id).toBe('user-123')); + + act(() => { + result.current.setInput('Draft to persist'); + }); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(window.localStorage.getItem(draftKey)).toBe('Draft to persist'); + jest.useRealTimers(); + }); + + test('sendMessage retries without provider streaming when unsupported', async () => { + jest.useFakeTimers(); + const now = new Date().toISOString(); + + mockChat.sendMessage + .mockRejectedValueOnce(new StreamingNotSupportedError('Streaming not supported')) + .mockResolvedValueOnce({ + content: 'Retried response', + conversation: { id: 'conv-retry', title: 'Retry', created_at: now }, + }); + + const { result } = renderUseChat(); + + await act(async () => { + await result.current.sendMessage('Hello'); + }); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + await waitFor(() => expect(mockChat.sendMessage).toHaveBeenCalledTimes(2)); + expect(mockChat.sendMessage.mock.calls[1][0].providerStream).toBe(false); + await waitFor(() => expect(result.current.status).toBe('idle')); + expect(result.current.messages).toHaveLength(2); + jest.useRealTimers(); + }); + + test('sendMessage includes files and images in the outgoing payload', async () => { + const now = new Date().toISOString(); + mockChat.sendMessage.mockResolvedValue({ + content: 'ok', + conversation: { id: 'conv-files', title: 'Files', created_at: now }, + }); + + const { result } = renderUseChat(); + + act(() => { + result.current.setInput('Please review the files'); + result.current.setFiles([{ name: 'example.ts', content: 'console.log(1);' }]); + result.current.setImages([ + { url: 'http://example.com/image.png', downloadUrl: 'http://cdn/image.png' }, + ]); + }); + + await act(async () => { + await result.current.sendMessage(); + }); + + const payload = mockChat.sendMessage.mock.calls[0][0]; + const userMessage = payload.messages[0]; + expect(Array.isArray(userMessage.content)).toBe(true); + expect(userMessage.content[0]).toMatchObject({ + type: 'text', + }); + expect(userMessage.content[0].text).toContain('File: example.ts'); + expect(userMessage.content[0].text).toContain('```typescript'); + expect(userMessage.content[0].text).toContain('Please review the files'); + expect(userMessage.content[1]).toEqual({ + type: 'image_url', + image_url: { url: 'http://cdn/image.png' }, + }); + expect(result.current.input).toBe(''); + expect(result.current.files).toEqual([]); + expect(result.current.images).toEqual([]); + }); + + test('regenerate reuses the original user message id without duplication', async () => { + const now = new Date().toISOString(); + mockChat.sendMessage.mockResolvedValue({ + content: 'regenerated', + conversation: { id: 'conv-regenerate', title: 'Regenerated', created_at: now }, + }); + + const { result } = renderUseChat(); + + act(() => { + result.current.setModel('openai::gpt-4o'); + }); + + const baseMessages = [ + { id: 'user-1', role: 'user', content: 'Original question' }, + { id: 'assistant-1', role: 'assistant', content: 'Old answer' }, + ]; + + await act(async () => { + await result.current.regenerate(baseMessages as any); + }); + + expect(result.current.messages).toHaveLength(3); + const payload = mockChat.sendMessage.mock.calls[0][0]; + const userMessageIds = payload.messages.filter((msg: any) => msg.id === 'user-1'); + expect(userMessageIds).toHaveLength(1); + }); }); diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index b9aa6b99..116446d1 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from 'next/font/google'; import './globals.css'; import { ThemeProvider } from '../contexts/ThemeContext'; import { AuthProvider } from '../contexts/AuthContext'; +import { ToastProvider } from '../components/ui/Toast'; const geistSans = Geist({ variable: '--font-geist-sans', @@ -28,7 +29,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/frontend/components/ChatHeader.tsx b/frontend/components/ChatHeader.tsx index d97efdc4..fa7c9fda 100644 --- a/frontend/components/ChatHeader.tsx +++ b/frontend/components/ChatHeader.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Sun, Moon, Settings, RefreshCw, Loader2, PanelLeft, PanelRight, Plus } from 'lucide-react'; import { useTheme } from '../contexts/ThemeContext'; import ModelSelector from './ui/ModelSelector'; +import CompareSelector from './ui/CompareSelector'; import { type Group as TabGroup } from './ui/TabbedSelect'; import { AuthButton } from './auth/AuthButton'; @@ -24,6 +25,12 @@ interface ChatHeaderProps { onToggleRightSidebar?: () => void; showLeftSidebarButton?: boolean; showRightSidebarButton?: boolean; + selectedComparisonModels?: string[]; + onComparisonModelsChange?: (models: string[]) => void; + comparisonLocked?: boolean; + comparisonLockReason?: string; + modelSelectionLocked?: boolean; + modelSelectionLockReason?: string; } export function ChatHeader({ @@ -44,6 +51,12 @@ export function ChatHeader({ showLeftSidebarButton = false, showRightSidebarButton = false, onNewChat, + selectedComparisonModels = [], + onComparisonModelsChange, + comparisonLocked = false, + comparisonLockReason = 'Model comparison is locked after the first message.', + modelSelectionLocked = false, + modelSelectionLockReason = 'Primary model is locked after comparison starts.', }: ChatHeaderProps) { const { theme, setTheme, resolvedTheme } = useTheme(); @@ -135,7 +148,21 @@ export function ChatHeader({ className="text-sm sm:text-base md:text-lg" ariaLabel="Model" onAfterChange={onFocusMessageInput} + disabled={modelSelectionLocked} + disabledReason={modelSelectionLockReason} /> + {onComparisonModelsChange && ( + + )} {onRefreshModels && (