TRUEREF-0023 rewrite indexing pipeline - parallel reads - serialized writes
This commit is contained in:
@@ -38,6 +38,9 @@
|
||||
<a href={resolveRoute('/search')} class="text-sm text-gray-600 hover:text-gray-900">
|
||||
Search
|
||||
</a>
|
||||
<a href={resolveRoute('/admin/jobs')} class="text-sm text-gray-600 hover:text-gray-900">
|
||||
Admin
|
||||
</a>
|
||||
<a href={resolveRoute('/settings')} class="text-sm text-gray-600 hover:text-gray-900">
|
||||
Settings
|
||||
</a>
|
||||
|
||||
@@ -95,7 +95,10 @@
|
||||
}
|
||||
|
||||
function filtersDirty(): boolean {
|
||||
return repositoryInput.trim() !== appliedRepositoryFilter || !sameStatuses(selectedStatuses, appliedStatuses);
|
||||
return (
|
||||
repositoryInput.trim() !== appliedRepositoryFilter ||
|
||||
!sameStatuses(selectedStatuses, appliedStatuses)
|
||||
);
|
||||
}
|
||||
|
||||
function isSpecificRepositoryId(repositoryId: string): boolean {
|
||||
@@ -107,7 +110,8 @@
|
||||
const repositoryFilter = appliedRepositoryFilter;
|
||||
const repositoryMatches = isSpecificRepositoryId(repositoryFilter)
|
||||
? job.repositoryId === repositoryFilter
|
||||
: job.repositoryId === repositoryFilter || job.repositoryId.startsWith(`${repositoryFilter}/`);
|
||||
: job.repositoryId === repositoryFilter ||
|
||||
job.repositoryId.startsWith(`${repositoryFilter}/`);
|
||||
|
||||
if (!repositoryMatches) {
|
||||
return false;
|
||||
@@ -199,8 +203,8 @@
|
||||
selectedStatuses = selectedStatuses.includes(status)
|
||||
? selectedStatuses.filter((candidate) => candidate !== status)
|
||||
: [...selectedStatuses, status].sort(
|
||||
(left, right) => filterStatuses.indexOf(left) - filterStatuses.indexOf(right)
|
||||
);
|
||||
(left, right) => filterStatuses.indexOf(left) - filterStatuses.indexOf(right)
|
||||
);
|
||||
}
|
||||
|
||||
function applyFilters(event?: SubmitEvent) {
|
||||
@@ -316,7 +320,10 @@
|
||||
|
||||
<WorkerStatusPanel />
|
||||
|
||||
<form class="mb-6 rounded-lg border border-gray-200 bg-white p-4 shadow-sm" onsubmit={applyFilters}>
|
||||
<form
|
||||
class="mb-6 rounded-lg border border-gray-200 bg-white p-4 shadow-sm"
|
||||
onsubmit={applyFilters}
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div class="flex-1">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700" for="repository-filter">
|
||||
@@ -327,10 +334,11 @@
|
||||
type="text"
|
||||
bind:value={repositoryInput}
|
||||
placeholder="/owner or /owner/repo"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
Use an owner prefix like <code>/facebook</code> or a full repository ID like <code>/facebook/react</code>.
|
||||
Use an owner prefix like <code>/facebook</code> or a full repository ID like
|
||||
<code>/facebook/react</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -341,7 +349,9 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleStatusFilter(status)}
|
||||
class="rounded-full border px-3 py-1 text-xs font-semibold uppercase transition {selectedStatuses.includes(status)
|
||||
class="rounded-full border px-3 py-1 text-xs font-semibold uppercase transition {selectedStatuses.includes(
|
||||
status
|
||||
)
|
||||
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-300 text-gray-600 hover:border-gray-400 hover:text-gray-900'}"
|
||||
>
|
||||
@@ -370,7 +380,9 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mb-4 flex flex-col gap-2 text-sm text-gray-600 md:flex-row md:items-center md:justify-between">
|
||||
<div
|
||||
class="mb-4 flex flex-col gap-2 text-sm text-gray-600 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<p>
|
||||
Showing <span class="font-semibold text-gray-900">{jobs.length}</span> of
|
||||
<span class="font-semibold text-gray-900">{total}</span> jobs
|
||||
@@ -444,103 +456,105 @@
|
||||
<JobSkeleton rows={6} />
|
||||
{:else}
|
||||
{#each jobs as job (job.id)}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-gray-900">
|
||||
{job.repositoryId}
|
||||
{#if job.versionId}
|
||||
<span class="ml-1 text-xs text-gray-500">@{job.versionId}</span>
|
||||
{/if}
|
||||
<div class="mt-1 text-xs text-gray-400">{job.id}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||
<JobStatusBadge status={job.status} spinning={job.status === 'running'} />
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{getStageLabel(job.stage)}</span>
|
||||
{#if job.stageDetail}
|
||||
<span class="text-xs text-gray-400">{job.stageDetail}</span>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-gray-900">
|
||||
{job.repositoryId}
|
||||
{#if job.versionId}
|
||||
<span class="ml-1 text-xs text-gray-500">@{job.versionId}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||
<div class="space-y-2">
|
||||
<div class="mt-1 text-xs text-gray-400">{job.id}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||
<JobStatusBadge status={job.status} spinning={job.status === 'running'} />
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-12 text-right text-xs font-semibold text-gray-600">{job.progress}%</span>
|
||||
<div class="h-2 w-32 rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-2 rounded-full bg-blue-600 transition-all"
|
||||
style="width: {job.progress}%"
|
||||
></div>
|
||||
</div>
|
||||
<span>{getStageLabel(job.stage)}</span>
|
||||
{#if job.stageDetail}
|
||||
<span class="text-xs text-gray-400">{job.stageDetail}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if job.totalFiles > 0}
|
||||
<div class="text-xs text-gray-400">
|
||||
{job.processedFiles}/{job.totalFiles} files processed
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-12 text-right text-xs font-semibold text-gray-600"
|
||||
>{job.progress}%</span
|
||||
>
|
||||
<div class="h-2 w-32 rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-2 rounded-full bg-blue-600 transition-all"
|
||||
style="width: {job.progress}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||
{formatDate(job.createdAt)}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
||||
<div class="flex justify-end gap-2">
|
||||
{#if pendingCancelJobId === job.id}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => void runJobAction(job, 'cancel')}
|
||||
disabled={isRowBusy(job.id)}
|
||||
class="rounded bg-red-600 px-3 py-1 text-xs font-semibold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{rowActions[job.id] === 'cancel' ? 'Cancelling...' : 'Confirm cancel'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => requestCancel(job.id)}
|
||||
disabled={isRowBusy(job.id)}
|
||||
class="rounded border border-gray-300 px-3 py-1 text-xs font-semibold text-gray-700 hover:border-gray-400 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Keep job
|
||||
</button>
|
||||
{:else}
|
||||
{#if canPause(job.status)}
|
||||
{#if job.totalFiles > 0}
|
||||
<div class="text-xs text-gray-400">
|
||||
{job.processedFiles}/{job.totalFiles} files processed
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||
{formatDate(job.createdAt)}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
||||
<div class="flex justify-end gap-2">
|
||||
{#if pendingCancelJobId === job.id}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => void runJobAction(job, 'pause')}
|
||||
onclick={() => void runJobAction(job, 'cancel')}
|
||||
disabled={isRowBusy(job.id)}
|
||||
class="rounded bg-yellow-600 px-3 py-1 text-xs font-semibold text-white hover:bg-yellow-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="rounded bg-red-600 px-3 py-1 text-xs font-semibold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{rowActions[job.id] === 'pause' ? 'Pausing...' : 'Pause'}
|
||||
{rowActions[job.id] === 'cancel' ? 'Cancelling...' : 'Confirm cancel'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if canResume(job.status)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => void runJobAction(job, 'resume')}
|
||||
disabled={isRowBusy(job.id)}
|
||||
class="rounded bg-green-600 px-3 py-1 text-xs font-semibold text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{rowActions[job.id] === 'resume' ? 'Resuming...' : 'Resume'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if canCancel(job.status)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => requestCancel(job.id)}
|
||||
disabled={isRowBusy(job.id)}
|
||||
class="rounded bg-red-600 px-3 py-1 text-xs font-semibold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="rounded border border-gray-300 px-3 py-1 text-xs font-semibold text-gray-700 hover:border-gray-400 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
Keep job
|
||||
</button>
|
||||
{:else}
|
||||
{#if canPause(job.status)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => void runJobAction(job, 'pause')}
|
||||
disabled={isRowBusy(job.id)}
|
||||
class="rounded bg-yellow-600 px-3 py-1 text-xs font-semibold text-white hover:bg-yellow-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{rowActions[job.id] === 'pause' ? 'Pausing...' : 'Pause'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if canResume(job.status)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => void runJobAction(job, 'resume')}
|
||||
disabled={isRowBusy(job.id)}
|
||||
class="rounded bg-green-600 px-3 py-1 text-xs font-semibold text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{rowActions[job.id] === 'resume' ? 'Resuming...' : 'Resume'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if canCancel(job.status)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => requestCancel(job.id)}
|
||||
disabled={isRowBusy(job.id)}
|
||||
class="rounded bg-red-600 px-3 py-1 text-xs font-semibold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/if}
|
||||
{#if !canPause(job.status) && !canResume(job.status) && !canCancel(job.status)}
|
||||
<span class="text-xs text-gray-400">—</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !canPause(job.status) && !canResume(job.status) && !canCancel(job.status)}
|
||||
<span class="text-xs text-gray-400">—</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
@@ -553,4 +567,4 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Toast bind:toasts={toasts} />
|
||||
<Toast bind:toasts />
|
||||
|
||||
@@ -36,9 +36,10 @@ function getServices(db: ReturnType<typeof getClient>) {
|
||||
|
||||
// Load the active embedding profile from the database
|
||||
const profileRow = db
|
||||
.prepare<[], EmbeddingProfileEntityProps>(
|
||||
'SELECT * FROM embedding_profiles WHERE is_default = 1 AND enabled = 1 LIMIT 1'
|
||||
)
|
||||
.prepare<
|
||||
[],
|
||||
EmbeddingProfileEntityProps
|
||||
>('SELECT * FROM embedding_profiles WHERE is_default = 1 AND enabled = 1 LIMIT 1')
|
||||
.get();
|
||||
|
||||
const profile = profileRow
|
||||
@@ -227,10 +228,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
// Fall back to commit hash prefix match (min 7 chars).
|
||||
if (!resolvedVersion && parsed.version.length >= 7) {
|
||||
resolvedVersion = db
|
||||
.prepare<
|
||||
[string, string],
|
||||
RawVersionRow
|
||||
>(
|
||||
.prepare<[string, string], RawVersionRow>(
|
||||
`SELECT id, tag FROM repository_versions
|
||||
WHERE repository_id = ? AND commit_hash LIKE ?`
|
||||
)
|
||||
@@ -261,14 +259,14 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
|
||||
const selectedResults = applyTokenBudget
|
||||
? (() => {
|
||||
const snippets = searchResults.map((r) => r.snippet);
|
||||
const selected = selectSnippetsWithinBudget(snippets, maxTokens);
|
||||
const snippets = searchResults.map((r) => r.snippet);
|
||||
const selected = selectSnippetsWithinBudget(snippets, maxTokens);
|
||||
|
||||
return selected.map((snippet) => {
|
||||
const found = searchResults.find((r) => r.snippet.id === snippet.id)!;
|
||||
return found;
|
||||
});
|
||||
})()
|
||||
return selected.map((snippet) => {
|
||||
const found = searchResults.find((r) => r.snippet.id === snippet.id)!;
|
||||
return found;
|
||||
});
|
||||
})()
|
||||
: searchResults;
|
||||
|
||||
const snippetVersionIds = Array.from(
|
||||
|
||||
@@ -22,17 +22,23 @@ const VALID_JOB_STATUSES: ReadonlySet<IndexingJob['status']> = new Set([
|
||||
'failed'
|
||||
]);
|
||||
|
||||
function parseStatusFilter(searchValue: string | null): IndexingJob['status'] | Array<IndexingJob['status']> | undefined {
|
||||
function parseStatusFilter(
|
||||
searchValue: string | null
|
||||
): IndexingJob['status'] | Array<IndexingJob['status']> | undefined {
|
||||
if (!searchValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const statuses = [...new Set(
|
||||
searchValue
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter((value): value is IndexingJob['status'] => VALID_JOB_STATUSES.has(value as IndexingJob['status']))
|
||||
)];
|
||||
const statuses = [
|
||||
...new Set(
|
||||
searchValue
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter((value): value is IndexingJob['status'] =>
|
||||
VALID_JOB_STATUSES.has(value as IndexingJob['status'])
|
||||
)
|
||||
)
|
||||
];
|
||||
|
||||
if (statuses.length === 0) {
|
||||
return undefined;
|
||||
|
||||
@@ -51,7 +51,9 @@ export const GET: RequestHandler = ({ params, request }) => {
|
||||
if (lastEventId) {
|
||||
const lastEvent = broadcaster.getLastEvent(jobId);
|
||||
if (lastEvent && lastEvent.id >= parseInt(lastEventId, 10)) {
|
||||
controller.enqueue(`id: ${lastEvent.id}\nevent: ${lastEvent.event}\ndata: ${lastEvent.data}\n\n`);
|
||||
controller.enqueue(
|
||||
`id: ${lastEvent.id}\nevent: ${lastEvent.event}\ndata: ${lastEvent.data}\n\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,10 +82,7 @@ export const GET: RequestHandler = ({ params, request }) => {
|
||||
controller.enqueue(value);
|
||||
|
||||
// Check if the incoming event indicates job completion
|
||||
if (
|
||||
value.includes('event: job-done') ||
|
||||
value.includes('event: job-failed')
|
||||
) {
|
||||
if (value.includes('event: job-done') || value.includes('event: job-failed')) {
|
||||
controller.close();
|
||||
break;
|
||||
}
|
||||
@@ -111,7 +110,7 @@ export const GET: RequestHandler = ({ params, request }) => {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export const GET: RequestHandler = ({ url }) => {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
|
||||
@@ -124,9 +124,11 @@ describe('POST /api/v1/libs/:id/index', () => {
|
||||
versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0');
|
||||
versionService.add('/facebook/react', 'v17.0.0', 'React v17.0.0');
|
||||
|
||||
const enqueue = vi.fn().mockImplementation(
|
||||
(repositoryId: string, versionId?: string) => makeEnqueueJob(repositoryId, versionId)
|
||||
);
|
||||
const enqueue = vi
|
||||
.fn()
|
||||
.mockImplementation((repositoryId: string, versionId?: string) =>
|
||||
makeEnqueueJob(repositoryId, versionId)
|
||||
);
|
||||
mockQueue = { enqueue };
|
||||
|
||||
const response = await postIndex({
|
||||
@@ -158,9 +160,11 @@ describe('POST /api/v1/libs/:id/index', () => {
|
||||
repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
|
||||
versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0');
|
||||
|
||||
const enqueue = vi.fn().mockImplementation(
|
||||
(repositoryId: string, versionId?: string) => makeEnqueueJob(repositoryId, versionId)
|
||||
);
|
||||
const enqueue = vi
|
||||
.fn()
|
||||
.mockImplementation((repositoryId: string, versionId?: string) =>
|
||||
makeEnqueueJob(repositoryId, versionId)
|
||||
);
|
||||
mockQueue = { enqueue };
|
||||
|
||||
const response = await postIndex({
|
||||
|
||||
@@ -49,7 +49,10 @@ function createTestDb(): Database.Database {
|
||||
const client = new Database(':memory:');
|
||||
client.pragma('foreign_keys = ON');
|
||||
|
||||
const migrationsFolder = join(import.meta.dirname, '../../../../../../../lib/server/db/migrations');
|
||||
const migrationsFolder = join(
|
||||
import.meta.dirname,
|
||||
'../../../../../../../lib/server/db/migrations'
|
||||
);
|
||||
const ftsFile = join(import.meta.dirname, '../../../../../../../lib/server/db/fts.sql');
|
||||
|
||||
const migration0 = readFileSync(join(migrationsFolder, '0000_large_master_chief.sql'), 'utf-8');
|
||||
|
||||
@@ -180,4 +180,4 @@ describe('embedding settings routes', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,9 +18,10 @@ export const GET: RequestHandler = () => {
|
||||
try {
|
||||
const db = getClient();
|
||||
const row = db
|
||||
.prepare<[], { value: string }>(
|
||||
"SELECT value FROM settings WHERE key = 'indexing.concurrency'"
|
||||
)
|
||||
.prepare<
|
||||
[],
|
||||
{ value: string }
|
||||
>("SELECT value FROM settings WHERE key = 'indexing.concurrency'")
|
||||
.get();
|
||||
|
||||
let concurrency = 2;
|
||||
@@ -54,13 +55,13 @@ export const PUT: RequestHandler = async ({ request }) => {
|
||||
|
||||
// Validate and clamp concurrency
|
||||
const maxConcurrency = Math.max(os.cpus().length - 1, 1);
|
||||
const concurrency = Math.max(1, Math.min(parseInt(String(body.concurrency ?? 2), 10), maxConcurrency));
|
||||
const concurrency = Math.max(
|
||||
1,
|
||||
Math.min(parseInt(String(body.concurrency ?? 2), 10), maxConcurrency)
|
||||
);
|
||||
|
||||
if (isNaN(concurrency)) {
|
||||
return json(
|
||||
{ error: 'Concurrency must be a valid integer' },
|
||||
{ status: 400 }
|
||||
);
|
||||
return json({ error: 'Concurrency must be a valid integer' }, { status: 400 });
|
||||
}
|
||||
|
||||
const db = getClient();
|
||||
|
||||
@@ -18,7 +18,8 @@ import type { ProgressBroadcaster as BroadcasterType } from '$lib/server/pipelin
|
||||
let db: Database.Database;
|
||||
// Closed over by the vi.mock factory below.
|
||||
let mockBroadcaster: BroadcasterType | null = null;
|
||||
let mockPool: { getStatus: () => object; setMaxConcurrency?: (value: number) => void } | null = null;
|
||||
let mockPool: { getStatus: () => object; setMaxConcurrency?: (value: number) => void } | null =
|
||||
null;
|
||||
|
||||
vi.mock('$lib/server/db/client', () => ({
|
||||
getClient: () => db
|
||||
@@ -39,7 +40,8 @@ vi.mock('$lib/server/pipeline/startup.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/pipeline/progress-broadcaster', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('$lib/server/pipeline/progress-broadcaster.js')>();
|
||||
const original =
|
||||
await importOriginal<typeof import('$lib/server/pipeline/progress-broadcaster.js')>();
|
||||
return {
|
||||
...original,
|
||||
getBroadcaster: () => mockBroadcaster
|
||||
@@ -47,7 +49,8 @@ vi.mock('$lib/server/pipeline/progress-broadcaster', async (importOriginal) => {
|
||||
});
|
||||
|
||||
vi.mock('$lib/server/pipeline/progress-broadcaster.js', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('$lib/server/pipeline/progress-broadcaster.js')>();
|
||||
const original =
|
||||
await importOriginal<typeof import('$lib/server/pipeline/progress-broadcaster.js')>();
|
||||
return {
|
||||
...original,
|
||||
getBroadcaster: () => mockBroadcaster
|
||||
@@ -62,7 +65,10 @@ import { ProgressBroadcaster } from '$lib/server/pipeline/progress-broadcaster.j
|
||||
import { GET as getJobsList } from './jobs/+server.js';
|
||||
import { GET as getJobStream } from './jobs/[id]/stream/+server.js';
|
||||
import { GET as getJobsStream } from './jobs/stream/+server.js';
|
||||
import { GET as getIndexingSettings, PUT as putIndexingSettings } from './settings/indexing/+server.js';
|
||||
import {
|
||||
GET as getIndexingSettings,
|
||||
PUT as putIndexingSettings
|
||||
} from './settings/indexing/+server.js';
|
||||
import { GET as getWorkers } from './workers/+server.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -84,7 +90,10 @@ function createTestDb(): Database.Database {
|
||||
'0005_fix_stage_defaults.sql'
|
||||
]) {
|
||||
const sql = readFileSync(join(migrationsFolder, migrationFile), 'utf-8');
|
||||
for (const stmt of sql.split('--> statement-breakpoint').map((s) => s.trim()).filter(Boolean)) {
|
||||
for (const stmt of sql
|
||||
.split('--> statement-breakpoint')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)) {
|
||||
client.exec(stmt);
|
||||
}
|
||||
}
|
||||
@@ -201,9 +210,7 @@ describe('GET /api/v1/jobs/:id/stream', () => {
|
||||
it('returns 404 when the job does not exist', async () => {
|
||||
seedRepo(db);
|
||||
|
||||
const response = await getJobStream(
|
||||
makeEvent({ params: { id: 'non-existent-job-id' } })
|
||||
);
|
||||
const response = await getJobStream(makeEvent({ params: { id: 'non-existent-job-id' } }));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
@@ -363,7 +370,9 @@ describe('GET /api/v1/jobs/stream', () => {
|
||||
const subscribeSpy = vi.spyOn(mockBroadcaster!, 'subscribeRepository');
|
||||
|
||||
await getJobsStream(
|
||||
makeEvent<Parameters<typeof getJobsStream>[0]>({ url: 'http://localhost/api/v1/jobs/stream?repositoryId=/test/repo' })
|
||||
makeEvent<Parameters<typeof getJobsStream>[0]>({
|
||||
url: 'http://localhost/api/v1/jobs/stream?repositoryId=/test/repo'
|
||||
})
|
||||
);
|
||||
|
||||
expect(subscribeSpy).toHaveBeenCalledWith('/test/repo');
|
||||
@@ -383,7 +392,9 @@ describe('GET /api/v1/jobs/stream', () => {
|
||||
seedRepo(db, '/repo/alpha');
|
||||
|
||||
const response = await getJobsStream(
|
||||
makeEvent<Parameters<typeof getJobsStream>[0]>({ url: 'http://localhost/api/v1/jobs/stream?repositoryId=/repo/alpha' })
|
||||
makeEvent<Parameters<typeof getJobsStream>[0]>({
|
||||
url: 'http://localhost/api/v1/jobs/stream?repositoryId=/repo/alpha'
|
||||
})
|
||||
);
|
||||
|
||||
// Broadcast an event for this repository
|
||||
@@ -521,7 +532,9 @@ describe('GET /api/v1/settings/indexing', () => {
|
||||
});
|
||||
|
||||
it('returns { concurrency: 2 } when no setting exists in DB', async () => {
|
||||
const response = await getIndexingSettings(makeEvent<Parameters<typeof getIndexingSettings>[0]>({}));
|
||||
const response = await getIndexingSettings(
|
||||
makeEvent<Parameters<typeof getIndexingSettings>[0]>({})
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -533,7 +546,9 @@ describe('GET /api/v1/settings/indexing', () => {
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES ('indexing.concurrency', ?, ?)"
|
||||
).run(JSON.stringify(4), NOW_S);
|
||||
|
||||
const response = await getIndexingSettings(makeEvent<Parameters<typeof getIndexingSettings>[0]>({}));
|
||||
const response = await getIndexingSettings(
|
||||
makeEvent<Parameters<typeof getIndexingSettings>[0]>({})
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.concurrency).toBe(4);
|
||||
@@ -544,7 +559,9 @@ describe('GET /api/v1/settings/indexing', () => {
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES ('indexing.concurrency', ?, ?)"
|
||||
).run(JSON.stringify({ value: 5 }), NOW_S);
|
||||
|
||||
const response = await getIndexingSettings(makeEvent<Parameters<typeof getIndexingSettings>[0]>({}));
|
||||
const response = await getIndexingSettings(
|
||||
makeEvent<Parameters<typeof getIndexingSettings>[0]>({})
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.concurrency).toBe(5);
|
||||
@@ -600,9 +617,10 @@ describe('PUT /api/v1/settings/indexing', () => {
|
||||
await putIndexingSettings(makePutEvent({ concurrency: 3 }));
|
||||
|
||||
const row = db
|
||||
.prepare<[], { value: string }>(
|
||||
"SELECT value FROM settings WHERE key = 'indexing.concurrency'"
|
||||
)
|
||||
.prepare<
|
||||
[],
|
||||
{ value: string }
|
||||
>("SELECT value FROM settings WHERE key = 'indexing.concurrency'")
|
||||
.get();
|
||||
|
||||
expect(row).toBeDefined();
|
||||
@@ -634,9 +652,7 @@ describe('PUT /api/v1/settings/indexing', () => {
|
||||
// The actual flow: parseInt('abc') => NaN, Math.max(1, Math.min(NaN, max)) => NaN,
|
||||
// then `if (isNaN(concurrency))` returns 400.
|
||||
// We pass the raw string directly.
|
||||
const response = await putIndexingSettings(
|
||||
makePutEvent({ concurrency: 'not-a-number' })
|
||||
);
|
||||
const response = await putIndexingSettings(makePutEvent({ concurrency: 'not-a-number' }));
|
||||
|
||||
// parseInt('not-a-number') = NaN, so the handler should return 400
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
@@ -13,4 +13,4 @@ export const GET: RequestHandler = () => {
|
||||
} catch (error) {
|
||||
return handleServiceError(error);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -39,8 +39,11 @@
|
||||
indexedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
type VersionStateFilter = VersionDto['state'] | 'all';
|
||||
let versions = $state<VersionDto[]>([]);
|
||||
let versionsLoading = $state(false);
|
||||
let activeVersionFilter = $state<VersionStateFilter>('all');
|
||||
let bulkReprocessBusy = $state(false);
|
||||
|
||||
// Add version form
|
||||
let addVersionTag = $state('');
|
||||
@@ -49,7 +52,7 @@
|
||||
// Discover tags state
|
||||
let discoverBusy = $state(false);
|
||||
let discoveredTags = $state<Array<{ tag: string; commitHash: string }>>([]);
|
||||
let selectedDiscoveredTags = new SvelteSet<string>();
|
||||
const selectedDiscoveredTags = new SvelteSet<string>();
|
||||
let showDiscoverPanel = $state(false);
|
||||
let registerBusy = $state(false);
|
||||
|
||||
@@ -76,6 +79,14 @@
|
||||
error: 'Error'
|
||||
};
|
||||
|
||||
const versionFilterOptions: Array<{ value: VersionStateFilter; label: string }> = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'pending', label: stateLabels.pending },
|
||||
{ value: 'indexing', label: stateLabels.indexing },
|
||||
{ value: 'indexed', label: stateLabels.indexed },
|
||||
{ value: 'error', label: stateLabels.error }
|
||||
];
|
||||
|
||||
const stageLabels: Record<string, string> = {
|
||||
queued: 'Queued',
|
||||
differential: 'Diff',
|
||||
@@ -88,6 +99,20 @@
|
||||
failed: 'Failed'
|
||||
};
|
||||
|
||||
const filteredVersions = $derived(
|
||||
activeVersionFilter === 'all'
|
||||
? versions
|
||||
: versions.filter((version) => version.state === activeVersionFilter)
|
||||
);
|
||||
const actionableErroredTags = $derived(
|
||||
versions
|
||||
.filter((version) => version.state === 'error' && !activeVersionJobs[version.tag])
|
||||
.map((version) => version.tag)
|
||||
);
|
||||
const activeVersionFilterLabel = $derived(
|
||||
versionFilterOptions.find((option) => option.value === activeVersionFilter)?.label ?? 'All'
|
||||
);
|
||||
|
||||
async function refreshRepo() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/libs/${encodeURIComponent(repo.id)}`);
|
||||
@@ -123,9 +148,7 @@
|
||||
if (!repo.id) return;
|
||||
|
||||
let stopped = false;
|
||||
const es = new EventSource(
|
||||
`/api/v1/jobs/stream?repositoryId=${encodeURIComponent(repo.id)}`
|
||||
);
|
||||
const es = new EventSource(`/api/v1/jobs/stream?repositoryId=${encodeURIComponent(repo.id)}`);
|
||||
|
||||
es.addEventListener('job-progress', (event) => {
|
||||
if (stopped) return;
|
||||
@@ -277,23 +300,58 @@
|
||||
async function handleIndexVersion(tag: string) {
|
||||
errorMessage = null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/libs/${encodeURIComponent(repo.id)}/versions/${encodeURIComponent(tag)}/index`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const d = await res.json();
|
||||
throw new Error(d.error ?? 'Failed to queue version indexing');
|
||||
}
|
||||
const d = await res.json();
|
||||
if (d.job?.id) {
|
||||
activeVersionJobs = { ...activeVersionJobs, [tag]: d.job.id };
|
||||
const jobId = await queueVersionIndex(tag);
|
||||
if (jobId) {
|
||||
activeVersionJobs = { ...activeVersionJobs, [tag]: jobId };
|
||||
}
|
||||
} catch (e) {
|
||||
errorMessage = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
async function queueVersionIndex(tag: string): Promise<string | null> {
|
||||
const res = await fetch(
|
||||
`/api/v1/libs/${encodeURIComponent(repo.id)}/versions/${encodeURIComponent(tag)}/index`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const d = await res.json();
|
||||
throw new Error(d.error ?? 'Failed to queue version indexing');
|
||||
}
|
||||
const d = await res.json();
|
||||
return d.job?.id ?? null;
|
||||
}
|
||||
|
||||
async function handleBulkReprocessErroredVersions() {
|
||||
if (actionableErroredTags.length === 0) return;
|
||||
bulkReprocessBusy = true;
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
try {
|
||||
const tags = [...actionableErroredTags];
|
||||
const BATCH_SIZE = 5;
|
||||
let next = { ...activeVersionJobs };
|
||||
|
||||
for (let i = 0; i < tags.length; i += BATCH_SIZE) {
|
||||
const batch = tags.slice(i, i + BATCH_SIZE);
|
||||
const jobIds = await Promise.all(batch.map((versionTag) => queueVersionIndex(versionTag)));
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
if (jobIds[j]) {
|
||||
next = { ...next, [batch[j]]: jobIds[j] ?? undefined };
|
||||
}
|
||||
}
|
||||
activeVersionJobs = next;
|
||||
}
|
||||
|
||||
successMessage = `Queued ${tags.length} errored tag${tags.length === 1 ? '' : 's'} for reprocessing.`;
|
||||
await loadVersions();
|
||||
} catch (e) {
|
||||
errorMessage = (e as Error).message;
|
||||
} finally {
|
||||
bulkReprocessBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveVersion() {
|
||||
if (!removeTag) return;
|
||||
const tag = removeTag;
|
||||
@@ -318,10 +376,9 @@
|
||||
discoverBusy = true;
|
||||
errorMessage = null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/libs/${encodeURIComponent(repo.id)}/versions/discover`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
const res = await fetch(`/api/v1/libs/${encodeURIComponent(repo.id)}/versions/discover`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json();
|
||||
throw new Error(d.error ?? 'Failed to discover tags');
|
||||
@@ -331,7 +388,10 @@
|
||||
discoveredTags = (d.tags ?? []).filter(
|
||||
(t: { tag: string; commitHash: string }) => !registeredTags.has(t.tag)
|
||||
);
|
||||
selectedDiscoveredTags = new SvelteSet(discoveredTags.map((t) => t.tag));
|
||||
selectedDiscoveredTags.clear();
|
||||
for (const discoveredTag of discoveredTags) {
|
||||
selectedDiscoveredTags.add(discoveredTag.tag);
|
||||
}
|
||||
showDiscoverPanel = true;
|
||||
} catch (e) {
|
||||
errorMessage = (e as Error).message;
|
||||
@@ -380,7 +440,7 @@
|
||||
activeVersionJobs = next;
|
||||
showDiscoverPanel = false;
|
||||
discoveredTags = [];
|
||||
selectedDiscoveredTags = new SvelteSet();
|
||||
selectedDiscoveredTags.clear();
|
||||
await loadVersions();
|
||||
} catch (e) {
|
||||
errorMessage = (e as Error).message;
|
||||
@@ -498,41 +558,69 @@
|
||||
|
||||
<!-- Versions -->
|
||||
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold text-gray-700">Versions</h2>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Add version inline form -->
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddVersion();
|
||||
}}
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={addVersionTag}
|
||||
placeholder="e.g. v2.0.0"
|
||||
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-400 focus:outline-none"
|
||||
/>
|
||||
<div class="mb-4 flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="text-sm font-semibold text-gray-700">Versions</h2>
|
||||
<div class="flex flex-wrap items-center gap-1 rounded-lg bg-gray-100 p-1">
|
||||
{#each versionFilterOptions as option (option.value)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeVersionFilter = option.value)}
|
||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors {activeVersionFilter ===
|
||||
option.value
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'}"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addVersionBusy || !addVersionTag.trim()}
|
||||
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
onclick={handleBulkReprocessErroredVersions}
|
||||
disabled={bulkReprocessBusy || actionableErroredTags.length === 0}
|
||||
class="rounded-lg border border-red-200 px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
{bulkReprocessBusy
|
||||
? 'Reprocessing...'
|
||||
: `Reprocess errored${actionableErroredTags.length > 0 ? ` (${actionableErroredTags.length})` : ''}`}
|
||||
</button>
|
||||
</form>
|
||||
<!-- Discover tags button — local repos only -->
|
||||
{#if repo.source === 'local'}
|
||||
<button
|
||||
onclick={handleDiscoverTags}
|
||||
disabled={discoverBusy}
|
||||
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
<!-- Add version inline form -->
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddVersion();
|
||||
}}
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
{discoverBusy ? 'Discovering...' : 'Discover tags'}
|
||||
</button>
|
||||
{/if}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={addVersionTag}
|
||||
placeholder="e.g. v2.0.0"
|
||||
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-400 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addVersionBusy || !addVersionTag.trim()}
|
||||
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
<!-- Discover tags button — local repos only -->
|
||||
{#if repo.source === 'local'}
|
||||
<button
|
||||
onclick={handleDiscoverTags}
|
||||
disabled={discoverBusy}
|
||||
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{discoverBusy ? 'Discovering...' : 'Discover tags'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -549,7 +637,7 @@
|
||||
onclick={() => {
|
||||
showDiscoverPanel = false;
|
||||
discoveredTags = [];
|
||||
selectedDiscoveredTags = new SvelteSet();
|
||||
selectedDiscoveredTags.clear();
|
||||
}}
|
||||
class="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
@@ -567,7 +655,9 @@
|
||||
class="rounded border-gray-300"
|
||||
/>
|
||||
<span class="font-mono text-gray-800">{discovered.tag}</span>
|
||||
<span class="font-mono text-xs text-gray-400">{discovered.commitHash.slice(0, 8)}</span>
|
||||
<span class="font-mono text-xs text-gray-400"
|
||||
>{discovered.commitHash.slice(0, 8)}</span
|
||||
>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -576,9 +666,7 @@
|
||||
disabled={registerBusy || selectedDiscoveredTags.size === 0}
|
||||
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{registerBusy
|
||||
? 'Registering...'
|
||||
: `Register ${selectedDiscoveredTags.size} selected`}
|
||||
{registerBusy ? 'Registering...' : `Register ${selectedDiscoveredTags.size} selected`}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -589,9 +677,15 @@
|
||||
<p class="text-sm text-gray-400">Loading versions...</p>
|
||||
{:else if versions.length === 0}
|
||||
<p class="text-sm text-gray-400">No versions registered. Add a tag above to get started.</p>
|
||||
{:else if filteredVersions.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-gray-200 bg-gray-50 px-4 py-5">
|
||||
<p class="text-sm text-gray-500">
|
||||
No versions match the {activeVersionFilterLabel.toLowerCase()} filter.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-gray-100">
|
||||
{#each versions as version (version.id)}
|
||||
{#each filteredVersions as version (version.id)}
|
||||
<div class="py-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -609,7 +703,9 @@
|
||||
disabled={version.state === 'indexing' || !!activeVersionJobs[version.tag]}
|
||||
class="rounded-lg border border-blue-200 px-3 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{version.state === 'indexing' || !!activeVersionJobs[version.tag] ? 'Indexing...' : 'Index'}
|
||||
{version.state === 'indexing' || !!activeVersionJobs[version.tag]
|
||||
? 'Indexing...'
|
||||
: 'Index'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (removeTag = version.tag)}
|
||||
@@ -625,12 +721,8 @@
|
||||
version.totalSnippets > 0
|
||||
? { text: `${version.totalSnippets} snippets`, mono: false }
|
||||
: null,
|
||||
version.commitHash
|
||||
? { text: version.commitHash.slice(0, 8), mono: true }
|
||||
: null,
|
||||
version.indexedAt
|
||||
? { text: formatDate(version.indexedAt), mono: false }
|
||||
: null
|
||||
version.commitHash ? { text: version.commitHash.slice(0, 8), mono: true } : null,
|
||||
version.indexedAt ? { text: formatDate(version.indexedAt), mono: false } : null
|
||||
] as Array<{ text: string; mono: boolean } | null>
|
||||
).filter((p): p is { text: string; mono: boolean } => p !== null)}
|
||||
<div class="mt-1 flex items-center gap-1.5">
|
||||
@@ -638,7 +730,8 @@
|
||||
{#if i > 0}
|
||||
<span class="text-xs text-gray-300">·</span>
|
||||
{/if}
|
||||
<span class="text-xs text-gray-400{part.mono ? ' font-mono' : ''}">{part.text}</span>
|
||||
<span class="text-xs text-gray-400{part.mono ? ' font-mono' : ''}">{part.text}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -646,10 +739,12 @@
|
||||
{@const job = versionJobProgress[activeVersionJobs[version.tag]!]}
|
||||
<div class="mt-2">
|
||||
<div class="flex justify-between text-xs text-gray-500">
|
||||
<span>
|
||||
{#if job?.stageDetail}{job.stageDetail}{:else}{(job?.processedFiles ?? 0).toLocaleString()} / {(job?.totalFiles ?? 0).toLocaleString()} files{/if}
|
||||
{#if job?.stage}{' - ' + (stageLabels[job.stage] ?? job.stage)}{/if}
|
||||
</span>
|
||||
<span>
|
||||
{#if job?.stageDetail}{job.stageDetail}{:else}{(
|
||||
job?.processedFiles ?? 0
|
||||
).toLocaleString()} / {(job?.totalFiles ?? 0).toLocaleString()} files{/if}
|
||||
{#if job?.stage}{' - ' + (stageLabels[job.stage] ?? job.stage)}{/if}
|
||||
</span>
|
||||
<span>{job?.progress ?? 0}%</span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 w-full rounded-full bg-gray-200">
|
||||
|
||||
@@ -39,4 +39,4 @@ describe('/repos/[id] page server load', () => {
|
||||
recentJobs: [{ id: 'job-1', repositoryId: '/facebook/react' }]
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,4 +30,4 @@ describe('SvelteKit route file conventions', () => {
|
||||
|
||||
expect(reservedTestFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EmbeddingSettingsService } from '$lib/server/services/embedding-setting
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const db = getClient();
|
||||
|
||||
|
||||
const service = new EmbeddingSettingsService(db);
|
||||
const settings = EmbeddingSettingsDtoMapper.toDto(service.getSettings());
|
||||
|
||||
@@ -20,11 +20,9 @@ export const load: PageServerLoad = async () => {
|
||||
// Read indexing concurrency setting
|
||||
let indexingConcurrency = 2;
|
||||
const concurrencyRow = db
|
||||
.prepare<[], { value: string }>(
|
||||
"SELECT value FROM settings WHERE key = 'indexing.concurrency'"
|
||||
)
|
||||
.prepare<[], { value: string }>("SELECT value FROM settings WHERE key = 'indexing.concurrency'")
|
||||
.get();
|
||||
|
||||
|
||||
if (concurrencyRow && concurrencyRow.value) {
|
||||
try {
|
||||
const parsed = JSON.parse(concurrencyRow.value);
|
||||
@@ -43,4 +41,4 @@ export const load: PageServerLoad = async () => {
|
||||
localProviderAvailable,
|
||||
indexingConcurrency
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -199,7 +199,9 @@
|
||||
}
|
||||
|
||||
function getOpenAiProfile(settings: EmbeddingSettingsDto): EmbeddingProfileDto | null {
|
||||
return settings.profiles.find((profile) => profile.providerKind === 'openai-compatible') ?? null;
|
||||
return (
|
||||
settings.profiles.find((profile) => profile.providerKind === 'openai-compatible') ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function resolveProvider(profile: EmbeddingProfileDto | null): 'none' | 'openai' | 'local' {
|
||||
@@ -210,27 +212,30 @@
|
||||
}
|
||||
|
||||
function resolveBaseUrl(settings: EmbeddingSettingsDto): string {
|
||||
const profile = settings.activeProfile?.providerKind === 'openai-compatible'
|
||||
? settings.activeProfile
|
||||
: getOpenAiProfile(settings);
|
||||
const profile =
|
||||
settings.activeProfile?.providerKind === 'openai-compatible'
|
||||
? settings.activeProfile
|
||||
: getOpenAiProfile(settings);
|
||||
return typeof profile?.config.baseUrl === 'string'
|
||||
? profile.config.baseUrl
|
||||
: 'https://api.openai.com/v1';
|
||||
}
|
||||
|
||||
function resolveModel(settings: EmbeddingSettingsDto): string {
|
||||
const profile = settings.activeProfile?.providerKind === 'openai-compatible'
|
||||
? settings.activeProfile
|
||||
: getOpenAiProfile(settings);
|
||||
const profile =
|
||||
settings.activeProfile?.providerKind === 'openai-compatible'
|
||||
? settings.activeProfile
|
||||
: getOpenAiProfile(settings);
|
||||
return typeof profile?.config.model === 'string'
|
||||
? profile.config.model
|
||||
: profile?.model ?? 'text-embedding-3-small';
|
||||
: (profile?.model ?? 'text-embedding-3-small');
|
||||
}
|
||||
|
||||
function resolveDimensions(settings: EmbeddingSettingsDto): number | undefined {
|
||||
const profile = settings.activeProfile?.providerKind === 'openai-compatible'
|
||||
? settings.activeProfile
|
||||
: getOpenAiProfile(settings);
|
||||
const profile =
|
||||
settings.activeProfile?.providerKind === 'openai-compatible'
|
||||
? settings.activeProfile
|
||||
: getOpenAiProfile(settings);
|
||||
return profile?.dimensions ?? 1536;
|
||||
}
|
||||
|
||||
@@ -296,34 +301,38 @@
|
||||
<dt class="font-medium text-gray-500">Provider</dt>
|
||||
<dd class="font-semibold text-gray-900">{activeProfile.providerKind}</dd>
|
||||
<dt class="font-medium text-gray-500">Model</dt>
|
||||
<dd class="break-all font-semibold text-gray-900">{activeProfile.model}</dd>
|
||||
<dd class="font-semibold break-all text-gray-900">{activeProfile.model}</dd>
|
||||
<dt class="font-medium text-gray-500">Dimensions</dt>
|
||||
<dd class="font-semibold text-gray-900">{activeProfile.dimensions}</dd>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[110px_1fr] gap-x-4 gap-y-2 pt-3">
|
||||
<dt class="text-gray-500">Enabled</dt>
|
||||
<dd class="font-medium text-gray-800">{activeProfile.enabled ? 'Yes' : 'No'}</dd>
|
||||
<dt class="text-gray-500">Default</dt>
|
||||
<dd class="font-medium text-gray-800">{activeProfile.isDefault ? 'Yes' : 'No'}</dd>
|
||||
<dt class="text-gray-500">Updated</dt>
|
||||
<dd class="font-medium text-gray-800">{formatTimestamp(activeProfile.updatedAt)}</dd>
|
||||
<dt class="text-gray-500">Enabled</dt>
|
||||
<dd class="font-medium text-gray-800">{activeProfile.enabled ? 'Yes' : 'No'}</dd>
|
||||
<dt class="text-gray-500">Default</dt>
|
||||
<dd class="font-medium text-gray-800">{activeProfile.isDefault ? 'Yes' : 'No'}</dd>
|
||||
<dt class="text-gray-500">Updated</dt>
|
||||
<dd class="font-medium text-gray-800">{formatTimestamp(activeProfile.updatedAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<p class="text-sm font-medium text-gray-800">Provider configuration</p>
|
||||
<p class="mb-3 mt-1 text-sm text-gray-500">
|
||||
<p class="mt-1 mb-3 text-sm text-gray-500">
|
||||
These are the provider-specific settings currently saved for the active profile.
|
||||
</p>
|
||||
|
||||
{#if activeConfigEntries.length > 0}
|
||||
<ul class="space-y-2 text-sm">
|
||||
{#each activeConfigEntries as entry (entry.key)}
|
||||
<li class="flex items-start justify-between gap-4 border-b border-gray-200 pb-2 last:border-b-0 last:pb-0">
|
||||
<li
|
||||
class="flex items-start justify-between gap-4 border-b border-gray-200 pb-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<span class="font-medium text-gray-600">{entry.key}</span>
|
||||
<span class={entry.redacted ? 'text-gray-500' : 'text-gray-800'}>{entry.value}</span>
|
||||
<span class={entry.redacted ? 'text-gray-500' : 'text-gray-800'}
|
||||
>{entry.value}</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -332,9 +341,9 @@
|
||||
No provider-specific configuration is stored for this profile.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
For <span class="font-medium text-gray-700">OpenAI-compatible</span> profiles, edit the
|
||||
settings in the <span class="font-medium text-gray-700">Embedding Provider</span> form
|
||||
below. The built-in <span class="font-medium text-gray-700">Local Model</span> profile
|
||||
For <span class="font-medium text-gray-700">OpenAI-compatible</span> profiles, edit
|
||||
the settings in the <span class="font-medium text-gray-700">Embedding Provider</span>
|
||||
form below. The built-in <span class="font-medium text-gray-700">Local Model</span> profile
|
||||
does not currently expose extra configurable fields.
|
||||
</p>
|
||||
{/if}
|
||||
@@ -342,14 +351,17 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
Embeddings are currently disabled. Keyword search remains available, but no embedding profile is active.
|
||||
Embeddings are currently disabled. Keyword search remains available, but no embedding
|
||||
profile is active.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 class="mb-1 text-base font-semibold text-gray-900">Profile Inventory</h2>
|
||||
<p class="mb-4 text-sm text-gray-500">Profiles stored in the database and available for activation.</p>
|
||||
<p class="mb-4 text-sm text-gray-500">
|
||||
Profiles stored in the database and available for activation.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<StatBadge label="Profiles" value={String(currentSettings.profiles.length)} />
|
||||
<StatBadge label="Active" value={activeProfile ? '1' : '0'} />
|
||||
@@ -363,7 +375,9 @@
|
||||
<p class="text-gray-500">{profile.id}</p>
|
||||
</div>
|
||||
{#if profile.id === currentSettings.activeProfileId}
|
||||
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">Active</span>
|
||||
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700"
|
||||
>Active</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -379,238 +393,234 @@
|
||||
</p>
|
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit}>
|
||||
<!-- Provider selector -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
{#each ['none', 'openai', 'local'] as p (p)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
provider = p as 'none' | 'openai' | 'local';
|
||||
testStatus = 'idle';
|
||||
testError = null;
|
||||
}}
|
||||
class={[
|
||||
'rounded-lg px-4 py-2 text-sm',
|
||||
provider === p
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-gray-200 text-gray-700 hover:bg-gray-50'
|
||||
].join(' ')}
|
||||
>
|
||||
{p === 'none'
|
||||
? 'None (FTS5 only)'
|
||||
: p === 'openai'
|
||||
? 'OpenAI-compatible'
|
||||
: 'Local Model'}
|
||||
</button>
|
||||
{/each}
|
||||
<!-- Provider selector -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
{#each ['none', 'openai', 'local'] as p (p)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
provider = p as 'none' | 'openai' | 'local';
|
||||
testStatus = 'idle';
|
||||
testError = null;
|
||||
}}
|
||||
class={[
|
||||
'rounded-lg px-4 py-2 text-sm',
|
||||
provider === p
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-gray-200 text-gray-700 hover:bg-gray-50'
|
||||
].join(' ')}
|
||||
>
|
||||
{p === 'none' ? 'None (FTS5 only)' : p === 'openai' ? 'OpenAI-compatible' : 'Local Model'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- None warning -->
|
||||
{#if provider === 'none'}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
|
||||
Search will use keyword matching only. Results may be less relevant for complex questions.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- None warning -->
|
||||
{#if provider === 'none'}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
|
||||
Search will use keyword matching only. Results may be less relevant for complex questions.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- OpenAI-compatible form -->
|
||||
{#if provider === 'openai'}
|
||||
<div class="space-y-3">
|
||||
<!-- Preset buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each PROVIDER_PRESETS as preset (preset.name)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => applyPreset(preset)}
|
||||
class="rounded border border-gray-200 px-2.5 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="block" for="embedding-base-url">
|
||||
<span class="text-sm font-medium text-gray-700">Base URL</span>
|
||||
<input
|
||||
id="embedding-base-url"
|
||||
name="baseUrl"
|
||||
type="text"
|
||||
autocomplete="url"
|
||||
bind:value={baseUrl}
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block" for="embedding-api-key">
|
||||
<span class="text-sm font-medium text-gray-700">API Key</span>
|
||||
<input
|
||||
id="embedding-api-key"
|
||||
name="apiKey"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
bind:value={apiKey}
|
||||
placeholder="sk-…"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block" for="embedding-model">
|
||||
<span class="text-sm font-medium text-gray-700">Model</span>
|
||||
<input
|
||||
id="embedding-model"
|
||||
name="model"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={model}
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block" for="embedding-dimensions">
|
||||
<span class="text-sm font-medium text-gray-700">Dimensions (optional override)</span>
|
||||
<input
|
||||
id="embedding-dimensions"
|
||||
name="dimensions"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
bind:value={dimensions}
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Test connection row -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- OpenAI-compatible form -->
|
||||
{#if provider === 'openai'}
|
||||
<div class="space-y-3">
|
||||
<!-- Preset buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each PROVIDER_PRESETS as preset (preset.name)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={testConnection}
|
||||
disabled={testStatus === 'testing'}
|
||||
class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50 disabled:opacity-50"
|
||||
onclick={() => applyPreset(preset)}
|
||||
class="rounded border border-gray-200 px-2.5 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{testStatus === 'testing' ? 'Testing…' : 'Test Connection'}
|
||||
{preset.name}
|
||||
</button>
|
||||
|
||||
{#if testStatus === 'ok'}
|
||||
<span class="text-sm text-green-600">
|
||||
Connection successful
|
||||
{#if testDimensions}— {testDimensions} dimensions{/if}
|
||||
</span>
|
||||
{:else if testStatus === 'error'}
|
||||
<span class="text-sm text-red-600">
|
||||
{testError}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Local model section -->
|
||||
{#if provider === 'local'}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm">
|
||||
<p class="font-medium text-gray-800">Local ONNX model via @xenova/transformers</p>
|
||||
<p class="mt-1 text-gray-500">Model: Xenova/all-MiniLM-L6-v2 · 384 dimensions</p>
|
||||
{#if getInitialLocalProviderAvailability()}
|
||||
<p class="mt-2 text-green-600">@xenova/transformers is installed and ready.</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-amber-700">
|
||||
@xenova/transformers is not installed. Run
|
||||
<code class="rounded bg-amber-100 px-1 py-0.5 font-mono text-xs"
|
||||
>npm install @xenova/transformers</code
|
||||
>
|
||||
to enable local embeddings.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Indexing section -->
|
||||
<div class="space-y-3 rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div>
|
||||
<label for="concurrency" class="block text-sm font-medium text-gray-700">
|
||||
Concurrent Workers
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500">
|
||||
Number of parallel indexing workers. Range: 1 to 8.
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="block" for="embedding-base-url">
|
||||
<span class="text-sm font-medium text-gray-700">Base URL</span>
|
||||
<input
|
||||
id="concurrency"
|
||||
type="number"
|
||||
min="1"
|
||||
max="8"
|
||||
inputmode="numeric"
|
||||
bind:value={concurrencyInput}
|
||||
disabled={concurrencySaving}
|
||||
class="w-20 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none disabled:opacity-50"
|
||||
id="embedding-base-url"
|
||||
name="baseUrl"
|
||||
type="text"
|
||||
autocomplete="url"
|
||||
bind:value={baseUrl}
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block" for="embedding-api-key">
|
||||
<span class="text-sm font-medium text-gray-700">API Key</span>
|
||||
<input
|
||||
id="embedding-api-key"
|
||||
name="apiKey"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
bind:value={apiKey}
|
||||
placeholder="sk-…"
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block" for="embedding-model">
|
||||
<span class="text-sm font-medium text-gray-700">Model</span>
|
||||
<input
|
||||
id="embedding-model"
|
||||
name="model"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={model}
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block" for="embedding-dimensions">
|
||||
<span class="text-sm font-medium text-gray-700">Dimensions (optional override)</span>
|
||||
<input
|
||||
id="embedding-dimensions"
|
||||
name="dimensions"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
bind:value={dimensions}
|
||||
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Test connection row -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveConcurrency}
|
||||
disabled={concurrencySaving}
|
||||
class="rounded-lg bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
onclick={testConnection}
|
||||
disabled={testStatus === 'testing'}
|
||||
class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{concurrencySaving ? 'Saving…' : 'Save'}
|
||||
{testStatus === 'testing' ? 'Testing…' : 'Test Connection'}
|
||||
</button>
|
||||
|
||||
{#if concurrencySaveStatus === 'ok'}
|
||||
<span class="text-sm text-green-600">✓ Saved</span>
|
||||
{:else if concurrencySaveStatus === 'error'}
|
||||
<span class="text-sm text-red-600">{concurrencySaveError}</span>
|
||||
{#if testStatus === 'ok'}
|
||||
<span class="text-sm text-green-600">
|
||||
Connection successful
|
||||
{#if testDimensions}— {testDimensions} dimensions{/if}
|
||||
</span>
|
||||
{:else if testStatus === 'error'}
|
||||
<span class="text-sm text-red-600">
|
||||
{testError}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Save feedback banners -->
|
||||
{#if saveStatus === 'ok'}
|
||||
<div
|
||||
class="mt-4 flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Settings saved successfully.
|
||||
</div>
|
||||
{:else if saveStatus === 'error'}
|
||||
<div
|
||||
class="mt-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{saveError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Save row -->
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Settings'}
|
||||
</button>
|
||||
<!-- Local model section -->
|
||||
{#if provider === 'local'}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm">
|
||||
<p class="font-medium text-gray-800">Local ONNX model via @xenova/transformers</p>
|
||||
<p class="mt-1 text-gray-500">Model: Xenova/all-MiniLM-L6-v2 · 384 dimensions</p>
|
||||
{#if getInitialLocalProviderAvailability()}
|
||||
<p class="mt-2 text-green-600">@xenova/transformers is installed and ready.</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-amber-700">
|
||||
@xenova/transformers is not installed. Run
|
||||
<code class="rounded bg-amber-100 px-1 py-0.5 font-mono text-xs"
|
||||
>npm install @xenova/transformers</code
|
||||
>
|
||||
to enable local embeddings.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Indexing section -->
|
||||
<div class="space-y-3 rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div>
|
||||
<label for="concurrency" class="block text-sm font-medium text-gray-700">
|
||||
Concurrent Workers
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500">
|
||||
Number of parallel indexing workers. Range: 1 to 8.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
id="concurrency"
|
||||
type="number"
|
||||
min="1"
|
||||
max="8"
|
||||
inputmode="numeric"
|
||||
bind:value={concurrencyInput}
|
||||
disabled={concurrencySaving}
|
||||
class="w-20 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveConcurrency}
|
||||
disabled={concurrencySaving}
|
||||
class="rounded-lg bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{concurrencySaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
|
||||
{#if concurrencySaveStatus === 'ok'}
|
||||
<span class="text-sm text-green-600">✓ Saved</span>
|
||||
{:else if concurrencySaveStatus === 'error'}
|
||||
<span class="text-sm text-red-600">{concurrencySaveError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save feedback banners -->
|
||||
{#if saveStatus === 'ok'}
|
||||
<div
|
||||
class="mt-4 flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Settings saved successfully.
|
||||
</div>
|
||||
{:else if saveStatus === 'error'}
|
||||
<div
|
||||
class="mt-4 flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{saveError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Save row -->
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -100,4 +100,4 @@ describe('/settings page server load', () => {
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user