Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/webuiapps/src/components/ChatPanel/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,38 @@
cursor: pointer;
}

.modelSelectorWrapper {
display: flex;
align-items: center;
gap: 4px;

.select {
flex: 1;
}

.fieldInput {
flex: 1;
}
}

.manualToggleBtn {
padding: 6px 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.6);
transition: all 0.2s;

&:hover {
background: #282a2a;
color: rgba(255, 255, 255, 0.9);
}
}

.settingsActions {
display: flex;
gap: 8px;
Expand Down
83 changes: 68 additions & 15 deletions apps/webuiapps/src/components/ChatPanel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,16 @@ import {
Maximize2,
ChevronDown,
ChevronRight,
Pencil,
List,
} from 'lucide-react';
import { chat, loadConfig, loadConfigSync, saveConfig, type ChatMessage } from '@/lib/llmClient';
import {
chat,
loadConfig,
loadConfigSync,
saveConfig,
getDefaultConfig,
PROVIDER_MODELS,
getDefaultProviderConfig,
type LLMConfig,
type LLMProvider,
type ChatMessage,
} from '@/lib/llmClient';
} from '@/lib/llmModels';
import {
loadImageGenConfig,
loadImageGenConfigSync,
Expand Down Expand Up @@ -1008,6 +1007,7 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({
{messages.map((msg) => (
<React.Fragment key={msg.id}>
<div
data-testid="chat-message"
className={`${styles.message} ${
msg.role === 'user'
? styles.user
Expand Down Expand Up @@ -1121,9 +1121,15 @@ const SettingsModal: React.FC<{
// LLM settings
const [provider, setProvider] = useState<LLMProvider>(config?.provider || 'minimax');
const [apiKey, setApiKey] = useState(config?.apiKey || '');
const [baseUrl, setBaseUrl] = useState(config?.baseUrl || getDefaultConfig('minimax').baseUrl);
const [model, setModel] = useState(config?.model || getDefaultConfig('minimax').model);
const [baseUrl, setBaseUrl] = useState(
config?.baseUrl || getDefaultProviderConfig('minimax').baseUrl,
);
const [model, setModel] = useState(config?.model || getDefaultProviderConfig('minimax').model);
const [customHeaders, setCustomHeaders] = useState(config?.customHeaders || '');
const [manualModelMode, setManualModelMode] = useState(false);

const isPresetModel = PROVIDER_MODELS[provider]?.includes(model) ?? false;
const showDropdown = !manualModelMode && isPresetModel;

// Image gen settings
const [igProvider, setIgProvider] = useState<ImageGenProvider>(
Expand All @@ -1140,9 +1146,15 @@ const SettingsModal: React.FC<{

const handleProviderChange = (p: LLMProvider) => {
setProvider(p);
const defaults = getDefaultConfig(p);
const defaults = getDefaultProviderConfig(p);
setBaseUrl(defaults.baseUrl);
setModel(defaults.model);
setManualModelMode(false);
};

const handleModelChange = (newModel: string) => {
setModel(newModel);
setManualModelMode(false);
};

const handleIgProviderChange = (p: ImageGenProvider) => {
Expand All @@ -1168,6 +1180,8 @@ const SettingsModal: React.FC<{
<option value="anthropic">Anthropic</option>
<option value="deepseek">DeepSeek</option>
<option value="minimax">MiniMax</option>
<option value="z.ai">Z.ai</option>
<option value="kimi">Kimi</option>
</select>
</div>

Expand All @@ -1193,11 +1207,50 @@ const SettingsModal: React.FC<{

<div className={styles.field}>
<label className={styles.label}>Model</label>
<input
className={styles.fieldInput}
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<div className={styles.modelSelectorWrapper}>
{showDropdown ? (
<>
<select
className={styles.select}
value={model}
onChange={(e) => handleModelChange(e.target.value)}
>
{PROVIDER_MODELS[provider]?.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<button
type="button"
onClick={() => setManualModelMode(true)}
className={styles.manualToggleBtn}
title="Enter custom model name"
>
<Pencil size={14} />
</button>
</>
) : (
<>
<input
className={styles.fieldInput}
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="e.g. gpt-4-turbo"
/>
{isPresetModel && (
<button
type="button"
onClick={() => setManualModelMode(false)}
className={styles.manualToggleBtn}
title="Back to model list"
>
<List size={14} />
</button>
)}
</>
)}
</div>
</div>

<div className={styles.field}>
Expand Down
116 changes: 32 additions & 84 deletions apps/webuiapps/src/lib/__tests__/chatHistoryStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import type { ChatMessage } from '../llmClient';
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

const STORAGE_KEY = 'webuiapps-chat-history';
const SESSION_PATH = 'char-1/mod-1';

function expectedUrl(file: string): string {
return `/api/session-data?path=${encodeURIComponent(`${SESSION_PATH}/chat/${file}`)}`;
}

const sampleMessages: DisplayMessage[] = [
{ id: '1', role: 'user', content: 'Hello' },
Expand All @@ -24,157 +28,101 @@ function makeSavedData(msgs = sampleMessages, history = sampleChatHistory): Chat
describe('chatHistoryStorage', () => {
beforeEach(() => {
fetchMock.mockReset();
localStorage.clear();
vi.resetModules();
});

// ============ loadChatHistorySync ============

describe('loadChatHistorySync', () => {
it('returns null when localStorage is empty', async () => {
const { loadChatHistorySync } = await import('../chatHistoryStorage');
expect(loadChatHistorySync()).toBeNull();
});

it('returns data from localStorage', async () => {
const data = makeSavedData();
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
const { loadChatHistorySync } = await import('../chatHistoryStorage');
const result = loadChatHistorySync();
expect(result).not.toBeNull();
expect(result!.messages).toHaveLength(2);
expect(result!.chatHistory).toHaveLength(2);
expect(result!.version).toBe(1);
});

it('returns null for invalid JSON', async () => {
localStorage.setItem(STORAGE_KEY, 'not-json');
const { loadChatHistorySync } = await import('../chatHistoryStorage');
expect(loadChatHistorySync()).toBeNull();
});

it('returns null for wrong version', async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ version: 99, savedAt: 0, messages: [], chatHistory: [] }),
);
it('returns null', async () => {
const { loadChatHistorySync } = await import('../chatHistoryStorage');
expect(loadChatHistorySync()).toBeNull();
expect(loadChatHistorySync(SESSION_PATH)).toBeNull();
});
});

// ============ loadChatHistory (async) ============

describe('loadChatHistory', () => {
it('loads from API and syncs to localStorage', async () => {
it('loads from API', async () => {
const data = makeSavedData();
fetchMock.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(data),
});
const { loadChatHistory } = await import('../chatHistoryStorage');

const result = await loadChatHistory();
const result = await loadChatHistory(SESSION_PATH);

expect(fetchMock).toHaveBeenCalledWith('/api/chat-history');
expect(fetchMock).toHaveBeenCalledWith(expectedUrl('chat.json'));
expect(result).not.toBeNull();
expect(result!.messages).toEqual(sampleMessages);
// Verify synced to localStorage
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
expect(stored.version).toBe(1);
});

it('falls back to localStorage when API returns non-ok', async () => {
const data = makeSavedData();
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
it('returns null when API returns non-ok', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 });
const { loadChatHistory } = await import('../chatHistoryStorage');

const result = await loadChatHistory();
const result = await loadChatHistory(SESSION_PATH);

expect(result).not.toBeNull();
expect(result!.messages).toEqual(sampleMessages);
expect(result).toBeNull();
});

it('falls back to localStorage when fetch throws', async () => {
const data = makeSavedData();
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
it('returns null when fetch throws', async () => {
fetchMock.mockRejectedValueOnce(new Error('network error'));
const { loadChatHistory } = await import('../chatHistoryStorage');

const result = await loadChatHistory();
const result = await loadChatHistory(SESSION_PATH);

expect(result).not.toBeNull();
expect(result!.messages).toEqual(sampleMessages);
expect(result).toBeNull();
});

it('returns null when both API and localStorage are empty', async () => {
it('returns null when API is empty', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 });
const { loadChatHistory } = await import('../chatHistoryStorage');

const result = await loadChatHistory();
const result = await loadChatHistory(SESSION_PATH);
expect(result).toBeNull();
});
});

// ============ saveChatHistory ============

describe('saveChatHistory', () => {
it('saves to localStorage and POSTs to API', async () => {
it('POSTs to API with expected payload', async () => {
fetchMock.mockResolvedValueOnce({ ok: true });
const { saveChatHistory } = await import('../chatHistoryStorage');

await saveChatHistory(sampleMessages, sampleChatHistory);

// Check localStorage
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
expect(stored.version).toBe(1);
expect(stored.messages).toEqual(sampleMessages);
expect(stored.chatHistory).toEqual(sampleChatHistory);
expect(typeof stored.savedAt).toBe('number');
await saveChatHistory(SESSION_PATH, sampleMessages, sampleChatHistory);

// Check fetch call
expect(fetchMock).toHaveBeenCalledOnce();
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe('/api/chat-history');
expect(url).toBe(expectedUrl('chat.json'));
expect(options.method).toBe('POST');
const body = JSON.parse(options.body);
expect(body.version).toBe(1);
expect(body.messages).toEqual(sampleMessages);
expect(body.chatHistory).toEqual(sampleChatHistory);
});

it('saves to localStorage even when fetch fails', async () => {
it('does not throw when fetch fails', async () => {
fetchMock.mockRejectedValueOnce(new Error('network error'));
const { saveChatHistory } = await import('../chatHistoryStorage');

await saveChatHistory(sampleMessages, sampleChatHistory);

const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
expect(stored.messages).toEqual(sampleMessages);
await expect(
saveChatHistory(SESSION_PATH, sampleMessages, sampleChatHistory),
).resolves.toBeUndefined();
});
});

// ============ clearChatHistory ============

describe('clearChatHistory', () => {
it('removes from localStorage and sends DELETE to API', async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(makeSavedData()));
it('sends DELETE to API', async () => {
fetchMock.mockResolvedValueOnce({ ok: true });
const { clearChatHistory } = await import('../chatHistoryStorage');

await clearChatHistory();
await clearChatHistory(SESSION_PATH);

expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(fetchMock).toHaveBeenCalledWith('/api/chat-history', { method: 'DELETE' });
expect(fetchMock).toHaveBeenCalledWith(expectedUrl('chat.json'), { method: 'DELETE' });
});

it('clears localStorage even when DELETE fetch fails', async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(makeSavedData()));
it('does not throw when DELETE fetch fails', async () => {
fetchMock.mockRejectedValueOnce(new Error('network error'));
const { clearChatHistory } = await import('../chatHistoryStorage');

await clearChatHistory();

expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
await expect(clearChatHistory(SESSION_PATH)).resolves.toBeUndefined();
});
});
});
2 changes: 1 addition & 1 deletion apps/webuiapps/src/lib/__tests__/configPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
savePersistedConfig,
type PersistedConfig,
} from '../configPersistence';
import type { LLMConfig } from '../llmClient';
import type { LLMConfig } from '../llmModels';
import type { ImageGenConfig } from '../imageGenClient';

// ─── Constants ──────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading