feat(admin): present OCR-only analysis UI, hide vision surfaces
The analysis admin now reflects the OCR backend instead of the vision LLM. Backend response types are unchanged (vision fields ride along, ignored) so this is fully reversible. - Settings → Analysis: keep only the OCR-relevant knobs (workers, job timeout, max image bytes) plus a read-only "Backend: OCR (ocrs)" line; drop the endpoint/model/API-key, sampling, slicing, and prompt fieldsets. - Analysis tab: reword the lede to OCR text extraction; the page-detail modal drops the NSFW/content-warning, scene, and auto-tag sections and shows only OCR text (the per-line kind label renders only when set, since ocrs leaves it blank). - History table: remove the NSFW-only filter and the per-row NSFW tag, and stop sending the `nsfw` query param. - Metrics panel: remove the by-model table (OCR has a single engine); keep the tiles and trend charts. Add component tests asserting the history table omits the NSFW filter and never tags a row, and the metrics panel renders tiles without the by-model table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,6 @@
|
|||||||
let total = $state(0);
|
let total = $state(0);
|
||||||
let page = $state(1);
|
let page = $state(1);
|
||||||
let statusFilter = $state<'' | 'done' | 'failed'>('');
|
let statusFilter = $state<'' | 'done' | 'failed'>('');
|
||||||
let nsfwOnly = $state(false);
|
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
@@ -34,7 +33,6 @@
|
|||||||
try {
|
try {
|
||||||
const resp = await listAnalysisHistory({
|
const resp = await listAnalysisHistory({
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
nsfw: nsfwOnly,
|
|
||||||
search: search.trim() || undefined,
|
search: search.trim() || undefined,
|
||||||
limit: LIMIT,
|
limit: LIMIT,
|
||||||
offset: (page - 1) * LIMIT
|
offset: (page - 1) * LIMIT
|
||||||
@@ -86,15 +84,6 @@
|
|||||||
<option value="done">done</option>
|
<option value="done">done</option>
|
||||||
<option value="failed">failed</option>
|
<option value="failed">failed</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="nsfw">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={nsfwOnly}
|
|
||||||
onchange={applyFilters}
|
|
||||||
data-testid="analysis-history-nsfw"
|
|
||||||
/>
|
|
||||||
NSFW only
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
class="search"
|
class="search"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -136,7 +125,6 @@
|
|||||||
<td><span class="status-pill {r.status}">{r.status}</span></td>
|
<td><span class="status-pill {r.status}">{r.status}</span></td>
|
||||||
<td>
|
<td>
|
||||||
{r.manga_title} · Ch {r.chapter_number} · p{r.page_number}
|
{r.manga_title} · Ch {r.chapter_number} · p{r.page_number}
|
||||||
{#if r.is_nsfw}<span class="nsfw-tag">⚠ NSFW</span>{/if}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="muted">{r.model ?? '—'}</td>
|
<td class="muted">{r.model ?? '—'}</td>
|
||||||
<td class="num">{fmtDuration(r.duration_ms)}</td>
|
<td class="num">{fmtDuration(r.duration_ms)}</td>
|
||||||
@@ -169,13 +157,6 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
}
|
}
|
||||||
.nsfw {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
@@ -218,11 +199,6 @@
|
|||||||
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.nsfw-tag {
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
color: #b85e1a;
|
|
||||||
margin-left: var(--space-1);
|
|
||||||
}
|
|
||||||
.error {
|
.error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, waitFor } from '@testing-library/svelte';
|
||||||
|
|
||||||
|
// Mock the admin API so the component's onMount load() is deterministic.
|
||||||
|
const listAnalysisHistory = vi.fn();
|
||||||
|
vi.mock('$lib/api/admin', () => ({
|
||||||
|
listAnalysisHistory: (...args: unknown[]) => listAnalysisHistory(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AnalysisHistoryTable from './AnalysisHistoryTable.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
listAnalysisHistory.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function row(over: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
page_id: 'p1',
|
||||||
|
page_number: 1,
|
||||||
|
chapter_id: 'c1',
|
||||||
|
chapter_number: 5,
|
||||||
|
manga_id: 'm1',
|
||||||
|
manga_title: 'Berserk',
|
||||||
|
status: 'done',
|
||||||
|
is_nsfw: true, // even when the backend reports nsfw, the OCR UI must not surface it
|
||||||
|
model: 'ocrs',
|
||||||
|
error: null,
|
||||||
|
analyzed_at: '2026-01-02T00:00:00Z',
|
||||||
|
duration_ms: 1234,
|
||||||
|
...over
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOnce(rows: ReturnType<typeof row>[]) {
|
||||||
|
listAnalysisHistory.mockResolvedValue({
|
||||||
|
items: rows,
|
||||||
|
page: { total: rows.length }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AnalysisHistoryTable (OCR)', () => {
|
||||||
|
it('does not render the NSFW-only filter', async () => {
|
||||||
|
resolveOnce([row()]);
|
||||||
|
render(AnalysisHistoryTable, { props: { onOpenDetail: () => {} } });
|
||||||
|
await waitFor(() => expect(listAnalysisHistory).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByTestId('analysis-history-nsfw')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pass an nsfw filter to the history query', async () => {
|
||||||
|
resolveOnce([row()]);
|
||||||
|
render(AnalysisHistoryTable, { props: { onOpenDetail: () => {} } });
|
||||||
|
await waitFor(() => expect(listAnalysisHistory).toHaveBeenCalled());
|
||||||
|
const arg = listAnalysisHistory.mock.calls[0][0] as Record<string, unknown>;
|
||||||
|
expect('nsfw' in arg).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never shows an NSFW tag on a row, even if the row is flagged', async () => {
|
||||||
|
resolveOnce([row({ is_nsfw: true })]);
|
||||||
|
render(AnalysisHistoryTable, { props: { onOpenDetail: () => {} } });
|
||||||
|
await waitFor(() => expect(screen.getByText(/Berserk/)).toBeTruthy());
|
||||||
|
expect(screen.queryByText(/NSFW/i)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -105,30 +105,6 @@
|
|||||||
<span class="value">{metrics.failed}</span>
|
<span class="value">{metrics.failed}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>By model</h2>
|
|
||||||
{#if metrics.by_model.length === 0}
|
|
||||||
<p class="muted">No completed analyses in this window.</p>
|
|
||||||
{:else}
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Model</th>
|
|
||||||
<th class="num">Avg</th>
|
|
||||||
<th class="num">N</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each metrics.by_model as m (m.model)}
|
|
||||||
<tr>
|
|
||||||
<td>{m.model ?? '(unknown)'}</td>
|
|
||||||
<td class="num">{fmtDuration(m.avg_ms)}</td>
|
|
||||||
<td class="num">{m.n}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -186,29 +162,6 @@
|
|||||||
font-weight: var(--weight-semibold);
|
font-weight: var(--weight-semibold);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
h2 {
|
|
||||||
margin: 0 0 var(--space-2);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
max-width: 32rem;
|
|
||||||
}
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: var(--space-2);
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
}
|
|
||||||
.num {
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, waitFor } from '@testing-library/svelte';
|
||||||
|
|
||||||
|
const getAnalysisMetrics = vi.fn();
|
||||||
|
const getAnalysisMetricsSeries = vi.fn();
|
||||||
|
vi.mock('$lib/api/admin', () => ({
|
||||||
|
getAnalysisMetrics: (...a: unknown[]) => getAnalysisMetrics(...a),
|
||||||
|
getAnalysisMetricsSeries: (...a: unknown[]) => getAnalysisMetricsSeries(...a)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AnalysisMetricsPanel from './AnalysisMetricsPanel.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
getAnalysisMetrics.mockReset();
|
||||||
|
getAnalysisMetricsSeries.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AnalysisMetricsPanel (OCR)', () => {
|
||||||
|
it('renders the aggregate tiles but not the by-model table', async () => {
|
||||||
|
getAnalysisMetrics.mockResolvedValue({
|
||||||
|
n: 10,
|
||||||
|
ok: 9,
|
||||||
|
failed: 1,
|
||||||
|
avg_ms: 1200,
|
||||||
|
// Even when the API returns a by-model breakdown, OCR has a single
|
||||||
|
// engine so the panel must not surface the table.
|
||||||
|
by_model: [{ model: 'ocrs', avg_ms: 1200, n: 10 }]
|
||||||
|
});
|
||||||
|
getAnalysisMetricsSeries.mockResolvedValue({ buckets: [] });
|
||||||
|
|
||||||
|
render(AnalysisMetricsPanel);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('analysis-metrics-n').textContent).toContain('10')
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/By model/i)).toBeNull();
|
||||||
|
expect(screen.queryByRole('table')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -533,9 +533,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="lede">
|
<p class="lede">
|
||||||
Coverage of the AI analysis worker (OCR, auto-tags, scene description,
|
Coverage of the OCR text-extraction worker. Browse which pages have had
|
||||||
NSFW moderation). Browse what's analyzed, queue more, and inspect any
|
their text extracted, queue more, and inspect any page's OCR result.
|
||||||
page's result. Updates stream live as pages are queued and processed.
|
Updates stream live as pages are queued and processed.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="viewtabs">
|
<div class="viewtabs">
|
||||||
@@ -818,37 +818,12 @@
|
|||||||
<p class="error">{detail.error ?? 'Analysis failed.'}</p>
|
<p class="error">{detail.error ?? 'Analysis failed.'}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if detail.is_nsfw || detail.content_warnings.length > 0}
|
|
||||||
<div class="warnings" data-testid="admin-analysis-detail-warnings">
|
|
||||||
<span class="cw-label">⚠ NSFW</span>
|
|
||||||
{#each detail.content_warnings as w (w)}
|
|
||||||
<span class="cw-chip">{w}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if detail.scene_description}
|
|
||||||
<section class="block">
|
|
||||||
<h3>Scene</h3>
|
|
||||||
<p>{detail.scene_description}</p>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if detail.tags.length > 0}
|
|
||||||
<section class="block">
|
|
||||||
<h3>Tags</h3>
|
|
||||||
<div class="tag-row">
|
|
||||||
{#each detail.tags as t (t)}<span class="tag">{t}</span>{/each}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if detail.ocr.length > 0}
|
{#if detail.ocr.length > 0}
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h3>OCR text</h3>
|
<h3>OCR text</h3>
|
||||||
<ul class="ocr">
|
<ul class="ocr">
|
||||||
{#each detail.ocr as line, i (i)}
|
{#each detail.ocr as line, i (i)}
|
||||||
<li><span class="ocr-kind">{line.kind}</span> {line.text}</li>
|
<li>{#if line.kind}<span class="ocr-kind">{line.kind}</span> {/if}{line.text}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
@@ -1186,28 +1161,6 @@
|
|||||||
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.warnings {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: color-mix(in srgb, #d4762a 12%, transparent);
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
.cw-label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #b85e1a;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
}
|
|
||||||
.cw-chip {
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
padding: 1px var(--space-2);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: color-mix(in srgb, #d4762a 22%, transparent);
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
.block {
|
.block {
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
@@ -1218,17 +1171,6 @@
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
margin: 0 0 var(--space-1);
|
margin: 0 0 var(--space-1);
|
||||||
}
|
}
|
||||||
.tag-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
.tag {
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
padding: 1px var(--space-2);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: var(--surface-elevated);
|
|
||||||
}
|
|
||||||
.ocr {
|
.ocr {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
updateAnalysisSettings,
|
updateAnalysisSettings,
|
||||||
type CrawlerSettings,
|
type CrawlerSettings,
|
||||||
type CrawlerEnvOnly,
|
type CrawlerEnvOnly,
|
||||||
type AnalysisSettings,
|
type AnalysisSettings
|
||||||
type PromptDefaults
|
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
|
|
||||||
type Tab = 'crawler' | 'analysis';
|
type Tab = 'crawler' | 'analysis';
|
||||||
@@ -21,10 +20,11 @@
|
|||||||
let crawlerEnv = $state<CrawlerEnvOnly | null>(null);
|
let crawlerEnv = $state<CrawlerEnvOnly | null>(null);
|
||||||
let allowlistText = $state(''); // textarea mirror of download_allowlist
|
let allowlistText = $state(''); // textarea mirror of download_allowlist
|
||||||
|
|
||||||
// Analysis state
|
// Analysis state. The full settings object is loaded and saved as-is;
|
||||||
|
// vision-only fields (endpoint, model, prompts, slicing, sampling) ride
|
||||||
|
// along unchanged while the OCR backend is active — only the OCR-relevant
|
||||||
|
// knobs are surfaced below.
|
||||||
let analysis = $state<AnalysisSettings | null>(null);
|
let analysis = $state<AnalysisSettings | null>(null);
|
||||||
let promptDefaults = $state<PromptDefaults | null>(null);
|
|
||||||
let apiKeyConfigured = $state(false);
|
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let loadError = $state<string | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
@@ -46,8 +46,6 @@
|
|||||||
crawlerEnv = c.env_only;
|
crawlerEnv = c.env_only;
|
||||||
allowlistText = c.editable.download_allowlist.join('\n');
|
allowlistText = c.editable.download_allowlist.join('\n');
|
||||||
analysis = a.editable;
|
analysis = a.editable;
|
||||||
promptDefaults = a.prompt_defaults;
|
|
||||||
apiKeyConfigured = a.env_only.api_key_configured;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError = e instanceof Error ? e.message : 'Failed to load settings.';
|
loadError = e instanceof Error ? e.message : 'Failed to load settings.';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,8 +94,6 @@
|
|||||||
} else if (tab === 'analysis' && analysis) {
|
} else if (tab === 'analysis' && analysis) {
|
||||||
const r = await updateAnalysisSettings(analysis);
|
const r = await updateAnalysisSettings(analysis);
|
||||||
analysis = r.editable;
|
analysis = r.editable;
|
||||||
promptDefaults = r.prompt_defaults;
|
|
||||||
apiKeyConfigured = r.env_only.api_key_configured;
|
|
||||||
}
|
}
|
||||||
success = 'Saved and applied. The subsystem was restarted with the new settings.';
|
success = 'Saved and applied. The subsystem was restarted with the new settings.';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -113,9 +109,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPrompt(which: 'system_prompt' | 'ocr_prompt' | 'grounding_prompt') {
|
|
||||||
if (analysis) analysis[which] = null;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
@@ -322,145 +315,29 @@
|
|||||||
{:else if tab === 'analysis' && analysis}
|
{:else if tab === 'analysis' && analysis}
|
||||||
<div class="form">
|
<div class="form">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Endpoint & model</legend>
|
<legend>Engine</legend>
|
||||||
<label class="field">
|
|
||||||
<span>Vision endpoint URL</span>
|
|
||||||
<input type="text" bind:value={analysis.endpoint} />
|
|
||||||
{#if fieldErrors.endpoint}<small class="fe">{fieldErrors.endpoint}</small>{/if}
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Model</span>
|
|
||||||
<input type="text" bind:value={analysis.model} />
|
|
||||||
{#if fieldErrors.model}<small class="fe">{fieldErrors.model}</small>{/if}
|
|
||||||
</label>
|
|
||||||
<p class="env-note muted">
|
<p class="env-note muted">
|
||||||
API key: {apiKeyConfigured
|
Backend: OCR (ocrs) — in-process text extraction. Selected via
|
||||||
? 'configured via environment'
|
environment; vision is temporarily disabled.
|
||||||
: 'not set'} (managed via environment).
|
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Workers & timeouts</legend>
|
<legend>Worker</legend>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Workers</span>
|
<span>Workers</span>
|
||||||
<input type="number" min="1" bind:value={analysis.workers} />
|
<input type="number" min="1" bind:value={analysis.workers} />
|
||||||
{#if fieldErrors.workers}<small class="fe">{fieldErrors.workers}</small>{/if}
|
{#if fieldErrors.workers}<small class="fe">{fieldErrors.workers}</small>{/if}
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
|
||||||
<span>Request timeout (seconds)</span>
|
|
||||||
<input type="number" min="1" bind:value={analysis.request_timeout_secs} />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Job timeout (seconds)</span>
|
<span>Job timeout (seconds)</span>
|
||||||
<input type="number" min="1" bind:value={analysis.job_timeout_secs} />
|
<input type="number" min="1" bind:value={analysis.job_timeout_secs} />
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend>Sampling & output</legend>
|
|
||||||
<label class="field">
|
|
||||||
<span>Temperature</span>
|
|
||||||
<input type="number" min="0" step="0.05" bind:value={analysis.temperature} />
|
|
||||||
{#if fieldErrors.temperature}<small class="fe"
|
|
||||||
>{fieldErrors.temperature}</small
|
|
||||||
>{/if}
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Frequency penalty</span>
|
|
||||||
<input type="number" step="0.05" bind:value={analysis.frequency_penalty} />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Max output tokens</span>
|
|
||||||
<input type="number" min="1" bind:value={analysis.max_tokens} />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Response format</span>
|
|
||||||
<select bind:value={analysis.response_format}>
|
|
||||||
<option value="json_schema">json_schema</option>
|
|
||||||
<option value="json_object">json_object</option>
|
|
||||||
<option value="none">none</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend>Image slicing</legend>
|
|
||||||
<label class="field">
|
|
||||||
<span>Max pixels per slice</span>
|
|
||||||
<input type="number" min="1" bind:value={analysis.max_pixels} />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Min slice height (px)</span>
|
|
||||||
<input type="number" min="1" bind:value={analysis.min_slice_height} />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Slice overlap (0–0.9)</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="0.9"
|
|
||||||
step="0.01"
|
|
||||||
bind:value={analysis.slice_overlap}
|
|
||||||
/>
|
|
||||||
{#if fieldErrors.slice_overlap}<small class="fe"
|
|
||||||
>{fieldErrors.slice_overlap}</small
|
|
||||||
>{/if}
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Tall aspect threshold (≥1.0)</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="0.1"
|
|
||||||
bind:value={analysis.tall_aspect_threshold}
|
|
||||||
/>
|
|
||||||
{#if fieldErrors.tall_aspect_threshold}<small class="fe"
|
|
||||||
>{fieldErrors.tall_aspect_threshold}</small
|
|
||||||
>{/if}
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Max slices</span>
|
|
||||||
<input type="number" min="1" bind:value={analysis.max_slices} />
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Max image bytes</span>
|
<span>Max image bytes</span>
|
||||||
<input type="number" min="0" bind:value={analysis.max_image_bytes} />
|
<input type="number" min="0" bind:value={analysis.max_image_bytes} />
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend>Prompts</legend>
|
|
||||||
{#each [['system_prompt', 'System prompt'], ['ocr_prompt', 'OCR prompt (slices)'], ['grounding_prompt', 'Grounding prompt']] as [key, label] (key)}
|
|
||||||
{@const k = key as 'system_prompt' | 'ocr_prompt' | 'grounding_prompt'}
|
|
||||||
<div class="prompt">
|
|
||||||
<div class="prompt-head">
|
|
||||||
<span>{label}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="link"
|
|
||||||
disabled={analysis[k] == null}
|
|
||||||
onclick={() => resetPrompt(k)}>Reset to default</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
rows="6"
|
|
||||||
class="mono"
|
|
||||||
value={analysis[k] ?? ''}
|
|
||||||
placeholder={promptDefaults?.[k] ?? ''}
|
|
||||||
oninput={(e) => {
|
|
||||||
const v = (e.currentTarget as HTMLTextAreaElement).value;
|
|
||||||
if (analysis) analysis[k] = v.trim() === '' ? null : v;
|
|
||||||
}}
|
|
||||||
></textarea>
|
|
||||||
<small class="muted">
|
|
||||||
{analysis[k] == null
|
|
||||||
? 'Using compiled default (shown as placeholder).'
|
|
||||||
: `${analysis[k]?.length ?? 0} chars (override).`}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -564,7 +441,6 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.field input,
|
.field input,
|
||||||
.field select,
|
|
||||||
.field textarea {
|
.field textarea {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -584,39 +460,6 @@
|
|||||||
color: var(--danger, #dc2626);
|
color: var(--danger, #dc2626);
|
||||||
font-size: var(--font-xs);
|
font-size: var(--font-xs);
|
||||||
}
|
}
|
||||||
.mono {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: var(--space-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.prompt {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
.prompt-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
}
|
|
||||||
button.link {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--primary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--font-xs);
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
button.link:disabled {
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.env dl {
|
.env dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: max-content 1fr;
|
grid-template-columns: max-content 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user