feat(rhema-code): add the colors setting with four palettes
The stored `colors` key names a palette — rhema (the default, Jadeite), aquamarine, black-and-white, or dracula — and the Appearance page of /settings cycles it live, like the theme and icon toggles beside it. Each palette carries a dark and a light appearance under the existing `theme` key; dracula's light side is derived, the official palette ships only dark. The appearance decision moves off token comparison: Theme now answers is_dark() from the ground/text luminance, so palettes beyond the original two get the right per-profile fallback tones.
This commit is contained in:
commit
81da26b955
159 files changed
+50628
No files matched your search
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
// The About page: what this client and the daemon behind it are, and
|
||||
// which public routes the client speaks. Static facts — the claw's own
|
||||
// version is not on the wire, so none is claimed.
|
||||
defineProps<{
|
||||
/** null while /healthz has not answered yet. */
|
||||
clawUp: boolean | null
|
||||
}>()
|
||||
|
||||
// The routes this client actually calls; the full surface lives in
|
||||
// crates/rhema-claw/src/http.rs.
|
||||
const routes = [
|
||||
{ method: 'GET', path: '/healthz', note: 'liveness, open without a token' },
|
||||
{ method: 'GET', path: '/v1/sessions', note: 'the dispatch-key inventory' },
|
||||
{ method: 'POST', path: '/v1/sessions', note: 'create an interactive session from a name' },
|
||||
{ method: 'POST', path: '/v1/sessions/{key}/messages', note: 'message ingest, 202 + stream' },
|
||||
{ method: 'GET', path: '/v1/sessions/{key}/events', note: 'the turn-event stream (SSE)' },
|
||||
{ method: 'POST', path: '/v1/consents/{id}', note: 'answer a consent (a secret or a grant)' },
|
||||
{ method: 'DELETE', path: '/v1/consents/{id}', note: 'deny or cancel a consent' },
|
||||
{ method: 'GET', path: '/v1/attachments/{id}', note: "an attachment's bytes" },
|
||||
{ method: 'GET', path: '/v1/providers', note: 'constructible backends + active pair' },
|
||||
{ method: 'PUT', path: '/v1/providers/active', note: 'switch the backend, persisted' },
|
||||
{ method: 'PUT', path: '/v1/providers/{kind}/credentials', note: 'add a backend or replace its login' },
|
||||
{ method: 'DELETE', path: '/v1/providers/{kind}', note: 'drop a backend from the roster' },
|
||||
{ method: 'GET', path: '/v1/providers/{kind}/models', note: "a kind's live model listing" },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader title="About" subtitle="Identity, the engine behind it, and the surface this client speaks" />
|
||||
|
||||
<div class="scroll">
|
||||
<div class="identity">
|
||||
<div class="avatar" aria-hidden="true">R</div>
|
||||
<div>
|
||||
<h2>Rhema</h2>
|
||||
<p>ῥῆμα, "the spoken word": words that do things.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppCallout variant="info" title="This is the claw's web client">
|
||||
A mirror over one running <code>rhema-claw</code> daemon: the
|
||||
conversation lives in the claw's sessions, the model choice in its
|
||||
settings store. A reload starts empty by design.
|
||||
</AppCallout>
|
||||
|
||||
<SettingCard title="Stack" meta="read-only">
|
||||
<SettingRow title="Daemon" description="The resident agent serving this UI.">
|
||||
<span class="mono">rhema-claw</span>
|
||||
</SettingRow>
|
||||
<SettingRow title="Engine" description="The step engine every turn runs through.">
|
||||
<span class="mono">rhema-core</span>
|
||||
</SettingRow>
|
||||
<SettingRow title="Web client" description="This dashboard: Nuxt 4, one static bundle.">
|
||||
<span class="mono">rhema-claw-web 0.1.0</span>
|
||||
</SettingRow>
|
||||
<SettingRow title="Claw status" description="What /healthz last answered.">
|
||||
<AppBadge :variant="clawUp === null ? undefined : clawUp ? 'ok' : 'error'">
|
||||
{{ clawUp === null ? 'checking' : clawUp ? 'up' : 'down' }}
|
||||
</AppBadge>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard title="API surface" meta="the routes this client calls">
|
||||
<div v-for="route in routes" :key="`${route.method} ${route.path}`" class="route">
|
||||
<span class="method mono">{{ route.method }}</span>
|
||||
<span class="path mono">{{ route.path }}</span>
|
||||
<span class="note">{{ route.note }}</span>
|
||||
</div>
|
||||
</SettingCard>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.identity h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.identity p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.route {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 9px 16px;
|
||||
}
|
||||
|
||||
.method {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
color: var(--accent-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.path {
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
// The Agent · Accounts page: logins the agent itself holds on external
|
||||
// services. Presentational like its siblings — data flows in as props,
|
||||
// intent flows up as events; the secret field is write-only by the
|
||||
// wire's shape, and the roster can only ever render what `GET /v1/
|
||||
// accounts` serves, which is the redacted record.
|
||||
import type { ClawAccount, NewAccount } from '~/composables/useClaw'
|
||||
|
||||
const props = defineProps<{
|
||||
accounts: ClawAccount[] | null
|
||||
loading: boolean
|
||||
/** A save or forget is in flight. */
|
||||
mutating: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [account: NewAccount]
|
||||
remove: [account: ClawAccount]
|
||||
}>()
|
||||
|
||||
const adding = ref(false)
|
||||
|
||||
/// The form's own shape: every field a string (empty = omit on the
|
||||
/// wire), so the template never fights `NewAccount`'s optional fields.
|
||||
const draft = ref({
|
||||
label: '',
|
||||
service: '',
|
||||
url: '',
|
||||
username: '',
|
||||
secret: '',
|
||||
secret_kind: 'token' as 'token' | 'password',
|
||||
watch: false,
|
||||
})
|
||||
|
||||
const formValid = computed(
|
||||
() =>
|
||||
draft.value.label.trim() !== '' &&
|
||||
draft.value.service.trim() !== '' &&
|
||||
draft.value.secret.trim() !== '',
|
||||
)
|
||||
|
||||
function submit(): void {
|
||||
if (!formValid.value || props.mutating) return
|
||||
// Empty optionals are omitted, never sent as "" strings.
|
||||
const payload: NewAccount = {
|
||||
label: draft.value.label.trim(),
|
||||
service: draft.value.service.trim().toLowerCase(),
|
||||
secret: draft.value.secret,
|
||||
}
|
||||
if (draft.value.url.trim() !== '') payload.url = draft.value.url.trim()
|
||||
if (draft.value.username.trim() !== '') payload.username = draft.value.username.trim()
|
||||
if (draft.value.secret_kind === 'password') payload.secret_kind = 'password'
|
||||
if (draft.value.watch) payload.watch = true
|
||||
emit('add', payload)
|
||||
draft.value = {
|
||||
label: '',
|
||||
service: '',
|
||||
url: '',
|
||||
username: '',
|
||||
secret: '',
|
||||
secret_kind: 'token',
|
||||
watch: false,
|
||||
}
|
||||
adding.value = false
|
||||
}
|
||||
|
||||
// A forget cascades the account's derived watches, so it asks twice.
|
||||
const confirming = ref<string | null>(null)
|
||||
|
||||
function forget(account: ClawAccount): void {
|
||||
if (props.mutating) return
|
||||
if (confirming.value !== account.id) {
|
||||
confirming.value = account.id
|
||||
return
|
||||
}
|
||||
confirming.value = null
|
||||
emit('remove', account)
|
||||
}
|
||||
|
||||
function meta(account: ClawAccount): string {
|
||||
const parts = [account.service]
|
||||
if (account.username) parts.push(account.username)
|
||||
if (account.url) parts.push(account.url)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader title="Accounts" subtitle="Who the agent is on the outside">
|
||||
<template #actions>
|
||||
<button class="primary" :disabled="mutating" @click="adding = !adding">
|
||||
{{ adding ? 'Close' : 'Add account…' }}
|
||||
</button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<div class="scroll">
|
||||
<SettingCard v-if="adding" title="New account" meta="the secret is stored, never shown again">
|
||||
<SettingRow title="Label" description="A name for the roster, e.g. “acme releases”.">
|
||||
<input
|
||||
v-model="draft.label"
|
||||
type="text"
|
||||
maxlength="128"
|
||||
placeholder="acme releases"
|
||||
autocomplete="off"
|
||||
:disabled="mutating"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Service"
|
||||
description="Lowercase: github, gitlab, or gitea — or any name for a service the agent only stores."
|
||||
>
|
||||
<input
|
||||
v-model="draft.service"
|
||||
type="text"
|
||||
placeholder="github"
|
||||
autocomplete="off"
|
||||
:disabled="mutating"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow title="URL" description="Base URL, for self-hosted instances.">
|
||||
<input
|
||||
v-model="draft.url"
|
||||
type="text"
|
||||
placeholder="https://git.example.net"
|
||||
autocomplete="off"
|
||||
:disabled="mutating"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow title="Username" description="The login name on the service.">
|
||||
<input
|
||||
v-model="draft.username"
|
||||
type="text"
|
||||
placeholder="optional"
|
||||
autocomplete="off"
|
||||
:disabled="mutating"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow title="Secret" description="An API token — or a password, which the agent stores but can never use.">
|
||||
<input
|
||||
v-model="draft.secret"
|
||||
type="password"
|
||||
placeholder="paste the credential"
|
||||
autocomplete="off"
|
||||
:disabled="mutating"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow title="Kind" description="What the secret authenticates as.">
|
||||
<select v-model="draft.secret_kind" :disabled="mutating">
|
||||
<option value="token">token</option>
|
||||
<option value="password">password</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Follow notifications"
|
||||
description="For a forge service: watch everything addressed to this account, triaged into agent work."
|
||||
>
|
||||
<input v-model="draft.watch" type="checkbox" :disabled="mutating" />
|
||||
</SettingRow>
|
||||
<div class="actions">
|
||||
<button :disabled="mutating" @click="adding = false">Cancel</button>
|
||||
<button class="primary" :disabled="!formValid || mutating" @click="submit">
|
||||
{{ mutating ? 'Saving…' : 'Save account' }}
|
||||
</button>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard
|
||||
title="Agent accounts"
|
||||
:meta="accounts?.length ? `${accounts.length}` : 'none yet'"
|
||||
>
|
||||
<p v-if="loading" class="dim">loading…</p>
|
||||
<p v-else-if="!accounts?.length" class="dim">
|
||||
No agent accounts yet. An account is a login the agent holds on
|
||||
an external service — a GitHub identity it answers issues as,
|
||||
an email it sends from — distinct from the model providers
|
||||
(what it thinks with) and the forge watches (what it looks at).
|
||||
For a forge identity, “follow notifications” turns the account
|
||||
into watches automatically: review requests, assignments, and
|
||||
feedback become agent work, mentions surface to you.
|
||||
</p>
|
||||
<SettingRow
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
:title="account.label"
|
||||
:description="meta(account)"
|
||||
>
|
||||
<div class="roster-control">
|
||||
<span class="kind">{{ account.secret_kind }}</span>
|
||||
<button
|
||||
class="ghost"
|
||||
:disabled="mutating"
|
||||
@click="forget(account)"
|
||||
>
|
||||
{{
|
||||
confirming === account.id
|
||||
? 'Forget — also drops its watches?'
|
||||
: 'Forget…'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px 16px 14px;
|
||||
}
|
||||
|
||||
.dim {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.roster-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kind {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
// The Agent · General page: the mock's identity editor. The text
|
||||
// fields save as a whole (empty clears, absent is untouched claw-side);
|
||||
// the images apply the moment a file is chosen — uploading is not the
|
||||
// kind of edit that needs a staging area. Presentational: data flows
|
||||
// in as props, intent flows up as events.
|
||||
import type { AgentIdentity, AgentImageKind, IdentityTexts } from '~/composables/useClaw'
|
||||
|
||||
const props = defineProps<{
|
||||
identity: AgentIdentity | null
|
||||
avatarUrl: string | null
|
||||
logoUrl: string | null
|
||||
/** An image upload or clear is in flight. */
|
||||
mutating: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [texts: IdentityTexts]
|
||||
uploadImage: [kind: AgentImageKind, file: File]
|
||||
clearImage: [kind: AgentImageKind]
|
||||
}>()
|
||||
|
||||
// The draft: '' means "clear this field", matching the wire.
|
||||
const draft = ref<IdentityTexts>({
|
||||
name: '',
|
||||
mark: '',
|
||||
initials: '',
|
||||
tagline: '',
|
||||
signoff: '',
|
||||
})
|
||||
|
||||
const dirty = computed(() => {
|
||||
const identity = props.identity
|
||||
if (!identity) return false
|
||||
return (
|
||||
draft.value.name !== (identity.name ?? '') ||
|
||||
draft.value.mark !== (identity.mark ?? '') ||
|
||||
draft.value.initials !== (identity.initials ?? '') ||
|
||||
draft.value.tagline !== (identity.tagline ?? '') ||
|
||||
draft.value.signoff !== (identity.signoff ?? '')
|
||||
)
|
||||
})
|
||||
|
||||
function discard(): void {
|
||||
const identity = props.identity
|
||||
if (!identity) return
|
||||
draft.value = {
|
||||
name: identity.name ?? '',
|
||||
mark: identity.mark ?? '',
|
||||
initials: identity.initials ?? '',
|
||||
tagline: identity.tagline ?? '',
|
||||
signoff: identity.signoff ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// The draft starts empty, which reads as "dirty" against a saved
|
||||
// identity — the first arrival syncs unconditionally, later ones only
|
||||
// when the operator has not typed.
|
||||
const initialized = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.identity,
|
||||
(identity) => {
|
||||
if (!identity || (initialized.value && dirty.value)) return
|
||||
initialized.value = true
|
||||
draft.value = {
|
||||
name: identity.name ?? '',
|
||||
mark: identity.mark ?? '',
|
||||
initials: identity.initials ?? '',
|
||||
tagline: identity.tagline ?? '',
|
||||
signoff: identity.signoff ?? '',
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function save(): void {
|
||||
if (!dirty.value || props.saving) return
|
||||
emit('save', { ...draft.value })
|
||||
}
|
||||
|
||||
// --- the images -------------------------------------------------------
|
||||
const avatarInput = ref<HTMLInputElement | null>(null)
|
||||
const logoInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function pickImage(kind: AgentImageKind): void {
|
||||
;(kind === 'avatar' ? avatarInput : logoInput).value?.click()
|
||||
}
|
||||
|
||||
function onImageChosen(kind: AgentImageKind, event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) emit('uploadImage', kind, file)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const CAPS: Record<keyof IdentityTexts, number> = {
|
||||
name: 64,
|
||||
mark: 8,
|
||||
initials: 4,
|
||||
tagline: 120,
|
||||
signoff: 120,
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader
|
||||
title="General"
|
||||
subtitle="The agent's identity — how every surface names and shows it"
|
||||
>
|
||||
<template #actions>
|
||||
<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">
|
||||
<div class="identity">
|
||||
<AgentAvatar
|
||||
:avatar-url="avatarUrl"
|
||||
:mark="draft.mark"
|
||||
:initials="draft.initials || draft.name.slice(0, 1)"
|
||||
:size="56"
|
||||
variant="initials"
|
||||
/>
|
||||
<div class="identity-text">
|
||||
<h2>{{ draft.name || 'rhema' }}</h2>
|
||||
<p>{{ draft.tagline || 'the spoken word' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppCallout variant="info" title="This is the agent's identity, not the engine's">
|
||||
Everything in the interface uses the name, mark, and images here —
|
||||
the rail, the chat, the window title, the notifications. The
|
||||
engine's own name appears only where it is a component.
|
||||
</AppCallout>
|
||||
|
||||
<SettingCard v-if="identity" title="Agent identity">
|
||||
<SettingRow
|
||||
title="Display name"
|
||||
description="Shown as the author of messages, in the window title, and in notifications."
|
||||
>
|
||||
<input
|
||||
v-model="draft.name"
|
||||
type="text"
|
||||
:maxlength="CAPS.name"
|
||||
placeholder="rhema"
|
||||
autocomplete="off"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Mark"
|
||||
description="The glyph or emoji in the rail and the empty states. Defaults to ῥ."
|
||||
>
|
||||
<input
|
||||
v-model="draft.mark"
|
||||
type="text"
|
||||
:maxlength="CAPS.mark"
|
||||
placeholder="ῥ"
|
||||
autocomplete="off"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Initials"
|
||||
description="Used when there is no mark and no avatar."
|
||||
>
|
||||
<input
|
||||
v-model="draft.initials"
|
||||
type="text"
|
||||
:maxlength="CAPS.initials"
|
||||
:placeholder="draft.name.slice(0, 1) || 'R'"
|
||||
autocomplete="off"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow title="Tagline" description="One line under the name on the agent pane.">
|
||||
<input
|
||||
v-model="draft.tagline"
|
||||
type="text"
|
||||
:maxlength="CAPS.tagline"
|
||||
placeholder="the spoken word"
|
||||
autocomplete="off"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Sign-off"
|
||||
description="Appended to messages the agent sends outside the interface."
|
||||
>
|
||||
<input
|
||||
v-model="draft.signoff"
|
||||
type="text"
|
||||
:maxlength="CAPS.signoff"
|
||||
placeholder="optional"
|
||||
autocomplete="off"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard
|
||||
v-if="identity"
|
||||
title="Avatar & logo"
|
||||
meta="png or jpeg, up to 2 MiB — uploads apply immediately"
|
||||
>
|
||||
<SettingRow
|
||||
title="Avatar"
|
||||
description="The square face in the chat and the rail."
|
||||
>
|
||||
<div class="image-control">
|
||||
<AgentAvatar :avatar-url="avatarUrl" :mark="identity.mark" :initials="identity.initials ?? identity.name?.slice(0, 1)" :size="40" variant="initials" />
|
||||
<button :disabled="mutating" @click="pickImage('avatar')">Upload…</button>
|
||||
<button v-if="identity.avatar" class="ghost" :disabled="mutating" @click="emit('clearImage', 'avatar')">
|
||||
Clear
|
||||
</button>
|
||||
<input
|
||||
ref="avatarInput"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
@change="onImageChosen('avatar', $event)"
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Logo"
|
||||
description="The mark the empty states and headers render."
|
||||
>
|
||||
<div class="image-control">
|
||||
<AgentAvatar :avatar-url="logoUrl" :mark="identity.mark" :size="40" variant="mark" />
|
||||
<button :disabled="mutating" @click="pickImage('logo')">Upload…</button>
|
||||
<button v-if="identity.logo" class="ghost" :disabled="mutating" @click="emit('clearImage', 'logo')">
|
||||
Clear
|
||||
</button>
|
||||
<input
|
||||
ref="logoInput"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
@change="onImageChosen('logo', $event)"
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.identity-text h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.identity-text p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.image-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.image-control input[type='file'] {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,491 @@
|
||||
<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 (a failed
|
||||
// one leaves the old, and the banner names the error), so the form
|
||||
// forgets what it held and closes.
|
||||
watch(
|
||||
() => props.state,
|
||||
(state, previous) => {
|
||||
if (pendingAdd.value !== null && state !== previous) {
|
||||
pendingAdd.value = null
|
||||
loginOpen.value = false
|
||||
addName.value = ''
|
||||
addApiKey.value = ''
|
||||
addUrl.value = ''
|
||||
addKind.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
</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 }}
|
||||
</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>
|
||||
Reference in new issue
Block a user