Files
rhema-claw/web/claw/app/components/settings/ProvidersPage.vue
T
vhaudiquet 94b82e2636 fix(web): the bugs a chat that never closes for a turn exposes
An investigation pass over the client, driven by steering's new shape —
a composer that is always live makes adjacent latent bugs visible.

The event stream now watches for silence: the claw keeps the
connection alive with a comment every fifteen seconds, and three
missed ones abort the fetch and reconnect, so a half-open TCP after a
laptop sleep no longer reads as 'live' forever. A reconnect clears the
attempted-attachment set with the transcript, or an image whose fetch
failed during the outage never retries against the recovered stream.
Saving a token reloads the identity and accounts the 401 froze: the
banner cleared, but the pages stayed empty until a browser reload.

A failed POST retracts its optimistic sent row (and its pending echo,
so a message the claw did take still renders from the stream's echo);
before, a send into an unreachable claw left a phantom row waiting for
a reply that could never come. Drafts are per-session now — the
composer's text belonged to no session, so a draft typed into one
session sent into whichever was selected at Enter. The providers
login form distinguishes a landed login (the roster carries its id)
from a failed one and no longer closes on an unrelated state refresh.

Two cost fixes that read as jank on long turns: the streamed reply's
markdown re-parsed the whole text per delta (quadratic) and now
re-renders on a short trailing debounce; the attachment-image watcher
walked the whole transcript on every streamed chunk and now scans only
the items it has not seen. The accounts page drops a stray Rust-ism
comment, and the avatar row's meta line admits webp like its accept
attribute always did.
2026-11-08 18:00:56 +01:00

507 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// The Model providers page: the primary backend the claw answers turns
// with, plus the provider roster itself. Pair edits stage into a local
// draft; Save PUTs it (the claw applies the pair on the next turn and
// persists it), Discard reverts to what the claw reports. Logins apply
// immediately: adding an instance constructs its backend claw-side and
// persists its credentials, removing one drops it (env-backed kinds
// re-discover on restart — the source badge says which). The live
// model listing comes from the provider itself; a backend without one
// still switches — its default model applies.
// Presentational: data flows in as props, intent flows up as events.
import type { ModelCard, ProviderCredentials, ProvidersState } from '~/composables/useClaw'
const props = defineProps<{
state: ProvidersState | null
models: ModelCard[]
modelsLoading: boolean
modelsError: string | null
/** The instance whose live listing the Model row shows. */
selectedProvider: string | null
saving: boolean
/** A login or a removal is in flight. */
mutating: boolean
}>()
const emit = defineEmits<{
/** Load (or reload) the selected instance's live listing. */
inspect: [id: string]
save: [provider: string, model?: string]
refresh: []
/** Add a backend instance (or replace an id's credentials). */
add: [id: string, credentials: ProviderCredentials]
/** Remove a backend instance from the claw's roster. */
remove: [id: string]
}>()
// The draft pair. An empty model string means "the provider's default" —
// the PUT omits the model and the claw falls back.
const draftProvider = ref<string | null>(null)
const draftModel = ref('')
const defaultModel = computed(() => {
const id = draftProvider.value
return props.state?.providers.find((p) => p.id === id)?.default_model ?? ''
})
const effectiveModel = computed(() => draftModel.value || defaultModel.value)
const dirty = computed(() => {
const active = props.state?.active
if (!active || draftProvider.value === null) return false
return (
draftProvider.value !== active.provider || effectiveModel.value !== active.model
)
})
/** Re-ground the draft on the claw's state; skipped while dirty. */
function syncDraft(): void {
const active = props.state?.active
if (!active) {
draftProvider.value = null
draftModel.value = ''
return
}
draftProvider.value = active.provider
draftModel.value = active.model === defaultModel.value ? '' : active.model
}
watch(
() => props.state,
(state) => {
// A removal can take the staged instance with it: the draft
// re-grounds even when dirty, because staging a gone provider
// saves nothing.
const stagedGone =
draftProvider.value !== null &&
!state?.providers.some((p) => p.id === draftProvider.value)
if (!dirty.value || stagedGone) syncDraft()
},
{ immediate: true },
)
function pickProvider(id: string): void {
draftProvider.value = id
draftModel.value = ''
emit('inspect', id)
}
function discard(): void {
syncDraft()
}
function save(): void {
if (!dirty.value || draftProvider.value === null) return
emit('save', draftProvider.value, draftModel.value || undefined)
}
// --- the login form ---------------------------------------------------
const KIND_LABELS: Record<string, string> = {
anthropic: 'Anthropic',
copilot: 'GitHub Copilot',
deepseek: 'DeepSeek',
google: 'Google Gemini',
'llama-cpp': 'llama.cpp server',
openai: 'OpenAI',
openrouter: 'OpenRouter',
zai: 'Z.ai (GLM Coding Plan)',
}
function kindLabel(kind: string): string {
return KIND_LABELS[kind] ?? kind
}
/** The key field's voice: copilot logs in with a GitHub token. */
const keyLabel = computed(() =>
addKind.value === 'copilot' ? 'GitHub token' : 'API key',
)
/** A field the daemon does not demand reads as optional, not hidden. */
function label(name: string, base: string): string {
return required(name) ? base : `${base} (optional)`
}
const loginOpen = ref(false)
const addKind = ref<string>('')
const addName = ref('')
const addApiKey = ref('')
const addUrl = ref('')
/** Set when a login is posted; the state swap that answers it closes
* the form (a failure leaves the state, and the form, as they were). */
const pendingAdd = ref<string | null>(null)
const addable = computed(() => props.state?.addable ?? [])
const addFields = computed(() => {
const entry = addable.value.find((entry) => entry.kind === addKind.value)
return entry?.fields ?? []
})
/** Whether the selected kind's form declares this field at all. */
function declared(name: string): boolean {
return addFields.value.some((field) => field.name === name)
}
/** Whether the daemon refuses the login without this field. */
function required(name: string): boolean {
return addFields.value.find((field) => field.name === name)?.required ?? false
}
function fieldValue(name: string): string {
return name === 'url' ? addUrl.value : name === 'api_key' ? addApiKey.value : ''
}
/** The id the login lands under: the name, or the kind unnamed. */
const resultingId = computed(() => addName.value.trim() || addKind.value)
const nameValid = computed(() => {
const name = addName.value.trim()
return name === '' || (name.length <= 64 && !/\s/.test(name) && !name.includes('/'))
})
const addIdPresent = computed(() =>
props.state?.providers.some((p) => p.id === resultingId.value) ?? false,
)
const addReady = computed(() => {
if (!addKind.value || !nameValid.value) return false
return addFields.value
.filter((field) => field.required)
.every((field) => fieldValue(field.name).trim() !== '')
})
function submitAdd(): void {
if (!addReady.value || props.mutating) return
// Only declared fields go out, and only filled ones: the claw treats
// an empty string as the value it was handed, not as "absent".
const credentials: ProviderCredentials = { kind: addKind.value }
if (addName.value.trim() !== '') credentials.name = addName.value.trim()
if (declared('api_key') && addApiKey.value.trim() !== '') {
credentials.api_key = addApiKey.value.trim()
}
if (declared('url') && addUrl.value.trim() !== '') {
credentials.url = addUrl.value.trim()
}
pendingAdd.value = resultingId.value
emit('add', resultingId.value, credentials)
}
function sourceTitle(source: string): string {
switch (source) {
case 'env':
return 'credential from the environment; a removal lasts until restart'
case 'stored':
return 'credential stored claw-side through this surface'
case 'profile':
return 'credential from the stored rhema config'
default:
return 'serves without a credential'
}
}
// A login landed: the claw answered with a new state object whose
// roster carries the new id, so the form forgets what it held and
// closes. A state change without the id means the login failed (the
// banner names the error) or an unrelated edit replaced the state —
// either way the form stays open, no longer waiting on a response that
// already came back.
watch(
() => props.state,
(state, previous) => {
if (pendingAdd.value !== null && state !== previous) {
if (state?.providers.some((p) => p.id === pendingAdd.value)) {
loginOpen.value = false
addName.value = ''
addApiKey.value = ''
addUrl.value = ''
addKind.value = ''
}
pendingAdd.value = null
}
},
)
/** The listing's vision verdict, spelled the way the TUI's picker
* spells it: a mark when the adapter knows the model takes images, a
* bare "no vision" when it knows it does not, nothing when the key is
* absent — unknown is no verdict at all, never a `false`. */
function visionLabel(model: ModelCard): string {
if (model.supports_vision === true) return ' ◉ vision'
if (model.supports_vision === false) return ' no vision'
return ''
}
</script>
<template>
<div class="page">
<PageHeader title="Model providers" subtitle="The backends the claw can answer with. Logins apply immediately; the pair switch persists.">
<template #actions>
<button :disabled="mutating" @click="loginOpen = true">Add provider</button>
<button :disabled="!dirty || saving" @click="discard">Discard</button>
<button class="primary" :disabled="!dirty || saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</template>
</PageHeader>
<div class="scroll">
<AppCallout v-if="dirty" variant="warn" title="1 change not applied">
The staged pair takes effect on the claw's next turn, no restart
involved. Save to apply it, Discard to keep the running backend.
</AppCallout>
<p v-if="!state" class="muted">loading providers…</p>
<template v-else>
<SettingCard title="Primary model" meta="what the agent answers with">
<SettingRow
title="Provider"
description="The backend instance the claw dispatches turns on."
>
<select
:value="draftProvider ?? undefined"
:disabled="saving"
@change="pickProvider(($event.target as HTMLSelectElement).value)"
>
<option v-for="p in state.providers" :key="p.id" :value="p.id">
{{ p.id }}
</option>
</select>
</SettingRow>
<SettingRow
title="Model"
description="Left on the default, the provider's own model answers."
>
<select v-model="draftModel" :disabled="saving || !draftProvider">
<option value="">(provider default)</option>
<option v-for="m in models" :key="m.id" :value="m.id">
{{ m.id }}{{ visionLabel(m) }}
</option>
</select>
</SettingRow>
</SettingCard>
<SettingCard
title="Available backends"
:meta="`${state.providers.length} constructed · env-backed kinds return on restart`"
>
<div
v-for="p in state.providers"
:key="p.id"
class="backend"
:class="{ staged: p.id === draftProvider, active: p.id === state.active.provider }"
role="button"
tabindex="0"
@click="pickProvider(p.id)"
@keydown.enter="pickProvider(p.id)"
>
<span class="kind mono">{{ p.id }}</span>
<AppBadge
v-if="p.kind !== p.id"
variant="dim"
:title="`a ${p.kind} backend`"
>{{ p.kind }}</AppBadge>
<span class="model mono">{{ p.default_model }}</span>
<AppBadge
v-if="p.source"
variant="dim"
:title="sourceTitle(p.source)"
>{{ p.source }}</AppBadge>
<AppBadge v-if="p.id === state.active.provider" variant="accent">active</AppBadge>
<AppBadge v-else-if="p.id === draftProvider && dirty">staged</AppBadge>
<button
class="ghost remove"
:title="`remove ${p.id}`"
:disabled="mutating || state.providers.length <= 1"
@click.stop="emit('remove', p.id)"
>
×
</button>
</div>
</SettingCard>
</template>
</div>
<AppModal :open="loginOpen" title="Add a provider" @close="loginOpen = false">
<div v-if="state" class="add-form">
<div class="add-row">
<label class="add-label" for="add-kind">Kind</label>
<select id="add-kind" v-model="addKind" :disabled="mutating">
<option value="" disabled>choose a provider…</option>
<option v-for="entry in addable" :key="entry.kind" :value="entry.kind">
{{ kindLabel(entry.kind) }}{{ state.providers.some((p) => p.id === entry.kind) ? '; replaces its credentials' : '' }}
</option>
</select>
</div>
<div class="add-row">
<label class="add-label" for="add-name">Name</label>
<input
id="add-name"
v-model="addName"
type="text"
placeholder="name (optional, defaults to the provider kind)"
autocomplete="off"
:disabled="mutating"
class="mono"
/>
</div>
<div v-if="declared('url')" class="add-row">
<label class="add-label" for="add-url">{{ label('url', 'Server URL') }}</label>
<input
id="add-url"
v-model="addUrl"
type="text"
placeholder="http://127.0.0.1:8080"
autocomplete="off"
:disabled="mutating"
class="mono"
/>
</div>
<div v-if="declared('api_key')" class="add-row">
<label class="add-label" for="add-key">{{ label('api_key', keyLabel) }}</label>
<input
id="add-key"
v-model="addApiKey"
type="password"
placeholder="stored claw-side, never shown again"
autocomplete="off"
:disabled="mutating"
/>
</div>
<p v-if="addName.trim() && !nameValid" class="add-note">
a name carries no whitespace or '/' and stays under 64 characters.
</p>
<p v-else-if="addIdPresent" class="add-note">
{{ resultingId }} is already serving. saving replaces its
credentials and restarts nothing; the active pair is untouched.
</p>
<p v-else-if="addKind" class="add-note">
saving adds a new {{ kindLabel(addKind) }} backend{{ addName.trim() ? ` named ${resultingId}` : '' }}.
</p>
</div>
<template #actions>
<button @click="loginOpen = false">Cancel</button>
<button class="primary" :disabled="!addReady || mutating" @click="submitAdd">
{{ mutating ? 'Working…' : addIdPresent ? 'Replace credentials' : 'Add provider' }}
</button>
</template>
</AppModal>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
flex: 1;
}
.scroll {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 860px;
}
.muted {
color: var(--text3);
padding: 8px 2px;
}
.backend {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 16px;
cursor: pointer;
}
.backend:hover {
background: var(--elevated);
}
.backend.staged {
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
}
.backend .kind {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.backend .model {
color: var(--text3);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.backend .remove {
flex: none;
padding: 0 6px;
font-size: 14px;
}
.add-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.add-row {
display: flex;
align-items: center;
gap: 12px;
}
.add-label {
width: 110px;
flex: none;
color: var(--text3);
font-size: 12.5px;
}
.add-row select,
.add-row input {
flex: 1;
min-width: 0;
}
.add-note {
margin: 0;
color: var(--text3);
font-size: 12.5px;
}
.add-actions {
display: flex;
align-items: center;
gap: 12px;
}
.add-actions .callout {
flex: 1;
}
@media (max-width: 760px) {
.add-row {
flex-direction: column;
align-items: stretch;
}
.add-label {
width: auto;
}
}
</style>