mirror of
https://github.com/vee1e/isscope.git
synced 2026-09-01 18:57:06 +00:00
fix(local): give a CORS-aware error and a Test Connection button
The previous `callLocal` re-threw the raw browser `NetworkError`
("NetworkError when attempting to fetch resource" / "Failed to
fetch"), which is what happens when the browser blocks a cross-origin
request because the local server has not enabled CORS. Users running
LMStudio or older Ollama versions had no way to know that — the error
just said "network error" with no actionable hint.
- `callLocal` now catches the fetch `TypeError` and throws a
descriptive `Error` that names the endpoint and includes the exact
CORS fix for the detected server (LMStudio → "Local Server → ⚙ →
enable CORS", Ollama → set `OLLAMA_ORIGINS="*"`). The original
error is preserved via `Error.cause`.
- New `testLocalConnection` helper that hits `${endpoint}/models`
and returns either `{ ok, models }` or `{ ok, error }` with the
same CORS-aware error message. The Authorization header is sent when
an API key is configured.
- New `detectLocalServer` and `corsHelpText` helpers (port-based
detection for LMStudio 1234 / Ollama 11434).
- InputScreen now renders a "Test" button next to the Local Endpoint
field. Success shows the model list ("2 models available: llama3.2,
qwen2.5-coder:7b"); failure shows the CORS-aware error inline. The
label is now just "Local Endpoint" with the per-server URLs moved
to a hint line that explicitly mentions the required `/v1` path
(LMStudio users were previously typing `http://127.0.0.1:1234`
without `/v1`, which would 404 once CORS was fixed).
- README: new "Local LLM provider (Ollama / LMStudio)" section with
startup commands, CORS instructions, and a troubleshooting line
pointing users to the in-app error message.
- 11 new tests for the local client (network error wrapping, test
connection success/failure, non-JSON response, server detection,
CORS help text). 4 new InputScreen tests for the provider UI and
Test button. 186/186 pass.
This commit is contained in:
parent
0bb9cd320e
commit
000f105d70
5 changed files with 451 additions and 21 deletions
14
README.md
14
README.md
|
|
@ -78,6 +78,20 @@ The application requires API keys to function. You can configure these directly
|
|||
- **OpenRouter API Key**: Required for AI analysis.
|
||||
- **GitHub Token**: Optional, but recommended for higher API rate limits.
|
||||
|
||||
### Local LLM provider (Ollama / LMStudio)
|
||||
|
||||
You can run analysis against a local model served by [Ollama](https://ollama.com) or [LMStudio](https://lmstudio.ai) instead of OpenRouter — no API key required. Both expose an OpenAI-compatible `/v1/chat/completions` endpoint.
|
||||
|
||||
1. **Start your local server.**
|
||||
- **Ollama:** `ollama serve` (default endpoint: `http://localhost:11434/v1`). Pull a model first, e.g. `ollama pull llama3.2`.
|
||||
- **LMStudio:** Open the "Local Server" tab and click Start Server (default endpoint: `http://localhost:1234/v1`).
|
||||
2. **Enable CORS** so the browser can call the server from this app:
|
||||
- **LMStudio:** in the Local Server tab, click the ⚙ settings icon and enable **CORS**.
|
||||
- **Ollama:** Ollama 0.1.14+ allows browser origins by default. For older versions, set the `OLLAMA_ORIGINS="*"` environment variable before starting the server.
|
||||
3. In the app, open **Configure API Keys → AI Provider → Local (Ollama / LMStudio)**, set the endpoint, model name, and (optionally) an API key, then click **Test** to verify the connection before saving.
|
||||
|
||||
If you see `NetworkError when attempting to fetch resource` (Firefox) or `Failed to fetch` (Chrome) when starting an analysis, the server is unreachable or CORS is disabled — the in-app error message includes the exact fix for your server.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Enter a GitHub repository (e.g., `owner/repo`) or a full URL in the input field.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { callLocal, resolveLocalConfig, isLocalConfigured } from './local';
|
||||
import {
|
||||
callLocal,
|
||||
resolveLocalConfig,
|
||||
isLocalConfigured,
|
||||
testLocalConnection,
|
||||
detectLocalServer,
|
||||
corsHelpText,
|
||||
} from './local';
|
||||
|
||||
describe('Local LLM client', () => {
|
||||
let mockFetch: any;
|
||||
|
|
@ -48,8 +55,6 @@ describe('Local LLM client', () => {
|
|||
{ role: 'system', content: 'system' },
|
||||
{ role: 'user', content: 'user' },
|
||||
]);
|
||||
// Local providers should not get the strict json_object response_format
|
||||
// (some local backends reject it).
|
||||
expect(body.response_format).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -101,6 +106,144 @@ describe('Local LLM client', () => {
|
|||
/Local LLM error 503/,
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps network errors (e.g. CORS rejections) with a helpful, endpoint-specific message', async () => {
|
||||
const networkError = new TypeError('NetworkError when attempting to fetch resource.');
|
||||
mockFetch.mockRejectedValue(networkError);
|
||||
|
||||
await expect(
|
||||
callLocal('s', 'u', { endpoint: 'http://127.0.0.1:1234/v1', model: 'm' }),
|
||||
).rejects.toThrow(/Could not reach the local LLM server at http:\/\/127\.0\.0\.1:1234\/v1/);
|
||||
|
||||
await expect(
|
||||
callLocal('s', 'u', { endpoint: 'http://127.0.0.1:1234/v1', model: 'm' }),
|
||||
).rejects.toThrow(/LMStudio/);
|
||||
|
||||
mockFetch.mockClear();
|
||||
mockFetch.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
|
||||
await expect(
|
||||
callLocal('s', 'u', { endpoint: 'http://localhost:11434/v1', model: 'm' }),
|
||||
).rejects.toThrow(/Ollama/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testLocalConnection', () => {
|
||||
it('returns ok with the list of model ids on success', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
createMockResponse(200, {
|
||||
data: [{ id: 'llama3.2' }, { id: 'qwen2.5-coder:7b' }, { id: 42 }],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await testLocalConnection({
|
||||
endpoint: 'http://localhost:11434/v1',
|
||||
model: 'llama3.2',
|
||||
});
|
||||
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('http://localhost:11434/v1/models');
|
||||
expect(mockFetch.mock.calls[0][1].method).toBe('GET');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.models).toEqual(['llama3.2', 'qwen2.5-coder:7b']);
|
||||
});
|
||||
|
||||
it('returns ok with empty model list when response has no data array', async () => {
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||
|
||||
const result = await testLocalConnection({
|
||||
endpoint: 'http://localhost:1234/v1',
|
||||
model: 'm',
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.models).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a CORS-aware error when fetch itself rejects', async () => {
|
||||
mockFetch.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
|
||||
const result = await testLocalConnection({
|
||||
endpoint: 'http://127.0.0.1:1234/v1',
|
||||
model: 'm',
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/Could not reach the local LLM server/);
|
||||
expect(result.error).toMatch(/LMStudio/);
|
||||
expect(result.models).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns a status error when the server responds with non-OK', async () => {
|
||||
mockFetch.mockResolvedValue(createMockResponse(404, 'not here'));
|
||||
|
||||
const result = await testLocalConnection({
|
||||
endpoint: 'http://localhost:11434/v1',
|
||||
model: 'm',
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/404/);
|
||||
});
|
||||
|
||||
it('returns a parse error when the server returns non-JSON', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
// Simulate a real browser response.json() that throws on invalid JSON
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token < in JSON at position 0');
|
||||
},
|
||||
text: async () => '<html>not json</html>',
|
||||
});
|
||||
|
||||
const result = await testLocalConnection({
|
||||
endpoint: 'http://localhost:11434/v1',
|
||||
model: 'm',
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatch(/non-JSON/);
|
||||
});
|
||||
|
||||
it('sends the Authorization header when an API key is configured', async () => {
|
||||
mockFetch.mockResolvedValue(createMockResponse(200, { data: [] }));
|
||||
|
||||
await testLocalConnection({
|
||||
endpoint: 'http://localhost:1234/v1',
|
||||
model: 'm',
|
||||
apiKey: 'lm-studio',
|
||||
});
|
||||
expect(mockFetch.mock.calls[0][1].headers.Authorization).toBe('Bearer lm-studio');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectLocalServer', () => {
|
||||
it('detects LMStudio by port 1234', () => {
|
||||
expect(detectLocalServer('http://127.0.0.1:1234/v1')).toBe('lmstudio');
|
||||
expect(detectLocalServer('http://localhost:1234/v1')).toBe('lmstudio');
|
||||
});
|
||||
|
||||
it('detects Ollama by port 11434', () => {
|
||||
expect(detectLocalServer('http://127.0.0.1:11434/v1')).toBe('ollama');
|
||||
expect(detectLocalServer('http://localhost:11434')).toBe('ollama');
|
||||
});
|
||||
|
||||
it('falls back to "other" for unknown ports', () => {
|
||||
expect(detectLocalServer('http://example.com:9000/v1')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('corsHelpText', () => {
|
||||
it('mentions LMStudio CORS setting for port 1234', () => {
|
||||
expect(corsHelpText('http://127.0.0.1:1234/v1')).toMatch(/LMStudio/);
|
||||
expect(corsHelpText('http://127.0.0.1:1234/v1')).toMatch(/CORS/);
|
||||
});
|
||||
|
||||
it('mentions Ollama CORS guidance for port 11434', () => {
|
||||
expect(corsHelpText('http://localhost:11434/v1')).toMatch(/Ollama/);
|
||||
expect(corsHelpText('http://localhost:11434/v1')).toMatch(/OLLAMA_ORIGINS/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLocalConfig', () => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,33 @@ function normalizeEndpoint(endpoint: string): string {
|
|||
return endpoint.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function detectLocalServer(endpoint: string): 'lmstudio' | 'ollama' | 'other' {
|
||||
const url = endpoint.toLowerCase();
|
||||
if (url.includes(':1234')) return 'lmstudio';
|
||||
if (url.includes(':11434')) return 'ollama';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function corsHelpText(endpoint: string): string {
|
||||
const server = detectLocalServer(endpoint);
|
||||
if (server === 'lmstudio') {
|
||||
return 'In LMStudio, open the "Local Server" tab, click the ⚙ settings icon, and enable "CORS".';
|
||||
}
|
||||
if (server === 'ollama') {
|
||||
return 'In Ollama, set the OLLAMA_ORIGINS="*" environment variable before starting the server (or update to Ollama 0.1.14+, which allows browser origins by default).';
|
||||
}
|
||||
return 'Make sure the server allows browser (CORS) requests from this origin.';
|
||||
}
|
||||
|
||||
function describeNetworkError(endpoint: string, original: string): string {
|
||||
return (
|
||||
`Could not reach the local LLM server at ${endpoint}. ` +
|
||||
`Verify the server is running and reachable. ` +
|
||||
`If the server is up, this is almost always a CORS issue — ${corsHelpText(endpoint)} ` +
|
||||
`(Original error: ${original})`
|
||||
);
|
||||
}
|
||||
|
||||
export async function callLocal(
|
||||
prompt: string,
|
||||
userMessage: string,
|
||||
|
|
@ -24,19 +51,25 @@ export async function callLocal(
|
|||
headers.Authorization = `Bearer ${options.apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${endpoint}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: options.model,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt },
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 1024,
|
||||
}),
|
||||
});
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${endpoint}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: options.model,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt },
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 1024,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(describeNetworkError(endpoint, message), { cause: err });
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new Error('RATE_LIMITED');
|
||||
|
|
@ -51,6 +84,55 @@ export async function callLocal(
|
|||
return data.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
export interface LocalConnectionTestResult {
|
||||
ok: boolean;
|
||||
models?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function testLocalConnection(
|
||||
options: LocalProviderOptions,
|
||||
): Promise<LocalConnectionTestResult> {
|
||||
const endpoint = normalizeEndpoint(options.endpoint);
|
||||
const headers: Record<string, string> = {};
|
||||
if (options.apiKey) {
|
||||
headers.Authorization = `Bearer ${options.apiKey}`;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${endpoint}/models`, { method: 'GET', headers });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, error: describeNetworkError(endpoint, message) };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Local server responded with ${response.status} ${response.statusText}.`,
|
||||
};
|
||||
}
|
||||
|
||||
let data: any;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Local server responded with non-JSON data — is this an OpenAI-compatible endpoint?',
|
||||
};
|
||||
}
|
||||
|
||||
const models: string[] = Array.isArray(data?.data)
|
||||
? data.data
|
||||
.map((m: any) => (typeof m?.id === 'string' ? m.id : null))
|
||||
.filter((m: string | null): m is string => m !== null)
|
||||
: [];
|
||||
|
||||
return { ok: true, models };
|
||||
}
|
||||
|
||||
export function resolveLocalConfig(override?: Partial<LocalProviderConfig>): LocalProviderConfig {
|
||||
return {
|
||||
endpoint: override?.endpoint?.trim() || CONFIG.DEFAULT_LOCAL_ENDPOINT,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
import { InputScreen } from './InputScreen';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useAppStore } from '../store/appStore';
|
||||
import React from 'react';
|
||||
import { historyService } from '../lib/history/historyService';
|
||||
import { testLocalConnection } from '../lib/api/local';
|
||||
|
||||
vi.mock('../hooks/useTheme', () => ({
|
||||
useTheme: () => ({
|
||||
|
|
@ -19,6 +20,14 @@ vi.mock('../lib/history/historyService', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock('../lib/api/local', async () => {
|
||||
const actual = await vi.importActual<typeof import('../lib/api/local')>('../lib/api/local');
|
||||
return {
|
||||
...actual,
|
||||
testLocalConnection: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('InputScreen', () => {
|
||||
beforeEach(() => {
|
||||
useAppStore.getState().reset();
|
||||
|
|
@ -26,6 +35,10 @@ describe('InputScreen', () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders input screen and allows typing', () => {
|
||||
render(<InputScreen />);
|
||||
|
||||
|
|
@ -138,4 +151,83 @@ describe('InputScreen', () => {
|
|||
expect(useAppStore.getState().currentScreen).toBe('report');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Local provider UI', () => {
|
||||
function openConfigAndSelectLocal() {
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Configure API Keys/i }));
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Local \(Ollama \/ LMStudio\)/i }));
|
||||
});
|
||||
}
|
||||
|
||||
it('hides the local fields until the provider is set to Local', () => {
|
||||
render(<InputScreen />);
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Configure API Keys/i }));
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Local Endpoint')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Local Model Name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the local fields and a Test button once Local is selected', () => {
|
||||
render(<InputScreen />);
|
||||
openConfigAndSelectLocal();
|
||||
|
||||
expect(screen.getByText('Local Endpoint')).toBeInTheDocument();
|
||||
expect(screen.getByText('Local Model Name')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Test$/ })).toBeInTheDocument();
|
||||
// The hint should make the /v1 path obvious — verify the suggested
|
||||
// endpoint <code> elements for both servers are present.
|
||||
expect(screen.getByText('http://localhost:11434/v1')).toBeInTheDocument();
|
||||
expect(screen.getByText('http://localhost:1234/v1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a success message with model names when the test connection succeeds', async () => {
|
||||
vi.mocked(testLocalConnection).mockResolvedValue({
|
||||
ok: true,
|
||||
models: ['llama3.2', 'qwen2.5-coder:7b'],
|
||||
});
|
||||
|
||||
render(<InputScreen />);
|
||||
openConfigAndSelectLocal();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Test$/ }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/2 models available/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/llama3\.2/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/qwen2\.5-coder:7b/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a CORS-aware error message when the test connection fails', async () => {
|
||||
vi.mocked(testLocalConnection).mockResolvedValue({
|
||||
ok: false,
|
||||
error:
|
||||
'Could not reach the local LLM server at http://127.0.0.1:1234/v1. ' +
|
||||
'In LMStudio, open the "Local Server" tab, click the ⚙ settings icon, and enable "CORS". ' +
|
||||
'(Original error: NetworkError when attempting to fetch resource.)',
|
||||
});
|
||||
|
||||
render(<InputScreen />);
|
||||
openConfigAndSelectLocal();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Test$/ }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Could not reach the local LLM server/)).toBeInTheDocument();
|
||||
});
|
||||
// The error message itself contains "LMStudio" and "CORS".
|
||||
const errorText = screen.getByText(/Could not reach the local LLM server/);
|
||||
expect(errorText.textContent).toMatch(/LMStudio/);
|
||||
expect(errorText.textContent).toMatch(/CORS/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
import { CONFIG } from '../lib/constants';
|
||||
import { historyService } from '../lib/history/historyService';
|
||||
import { formatTimeAgo } from '../lib/utils/formatters';
|
||||
import { testLocalConnection, type LocalConnectionTestResult } from '../lib/api/local';
|
||||
import type { AIProvider } from '../lib/types';
|
||||
|
||||
// Storage keys for localStorage
|
||||
|
|
@ -73,6 +74,12 @@ export function InputScreen() {
|
|||
const [localMaxIssues, setLocalMaxIssues] = useState(maxIssues);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
const [rememberKeys, setRememberKeys] = useState(false);
|
||||
const [connectionTest, setConnectionTest] = useState<
|
||||
| { state: 'idle' }
|
||||
| { state: 'testing' }
|
||||
| { state: 'ok'; models: string[] }
|
||||
| { state: 'error'; message: string }
|
||||
>({ state: 'idle' });
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
|
|
@ -163,6 +170,20 @@ export function InputScreen() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setConnectionTest({ state: 'testing' });
|
||||
const result: LocalConnectionTestResult = await testLocalConnection({
|
||||
endpoint: localEndpointInput,
|
||||
model: localModelInput,
|
||||
apiKey: localApiKeyInput,
|
||||
});
|
||||
if (result.ok) {
|
||||
setConnectionTest({ state: 'ok', models: result.models ?? [] });
|
||||
} else {
|
||||
setConnectionTest({ state: 'error', message: result.error || 'Unknown error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
const validation = validateRepoInput(repoInput);
|
||||
|
|
@ -760,8 +781,7 @@ export function InputScreen() {
|
|||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
Local Endpoint (Ollama: http://localhost:11434/v1 · LMStudio:
|
||||
http://localhost:1234/v1)
|
||||
Local Endpoint
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
|
|
@ -770,11 +790,12 @@ export function InputScreen() {
|
|||
onChange={(e) => {
|
||||
setLocalEndpointInput(e.target.value);
|
||||
setSaveStatus('idle');
|
||||
setConnectionTest({ state: 'idle' });
|
||||
}}
|
||||
placeholder={CONFIG.DEFAULT_LOCAL_ENDPOINT}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 10px 10px 36px',
|
||||
padding: '10px 90px 10px 36px',
|
||||
fontSize: '13px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
|
|
@ -794,7 +815,85 @@ export function InputScreen() {
|
|||
color: 'var(--text-dim)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={connectionTest.state === 'testing'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '6px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
padding: '4px 10px',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
background: 'var(--text)',
|
||||
color: 'var(--bg)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: connectionTest.state === 'testing' ? 'wait' : 'pointer',
|
||||
opacity: connectionTest.state === 'testing' ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{connectionTest.state === 'testing' ? 'Testing…' : 'Test'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-dim)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
Must include the <code>/v1</code> path: Ollama →{' '}
|
||||
<code>http://localhost:11434/v1</code> · LMStudio →{' '}
|
||||
<code>http://localhost:1234/v1</code>
|
||||
</div>
|
||||
{connectionTest.state === 'ok' && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--status-success)',
|
||||
marginTop: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<span>✓</span>
|
||||
<span>
|
||||
Connected.
|
||||
{connectionTest.models.length > 0 ? (
|
||||
<>
|
||||
{' '}
|
||||
{connectionTest.models.length} model
|
||||
{connectionTest.models.length === 1 ? '' : 's'} available
|
||||
{connectionTest.models.length <= 5 && (
|
||||
<>: {connectionTest.models.join(', ')}</>
|
||||
)}
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
' Server reachable.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{connectionTest.state === 'error' && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--status-error)',
|
||||
marginTop: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<span>✗</span>
|
||||
<span>{connectionTest.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue