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,701 @@
|
||||
<script setup lang="ts">
|
||||
// The one page: the icon rail, then either the chat view (session
|
||||
// inventory + live transcript) or the settings view (nav + pages). A
|
||||
// tiny hash router keeps views deep-linkable without vue-router. All
|
||||
// state is in-memory; only the token and the active session persist.
|
||||
// The claw owns the conversation and the config, so this page is a
|
||||
// mirror, not a store.
|
||||
import { ClawError, clawHealth, isInteractive, useClaw } from '~/composables/useClaw'
|
||||
import type {
|
||||
AgentIdentity,
|
||||
AgentImageKind,
|
||||
ClawAccount,
|
||||
ClawSession,
|
||||
ConsentAnswer,
|
||||
IdentityTexts,
|
||||
ModelCard,
|
||||
NewAccount,
|
||||
ProviderCredentials,
|
||||
ProvidersState,
|
||||
StreamStatus,
|
||||
} from '~/composables/useClaw'
|
||||
import { useTranscript } from '~/composables/useTranscript'
|
||||
|
||||
const TOKEN_STORE_KEY = 'claw.token'
|
||||
const ACTIVE_STORE_KEY = 'claw.activeKey'
|
||||
const DEFAULT_KEY = 'interactive:web'
|
||||
|
||||
function stored(key: string): string | null {
|
||||
if (!import.meta.client) return null
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function persist(key: string, value: string | null): void {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(key)
|
||||
else localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// Storage unavailable (private mode): the session still works, it
|
||||
// just forgets.
|
||||
}
|
||||
}
|
||||
|
||||
// --- the two views ---------------------------------------------------
|
||||
type Route = { view: 'chat' } | { view: 'settings'; section: string }
|
||||
|
||||
const SETTINGS_SECTIONS: { label: string; entries: { id: string; label: string }[] }[] = [
|
||||
{
|
||||
label: 'Agent',
|
||||
entries: [
|
||||
{ id: 'agent/general', label: 'General' },
|
||||
{ id: 'agent/accounts', label: 'Accounts' },
|
||||
],
|
||||
},
|
||||
{ label: 'Providers', entries: [{ id: 'providers', label: 'Model providers' }] },
|
||||
{ label: 'System', entries: [{ id: 'about', label: 'About' }] },
|
||||
]
|
||||
|
||||
function parseHash(): Route {
|
||||
if (!import.meta.client) return { view: 'chat' }
|
||||
const hash = window.location.hash.replace(/^#\/?/, '')
|
||||
if (hash.startsWith('settings')) {
|
||||
// Section ids may carry their category ('agent/general'), so the
|
||||
// section is everything after 'settings/', not the second segment.
|
||||
const section = hash.replace(/^settings\/?/, '') || 'agent/general'
|
||||
return {
|
||||
view: 'settings',
|
||||
section: SETTINGS_SECTIONS.some((s) => s.entries.some((e) => e.id === section))
|
||||
? section
|
||||
: 'providers',
|
||||
}
|
||||
}
|
||||
return { view: 'chat' }
|
||||
}
|
||||
|
||||
const route = ref<Route>(parseHash())
|
||||
|
||||
function go(next: Route): void {
|
||||
window.location.hash = next.view === 'chat' ? '#/chat' : `#/settings/${next.section}`
|
||||
}
|
||||
|
||||
function onHashChange(): void {
|
||||
route.value = parseHash()
|
||||
}
|
||||
|
||||
// --- claw state ------------------------------------------------------
|
||||
const config = useRuntimeConfig()
|
||||
const token = ref<string | null>(
|
||||
stored(TOKEN_STORE_KEY) ?? (config.public.clawToken || null),
|
||||
)
|
||||
const claw = useClaw(token)
|
||||
|
||||
const activeKey = ref(stored(ACTIVE_STORE_KEY) ?? DEFAULT_KEY)
|
||||
watch(activeKey, (key) => persist(ACTIVE_STORE_KEY, key))
|
||||
|
||||
const transcript = useTranscript()
|
||||
const { items: transcriptItems, live: liveTurn } = transcript
|
||||
|
||||
// Attachment ids this page already tried to fetch; a failed fetch is
|
||||
// not retried until the stream resets and clears the set.
|
||||
const attemptedAttachments = new Set<string>()
|
||||
|
||||
// Image cards need their bytes before they can render: fetched as the
|
||||
// card appears, not on any timer. code/file fetch on download instead.
|
||||
watch(
|
||||
transcriptItems,
|
||||
(items) => {
|
||||
for (const item of items) {
|
||||
if (item.kind !== 'attachment') continue
|
||||
const { id, media, url } = item.attachment
|
||||
if (media !== 'image' || url !== null || attemptedAttachments.has(id)) continue
|
||||
attemptedAttachments.add(id)
|
||||
claw
|
||||
.attachmentUrl(id)
|
||||
.then((fetched) => transcript.setAttachmentUrl(id, fetched))
|
||||
.catch((err) => noteApiError(err))
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// --- the agent's identity --------------------------------------------
|
||||
const identity = ref<AgentIdentity | null>(null)
|
||||
const identityAvatarUrl = ref<string | null>(null)
|
||||
const identityLogoUrl = ref<string | null>(null)
|
||||
|
||||
async function loadImageUrls(): Promise<void> {
|
||||
// A new object URL per fetch; the old one is revoked, not leaked.
|
||||
for (const [kind, url] of [
|
||||
['avatar', identityAvatarUrl],
|
||||
['logo', identityLogoUrl],
|
||||
] as const) {
|
||||
if (!identity.value?.[kind]) {
|
||||
if (url.value) URL.revokeObjectURL(url.value)
|
||||
url.value = null
|
||||
continue
|
||||
}
|
||||
if (url.value) URL.revokeObjectURL(url.value)
|
||||
try {
|
||||
url.value = await claw.agentImage(kind)
|
||||
} catch {
|
||||
url.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshIdentity(): Promise<void> {
|
||||
try {
|
||||
identity.value = await claw.agent()
|
||||
await loadImageUrls()
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveIdentity(texts: IdentityTexts): Promise<void> {
|
||||
savingIdentity.value = true
|
||||
try {
|
||||
identity.value = await claw.setAgent(texts)
|
||||
pushNotice('identity saved')
|
||||
apiError.value = null
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
} finally {
|
||||
savingIdentity.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadIdentityImage(kind: AgentImageKind, file: File): Promise<void> {
|
||||
mutatingIdentity.value = true
|
||||
try {
|
||||
identity.value = await claw.setAgentImage(kind, file)
|
||||
await loadImageUrls()
|
||||
pushNotice(`${kind} updated`)
|
||||
apiError.value = null
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
} finally {
|
||||
mutatingIdentity.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearIdentityImage(kind: AgentImageKind): Promise<void> {
|
||||
mutatingIdentity.value = true
|
||||
try {
|
||||
identity.value = await claw.clearAgentImage(kind)
|
||||
await loadImageUrls()
|
||||
pushNotice(`${kind} cleared`)
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
} finally {
|
||||
mutatingIdentity.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// The window title answers to the identity and the view, as the design
|
||||
// shows it: "Kosmos — Settings · Model providers".
|
||||
const viewTitle = computed(() => {
|
||||
const current = route.value
|
||||
if (current.view === 'chat') return 'Chat'
|
||||
const label = SETTINGS_SECTIONS.flatMap((section) => section.entries).find(
|
||||
(entry) => entry.id === current.section,
|
||||
)?.label
|
||||
return `Settings · ${label ?? current.section}`
|
||||
})
|
||||
watch(
|
||||
[identity, viewTitle],
|
||||
() => {
|
||||
const name = identity.value?.name?.trim()
|
||||
document.title = name ? `${name} — ${viewTitle.value}` : `rhema claw — ${viewTitle.value}`
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const clawUp = ref<boolean | null>(null)
|
||||
const sessions = ref<ClawSession[]>([])
|
||||
const sessionsLoading = ref(false)
|
||||
const providersState = ref<ProvidersState | null>(null)
|
||||
const providerModels = ref<ModelCard[]>([])
|
||||
const providerModelsLoading = ref(false)
|
||||
const providerModelsError = ref<string | null>(null)
|
||||
const selectedProvider = ref<string | null>(null)
|
||||
const savingProviders = ref(false)
|
||||
const mutatingProviders = ref(false)
|
||||
const savingIdentity = ref(false)
|
||||
const mutatingIdentity = ref(false)
|
||||
const accounts = ref<ClawAccount[] | null>(null)
|
||||
const accountsLoading = ref(false)
|
||||
const mutatingAccounts = ref(false)
|
||||
const streamStatus = ref<StreamStatus>('connecting')
|
||||
const busy = ref(false)
|
||||
const authRequired = ref(false)
|
||||
const tokenDraft = ref('')
|
||||
const apiError = ref<string | null>(null)
|
||||
const notices = ref<{ id: number; text: string }[]>([])
|
||||
let noticeId = 1
|
||||
|
||||
const activeSession = computed(
|
||||
() => sessions.value.find((session) => session.key === activeKey.value) ?? null,
|
||||
)
|
||||
|
||||
function noteApiError(err: unknown): void {
|
||||
if (err instanceof ClawError && err.status === 401) {
|
||||
authRequired.value = true
|
||||
return
|
||||
}
|
||||
apiError.value = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
function pushNotice(text: string): void {
|
||||
notices.value.push({ id: noticeId++, text })
|
||||
if (notices.value.length > 4) notices.value.shift()
|
||||
}
|
||||
|
||||
// --- claw calls ------------------------------------------------------
|
||||
async function checkHealth(): Promise<void> {
|
||||
clawUp.value = await clawHealth()
|
||||
}
|
||||
|
||||
async function refreshSessions(): Promise<void> {
|
||||
sessionsLoading.value = true
|
||||
try {
|
||||
sessions.value = await claw.sessions()
|
||||
apiError.value = null
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
} finally {
|
||||
sessionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(name: string): Promise<void> {
|
||||
try {
|
||||
// The SSE resubscribe watcher follows the switch; the inventory
|
||||
// refresh lands the new key in the sidebar and closes its form.
|
||||
activeKey.value = (await claw.createSession(name)).key
|
||||
apiError.value = null
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
}
|
||||
void refreshSessions()
|
||||
}
|
||||
|
||||
async function refreshProviders(): Promise<void> {
|
||||
try {
|
||||
providersState.value = await claw.providers()
|
||||
if (providersState.value && !selectedProvider.value) {
|
||||
selectedProvider.value = providersState.value.active.provider
|
||||
await loadProviderModels(selectedProvider.value)
|
||||
}
|
||||
apiError.value = null
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviderModels(id: string): Promise<void> {
|
||||
selectedProvider.value = id
|
||||
providerModelsLoading.value = true
|
||||
providerModelsError.value = null
|
||||
try {
|
||||
providerModels.value = await claw.models(id)
|
||||
} catch (err) {
|
||||
// A backend without a live listing is normal (404): the operator
|
||||
// still sees the switch go through on the default model.
|
||||
providerModels.value = []
|
||||
providerModelsError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
providerModelsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAccounts(): Promise<void> {
|
||||
accountsLoading.value = true
|
||||
try {
|
||||
accounts.value = await claw.accounts()
|
||||
apiError.value = null
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
} finally {
|
||||
accountsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addAccount(account: NewAccount): Promise<void> {
|
||||
mutatingAccounts.value = true
|
||||
try {
|
||||
const saved = await claw.addAccount(account)
|
||||
pushNotice(
|
||||
saved.watchable_forge
|
||||
? saved.watched
|
||||
? `account ${saved.label} saved — following ${saved.service} notifications`
|
||||
: `account ${saved.label} saved (already watched)`
|
||||
: `account ${saved.label} saved — ${saved.service} is not a watchable forge`,
|
||||
)
|
||||
await refreshAccounts()
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
} finally {
|
||||
mutatingAccounts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAccount(account: ClawAccount): Promise<void> {
|
||||
mutatingAccounts.value = true
|
||||
try {
|
||||
const forgot = await claw.removeAccount(account.id)
|
||||
pushNotice(
|
||||
forgot.forgot
|
||||
? `account ${account.label} forgotten${forgot.watches_removed ? ` — ${forgot.watches_removed} watch(es) removed` : ''}`
|
||||
: `account ${account.label} was already gone`,
|
||||
)
|
||||
await refreshAccounts()
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
} finally {
|
||||
mutatingAccounts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProviders(provider: string, model?: string): Promise<void> {
|
||||
savingProviders.value = true
|
||||
try {
|
||||
// The route answers the new pair only; carry the provider list
|
||||
// forward so the state stays whole.
|
||||
const { active } = await claw.setActive(provider, model)
|
||||
providersState.value = providersState.value
|
||||
? { ...providersState.value, active }
|
||||
: { active, providers: [] }
|
||||
pushNotice(`active backend: ${provider} / ${active.model}`)
|
||||
apiError.value = null
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
} finally {
|
||||
savingProviders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addProvider(id: string, credentials: ProviderCredentials): Promise<void> {
|
||||
mutatingProviders.value = true
|
||||
const existed = providersState.value?.providers.some((p) => p.id === id) ?? false
|
||||
try {
|
||||
// The login answers the whole state: the roster with the new (or
|
||||
// replaced) backend instance on it.
|
||||
providersState.value = await claw.setCredentials(id, credentials)
|
||||
selectedProvider.value = id
|
||||
await loadProviderModels(id)
|
||||
pushNotice(existed ? `provider ${id} replaced` : `provider ${id} added`)
|
||||
apiError.value = null
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
} finally {
|
||||
mutatingProviders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProvider(id: string): Promise<void> {
|
||||
mutatingProviders.value = true
|
||||
try {
|
||||
providersState.value = await claw.removeProvider(id)
|
||||
// The removal may have moved the active pair: both re-ground on
|
||||
// what remains.
|
||||
if (selectedProvider.value === id) {
|
||||
selectedProvider.value = providersState.value.active.provider
|
||||
await loadProviderModels(selectedProvider.value)
|
||||
}
|
||||
pushNotice(`provider ${id} removed`)
|
||||
apiError.value = null
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
} finally {
|
||||
mutatingProviders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function send(text: string): Promise<void> {
|
||||
transcript.addSent(text)
|
||||
busy.value = true
|
||||
try {
|
||||
// Fire-and-forget by design: the claw answers 202 and the reply
|
||||
// arrives on the event stream.
|
||||
await claw.sendMessage(activeKey.value, text)
|
||||
apiError.value = null
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
void refreshSessions()
|
||||
}
|
||||
|
||||
// A consent answers optimistically: the card flips as soon as the POST
|
||||
// lands, and the stream's consent_resolved echo confirms the same
|
||||
// value.
|
||||
async function answerConsent(id: string, answer: ConsentAnswer): Promise<void> {
|
||||
try {
|
||||
await claw.resolveConsent(id, answer)
|
||||
transcript.resolveConsent(id, 'secret' in answer ? true : answer.grant)
|
||||
apiError.value = null
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function denyConsent(id: string): Promise<void> {
|
||||
try {
|
||||
await claw.denyConsent(id)
|
||||
transcript.resolveConsent(id, false)
|
||||
apiError.value = null
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAttachment(id: string, name: string): Promise<void> {
|
||||
try {
|
||||
// The URL is cached per id, so a second download of the same file
|
||||
// replays the blob, not the request.
|
||||
const url = await claw.attachmentUrl(id)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = name
|
||||
anchor.click()
|
||||
} catch (err) {
|
||||
noteApiError(err)
|
||||
}
|
||||
}
|
||||
|
||||
function saveToken(): void {
|
||||
const next = tokenDraft.value.trim()
|
||||
token.value = next || null
|
||||
persist(TOKEN_STORE_KEY, next || null)
|
||||
tokenDraft.value = ''
|
||||
authRequired.value = false
|
||||
apiError.value = null
|
||||
// The stream's retry loop reads the token fresh each attempt, so it
|
||||
// recovers on its own; the polls below confirm the token works.
|
||||
void checkHealth()
|
||||
void refreshProviders()
|
||||
void refreshSessions()
|
||||
}
|
||||
|
||||
// --- the one live subscription ---------------------------------------
|
||||
let stream: { close: () => void } | null = null
|
||||
|
||||
function resubscribe(): void {
|
||||
stream?.close()
|
||||
transcript.reset()
|
||||
attemptedAttachments.clear()
|
||||
notices.value = []
|
||||
streamStatus.value = 'connecting'
|
||||
stream = claw.subscribeEvents(activeKey.value, {
|
||||
onEvent: (event) => transcript.applyEvent(event),
|
||||
onStatus: (status) => {
|
||||
streamStatus.value = status
|
||||
// A retry reopens the stream with the claw's replay of the
|
||||
// recorded turns: drop the mirror and let it rebuild, or the
|
||||
// replay would double everything already on screen.
|
||||
if (status === 'reconnecting') transcript.reset()
|
||||
if (status === 'open') void refreshSessions()
|
||||
},
|
||||
onNotice: pushNotice,
|
||||
})
|
||||
}
|
||||
|
||||
watch(activeKey, resubscribe)
|
||||
|
||||
let healthTimer: ReturnType<typeof setInterval> | undefined
|
||||
let sessionsTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('hashchange', onHashChange)
|
||||
resubscribe()
|
||||
void checkHealth()
|
||||
void refreshIdentity()
|
||||
void refreshProviders()
|
||||
void refreshAccounts()
|
||||
void refreshSessions()
|
||||
healthTimer = setInterval(checkHealth, 30000)
|
||||
sessionsTimer = setInterval(() => void refreshSessions(), 20000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('hashchange', onHashChange)
|
||||
stream?.close()
|
||||
clearInterval(healthTimer)
|
||||
clearInterval(sessionsTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<AppRail
|
||||
:view="route.view"
|
||||
:claw-up="clawUp"
|
||||
:identity="identity"
|
||||
:avatar-url="identityAvatarUrl"
|
||||
@navigate="go($event === 'chat' ? { view: 'chat' } : { view: 'settings', section: 'providers' })"
|
||||
/>
|
||||
|
||||
<div class="body">
|
||||
<div class="banners">
|
||||
<ClawBanner v-if="authRequired" variant="error">
|
||||
the claw requires a bearer token on /v1.
|
||||
<template #actions>
|
||||
<input
|
||||
v-model="tokenDraft"
|
||||
type="password"
|
||||
placeholder="token"
|
||||
autocomplete="off"
|
||||
@keydown.enter="saveToken"
|
||||
/>
|
||||
<button @click="saveToken">save</button>
|
||||
</template>
|
||||
</ClawBanner>
|
||||
<ClawBanner v-if="clawUp === false" variant="error">
|
||||
the claw is unreachable at {{ config.public.clawBase || 'same origin (dev proxy → 127.0.0.1:7799)' }};
|
||||
start it with <code>rhema-claw</code>
|
||||
</ClawBanner>
|
||||
<ClawBanner v-if="apiError" variant="error" dismissible @dismiss="apiError = null">
|
||||
{{ apiError }}
|
||||
</ClawBanner>
|
||||
<ClawBanner
|
||||
v-for="notice in notices"
|
||||
:key="notice.id"
|
||||
variant="warn"
|
||||
dismissible
|
||||
@dismiss="notices = notices.filter((n) => n.id !== notice.id)"
|
||||
>
|
||||
{{ notice.text }}
|
||||
</ClawBanner>
|
||||
</div>
|
||||
|
||||
<main class="view">
|
||||
<template v-if="route.view === 'chat'">
|
||||
<SessionsSidebar
|
||||
:sessions="sessions"
|
||||
:active-key="activeKey"
|
||||
:loading="sessionsLoading"
|
||||
:identity="identity"
|
||||
:avatar-url="identityAvatarUrl"
|
||||
@select="activeKey = $event"
|
||||
@refresh="refreshSessions"
|
||||
@create="createSession"
|
||||
/>
|
||||
<ChatPanel
|
||||
:items="transcriptItems"
|
||||
:live="liveTurn"
|
||||
:active-key="activeKey"
|
||||
:can-send="isInteractive(activeKey)"
|
||||
:busy="busy"
|
||||
:session="activeSession"
|
||||
:active-backend="providersState?.active ?? null"
|
||||
:stream-status="streamStatus"
|
||||
:identity="identity"
|
||||
:avatar-url="identityAvatarUrl"
|
||||
@send="send"
|
||||
@open-providers="go({ view: 'settings', section: 'providers' })"
|
||||
@consent-answer="answerConsent"
|
||||
@consent-deny="denyConsent"
|
||||
@download-attachment="downloadAttachment"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<SettingsNav
|
||||
:sections="SETTINGS_SECTIONS"
|
||||
:active="route.section"
|
||||
:claw-up="clawUp"
|
||||
@select="go({ view: 'settings', section: $event })"
|
||||
/>
|
||||
<SettingsAgentGeneralPage
|
||||
v-show="route.section === 'agent/general'"
|
||||
:identity="identity"
|
||||
:avatar-url="identityAvatarUrl"
|
||||
:logo-url="identityLogoUrl"
|
||||
:saving="savingIdentity"
|
||||
:mutating="mutatingIdentity"
|
||||
@save="saveIdentity"
|
||||
@upload-image="uploadIdentityImage"
|
||||
@clear-image="clearIdentityImage"
|
||||
/>
|
||||
<SettingsAgentAccountsPage
|
||||
v-show="route.section === 'agent/accounts'"
|
||||
:accounts="accounts"
|
||||
:loading="accountsLoading"
|
||||
:mutating="mutatingAccounts"
|
||||
@add="addAccount"
|
||||
@remove="removeAccount"
|
||||
/>
|
||||
<SettingsProvidersPage
|
||||
v-show="route.section === 'providers'"
|
||||
:state="providersState"
|
||||
:models="providerModels"
|
||||
:models-loading="providerModelsLoading"
|
||||
:models-error="providerModelsError"
|
||||
:selected-provider="selectedProvider"
|
||||
:saving="savingProviders"
|
||||
:mutating="mutatingProviders"
|
||||
@inspect="loadProviderModels"
|
||||
@save="saveProviders"
|
||||
@add="addProvider"
|
||||
@remove="removeProvider"
|
||||
/>
|
||||
<SettingsAboutPage v-show="route.section === 'about'" :claw-up="clawUp" />
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.banners {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 16px 0;
|
||||
}
|
||||
|
||||
.view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.view > * {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.shell {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.view {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.view > * {
|
||||
height: 60vh;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,395 @@
|
||||
/*
|
||||
* The Jadeite tokens, dark theme — lifted token for token from
|
||||
* crates/rhema-code/src/palette.rs `DARK` so the web claw reads as the
|
||||
* same tool as the TUI. The ground is deliberately jade-tinted, not
|
||||
* slate; saturated color stays on the accent and the status set.
|
||||
*/
|
||||
:root {
|
||||
--ground: #0f1413;
|
||||
--surface: #171d1b;
|
||||
--elevated: #1f2624;
|
||||
--sunken: #0a0f0e;
|
||||
--border: #28312e;
|
||||
--border-strong: #3d4844;
|
||||
--text: #eef3f1;
|
||||
--text2: #b9c6c1;
|
||||
--text3: #87948f;
|
||||
--accent: #35a882;
|
||||
--accent-text: #64c9a3;
|
||||
--on-accent: #07110d;
|
||||
--running: #64c9a3;
|
||||
--ok: #4fbf94;
|
||||
--warn: #d9a643;
|
||||
--error: #e4687a;
|
||||
--info: #8fa6b8;
|
||||
|
||||
--sans: ui-sans-serif, system-ui, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, sans-serif;
|
||||
--mono: ui-monospace, 'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo,
|
||||
Consolas, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#__nuxt {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Values, keys, and anything the claw itself says render mono; the UI
|
||||
chrome around them renders sans. */
|
||||
code,
|
||||
pre,
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.93em;
|
||||
}
|
||||
|
||||
/* Section labels: the one repeated typographic voice across panels. */
|
||||
.label {
|
||||
font-family: var(--sans);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
/* --- controls ------------------------------------------------------- */
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
padding: 5px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: var(--accent-text);
|
||||
border-color: var(--accent-text);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: none;
|
||||
border-color: transparent;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
button.ghost:hover:not(:disabled) {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
input:hover,
|
||||
select:hover,
|
||||
textarea:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
/* --- surfaces ------------------------------------------------------- */
|
||||
|
||||
/* The one card: a titled container of setting rows or static facts. */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card > .card-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card > .card-head .title {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card > .card-head .meta {
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.card > .card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Rows inside a card body separate by hairline, not by gaps. */
|
||||
.card > .card-body > * + * {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card > .card-body.flush {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.card > .card-body.flush > * + * {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* --- badges ---------------------------------------------------------- */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text3);
|
||||
font-size: 11px;
|
||||
font-family: var(--mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.accent {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
color: var(--accent-text);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.badge.ok {
|
||||
border-color: color-mix(in srgb, var(--ok) 55%, transparent);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.badge.warn {
|
||||
border-color: color-mix(in srgb, var(--warn) 55%, transparent);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.badge.error {
|
||||
border-color: color-mix(in srgb, var(--error) 55%, transparent);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* --- banners --------------------------------------------------------- */
|
||||
|
||||
/* Banners: the single inline error/notice surface. Never toasts, never
|
||||
console-only — claw down, 401, and stream drops all land here. */
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-left: 3px solid var(--info);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.banner.error {
|
||||
border-left-color: var(--error);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.banner.warn {
|
||||
border-left-color: var(--warn);
|
||||
}
|
||||
|
||||
.banner .banner-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* --- markdown -------------------------------------------------------- */
|
||||
|
||||
/* Rendered agent replies (AppMarkdown): markdown-it output mounted with
|
||||
v-html, so only generated tags show up here. Blocks separate by small
|
||||
margins; headings stay close to body size, a chat reply not a page. */
|
||||
.md > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.md > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.md p,
|
||||
.md ul,
|
||||
.md ol,
|
||||
.md pre,
|
||||
.md blockquote,
|
||||
.md table {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.md ul,
|
||||
.md ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.md li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.md li > ul,
|
||||
.md li > ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.md h1,
|
||||
.md h2,
|
||||
.md h3,
|
||||
.md h4,
|
||||
.md h5,
|
||||
.md h6 {
|
||||
margin: 14px 0 6px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.md h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.md h2 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.md h3 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.md h4,
|
||||
.md h5,
|
||||
.md h6 {
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.md a {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.md code {
|
||||
background: var(--sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.md pre {
|
||||
padding: 10px 12px;
|
||||
background: var(--sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* The global code rule shrinks em-relative twice inside a pre; undo it. */
|
||||
.md pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.md blockquote {
|
||||
padding: 2px 12px;
|
||||
border-left: 3px solid var(--border-strong);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.md hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.md table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.md th,
|
||||
.md td {
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 3px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.md th {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.md img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* While the turn streams, the cursor rides the end of the last block. */
|
||||
.md.streaming > :last-child::after {
|
||||
content: '▊';
|
||||
margin-left: 1px;
|
||||
color: var(--accent-text);
|
||||
animation: md-blink 1.1s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes md-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
// The agent's face at one size. Two voices: `initials` (the chat, the
|
||||
// sidebar, the rail's bottom — the avatar image when there is one, the
|
||||
// initials when not) and `mark` (the rail's brand slot — the avatar
|
||||
// image when there is one, the mark or emoji, ῥ by default). The
|
||||
// precedence lives here and nowhere else.
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'initials' | 'mark'
|
||||
/** The uploaded avatar's object URL, when the claw has one. */
|
||||
avatarUrl?: string | null
|
||||
/** The glyph or emoji; the mark voice defaults to ῥ. */
|
||||
mark?: string | null
|
||||
/** The initials voice's fallback, first letter of the name or R. */
|
||||
initials?: string | null
|
||||
size?: number
|
||||
}>(),
|
||||
{
|
||||
variant: 'initials',
|
||||
avatarUrl: null,
|
||||
mark: null,
|
||||
initials: null,
|
||||
size: 32,
|
||||
},
|
||||
)
|
||||
|
||||
const glyph = computed(() =>
|
||||
props.variant === 'mark'
|
||||
? props.mark?.trim() || 'ῥ'
|
||||
: props.initials?.trim() || 'R',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
:src="avatarUrl"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
class="agent-avatar"
|
||||
alt=""
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="agent-avatar agent-square"
|
||||
:style="{ width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.5)}px` }"
|
||||
aria-hidden="true"
|
||||
>{{ glyph }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agent-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
border-radius: 25%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.agent-square {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// The pill: one small status or tag word. Machine spellings (session
|
||||
// keys, model ids) stay mono via the global `.badge` voice.
|
||||
defineProps<{
|
||||
variant?: 'dim' | 'accent' | 'ok' | 'warn' | 'error'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="badge" :class="variant"><slot /></span>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
// The inline callout: a bordered note with a tone — the "restart
|
||||
// required"/"changes not applied" voice from the designs. One per
|
||||
// situation, inside the page flow, dismissible by the caller.
|
||||
defineProps<{
|
||||
variant: 'info' | 'ok' | 'warn' | 'error'
|
||||
title?: string
|
||||
dismissible?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ dismiss: [] }>()
|
||||
|
||||
const marks: Record<string, string> = {
|
||||
info: 'i',
|
||||
ok: '✓',
|
||||
warn: '↺',
|
||||
error: '✗',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="callout" :class="variant" role="status">
|
||||
<span class="mark" aria-hidden="true">{{ marks[variant] }}</span>
|
||||
<div class="body">
|
||||
<strong v-if="title" class="title">{{ title }}</strong>
|
||||
<div class="text"><slot /></div>
|
||||
</div>
|
||||
<button v-if="dismissible" class="ghost dismiss" title="dismiss" @click="$emit('dismiss')">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.callout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--info);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--info) 7%, var(--surface));
|
||||
}
|
||||
|
||||
.callout.ok {
|
||||
border-left-color: var(--ok);
|
||||
background: color-mix(in srgb, var(--ok) 7%, var(--surface));
|
||||
}
|
||||
|
||||
.callout.warn {
|
||||
border-left-color: var(--warn);
|
||||
background: color-mix(in srgb, var(--warn) 7%, var(--surface));
|
||||
}
|
||||
|
||||
.callout.error {
|
||||
border-left-color: var(--error);
|
||||
background: color-mix(in srgb, var(--error) 7%, var(--surface));
|
||||
}
|
||||
|
||||
.mark {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.ok .mark {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.warn .mark {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.error .mark {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--text2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dismiss {
|
||||
padding: 0 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
// Agent replies are markdown; this mounts the rendered HTML. The parser
|
||||
// keeps raw HTML in the source escaped (html stays false) and refuses
|
||||
// javascript: link targets, so v-html only ever receives generated
|
||||
// markup.
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import type { RendererRule } from 'markdown-it'
|
||||
|
||||
const props = defineProps<{
|
||||
text: string
|
||||
/** While true, a blinking cursor trails the last rendered block. */
|
||||
streaming?: boolean
|
||||
}>()
|
||||
|
||||
const md = new MarkdownIt({ html: false, linkify: true })
|
||||
|
||||
// Rendered links leave the dashboard; open them in a new tab so the
|
||||
// transcript and its event stream survive the click.
|
||||
const defaultLinkOpen: RendererRule =
|
||||
md.renderer.rules.link_open ??
|
||||
((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options))
|
||||
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx]
|
||||
if (token) {
|
||||
token.attrSet('target', '_blank')
|
||||
token.attrSet('rel', 'noopener noreferrer')
|
||||
}
|
||||
return defaultLinkOpen(tokens, idx, options, env, self)
|
||||
}
|
||||
|
||||
const html = computed(() => md.render(props.text))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="md" :class="{ streaming }" v-html="html" />
|
||||
</template>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
// The one modal: a centered panel over a dimmed backdrop. Escape and a
|
||||
// backdrop click close it; the panel stops the click from beneath. The
|
||||
// caller owns open state and what the actions do.
|
||||
import { onUnmounted, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape' && props.open) emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!import.meta.client) return
|
||||
if (open) window.addEventListener('keydown', onKeydown)
|
||||
else window.removeEventListener('keydown', onKeydown)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (import.meta.client) window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="overlay" @click.self="emit('close')">
|
||||
<div class="panel" role="dialog" aria-modal="true" :aria-label="title">
|
||||
<header class="head">
|
||||
<strong class="title">{{ title }}</strong>
|
||||
<button class="ghost dismiss" title="close" @click="emit('close')">×</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<slot />
|
||||
</div>
|
||||
<footer v-if="$slots.actions" class="actions">
|
||||
<slot name="actions" />
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: color-mix(in srgb, var(--sunken) 70%, transparent);
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 18px 48px color-mix(in srgb, var(--sunken) 80%, transparent);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dismiss {
|
||||
padding: 0 6px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
// The icon rail: the leftmost strip every view shares — the brand mark,
|
||||
// one button per view, and the claw's pulse at the bottom. The active
|
||||
// view is highlighted; navigation intent flows up, the shell decides.
|
||||
defineProps<{
|
||||
view: 'chat' | 'settings'
|
||||
/** null while /healthz has not answered yet. */
|
||||
clawUp: boolean | null
|
||||
identity?: { mark: string | null; initials: string | null; name: string | null } | null
|
||||
avatarUrl?: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{ navigate: [view: 'chat' | 'settings'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="rail">
|
||||
<AgentAvatar
|
||||
variant="mark"
|
||||
:avatar-url="avatarUrl"
|
||||
:mark="identity?.mark"
|
||||
:size="36"
|
||||
class="mark"
|
||||
title="rhema, the spoken word"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="rail-btn"
|
||||
:class="{ active: view === 'chat' }"
|
||||
title="Chat"
|
||||
aria-label="Chat"
|
||||
@click="$emit('navigate', 'chat')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 11.5a7.5 7.5 0 0 1-7.5 7.5H8l-4.5 3V11.5a7.5 7.5 0 0 1 7.5-7.5h2.5a7.5 7.5 0 0 1 7.5 7.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rail-btn"
|
||||
:class="{ active: view === 'settings' }"
|
||||
title="Settings"
|
||||
aria-label="Settings"
|
||||
@click="$emit('navigate', 'settings')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="spacer" />
|
||||
|
||||
<span
|
||||
class="pulse"
|
||||
:title="clawUp === null ? 'health unknown' : clawUp ? '/healthz ok' : '/healthz failing'"
|
||||
>
|
||||
<span class="dot" :class="{ up: clawUp === true, down: clawUp === false }" aria-hidden="true" />
|
||||
</span>
|
||||
<AgentAvatar
|
||||
:avatar-url="avatarUrl"
|
||||
:initials="identity?.initials ?? identity?.name?.slice(0, 1)"
|
||||
:size="30"
|
||||
class="avatar"
|
||||
title="rhema"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 56px;
|
||||
flex: none;
|
||||
padding: 12px 0;
|
||||
background: var(--sunken);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mark {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rail-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 9px;
|
||||
border-color: transparent;
|
||||
background: none;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.rail-btn svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.rail-btn:hover:not(:disabled) {
|
||||
background: var(--elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.rail-btn.active {
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text3);
|
||||
}
|
||||
|
||||
.dot.up {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.dot.down {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,685 @@
|
||||
<script setup lang="ts">
|
||||
// The transcript: sent lines and streamed turns, the turn in flight at
|
||||
// the bottom. The claw owns the conversation — this renders what the
|
||||
// event stream delivered, nothing more. The header names the session
|
||||
// and hides the dispatch internals behind an info modal; the composer
|
||||
// chips mirror live claw state; the backend chip jumps to settings.
|
||||
import {
|
||||
PASTE_INLINE_LINES,
|
||||
chipLabel,
|
||||
embedPastes,
|
||||
lineCount,
|
||||
normalizePaste,
|
||||
pasteParts,
|
||||
} from '~/composables/useTranscript'
|
||||
import type { ClawSession, ConsentAnswer, StreamStatus } from '~/composables/useClaw'
|
||||
import type { PastePart, TranscriptItem, TurnBlock } from '~/composables/useTranscript'
|
||||
|
||||
const props = defineProps<{
|
||||
items: TranscriptItem[]
|
||||
live: TurnBlock | null
|
||||
activeKey: string
|
||||
/** Only `interactive:*` keys accept messages (the claw 400s the rest). */
|
||||
canSend: boolean
|
||||
busy: boolean
|
||||
session: ClawSession | null
|
||||
activeBackend: { provider: string; model: string } | null
|
||||
streamStatus: StreamStatus
|
||||
identity?: { name: string | null; mark: string | null; initials: string | null } | null
|
||||
avatarUrl?: string | null
|
||||
}>()
|
||||
|
||||
const agentName = computed(() => props.identity?.name?.trim() || 'rhema')
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [text: string]
|
||||
openProviders: []
|
||||
consentAnswer: [id: string, answer: ConsentAnswer]
|
||||
consentDeny: [id: string]
|
||||
downloadAttachment: [id: string, name: string]
|
||||
}>()
|
||||
|
||||
const draft = ref('')
|
||||
const scroller = ref<HTMLElement | null>(null)
|
||||
/** Pin to the bottom only while the reader is already there. */
|
||||
const pinned = ref(true)
|
||||
|
||||
function onScroll(): void {
|
||||
const el = scroller.value
|
||||
if (!el) return
|
||||
pinned.value = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.items.length, props.live?.reply.length, props.live?.thinking.length, props.live?.entries.length],
|
||||
async () => {
|
||||
if (!pinned.value) return
|
||||
await nextTick()
|
||||
const el = scroller.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
},
|
||||
)
|
||||
|
||||
function submit(): void {
|
||||
const text = draft.value.trim()
|
||||
if (!text || !props.canSend || props.busy) return
|
||||
emit('send', embedPastes(text, pastes.value))
|
||||
draft.value = ''
|
||||
pastes.value = []
|
||||
pinned.value = true
|
||||
void nextTick(resizePrompt)
|
||||
}
|
||||
|
||||
// --- the composer -----------------------------------------------------
|
||||
const prompt = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
/** The composer grows to this many rows, then scrolls instead. */
|
||||
const MAX_PROMPT_ROWS = 10
|
||||
|
||||
/** Height follows the content; the CSS max-height clamps the growth. */
|
||||
function resizePrompt(): void {
|
||||
const el = prompt.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}
|
||||
|
||||
/** Enter submits, shift+enter is a newline, ctrl/cmd+enter submits. */
|
||||
function onPromptKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
// --- pasted attachments ----------------------------------------------
|
||||
// A paste over PASTE_INLINE_LINES lines rides as a chip: the payload
|
||||
// never lands in the box, it embeds into the sent text on submit —
|
||||
// the same record the TUI keeps.
|
||||
|
||||
const pastes = ref<string[]>([])
|
||||
|
||||
function onPaste(event: ClipboardEvent): void {
|
||||
const transfer = event.clipboardData
|
||||
if (!transfer) return
|
||||
const text = transfer.getData('text/plain')
|
||||
if (text !== '') {
|
||||
if (absorbPaste(text)) event.preventDefault()
|
||||
return
|
||||
}
|
||||
// A file paste reads as its text when it claims a textual type; the
|
||||
// rest the composer ignores.
|
||||
const file = transfer.files[0]
|
||||
if (!file || !file.type.startsWith('text/')) return
|
||||
event.preventDefault()
|
||||
void file.text().then((text) => {
|
||||
if (!absorbPaste(text)) insertInline(text)
|
||||
})
|
||||
}
|
||||
|
||||
/** A long paste becomes one chip; whether it did is the answer. */
|
||||
function absorbPaste(text: string): boolean {
|
||||
const normalized = normalizePaste(text)
|
||||
if (lineCount(normalized) <= PASTE_INLINE_LINES) return false
|
||||
pastes.value.push(normalized)
|
||||
return true
|
||||
}
|
||||
|
||||
/** A short file paste joins the draft where the caret sits. */
|
||||
function insertInline(text: string): void {
|
||||
const el = prompt.value
|
||||
if (el && !el.disabled) {
|
||||
el.setRangeText(text, el.selectionStart ?? el.value.length, el.selectionEnd ?? el.value.length, 'end')
|
||||
draft.value = el.value
|
||||
} else {
|
||||
draft.value += text
|
||||
}
|
||||
resizePrompt()
|
||||
}
|
||||
|
||||
/** One render piece of a user message: literal text or a paste chip. */
|
||||
type MessageSegment = PastePart | { kind: 'chip'; label: string }
|
||||
|
||||
/** The message's render pieces: literal text and resolved chips, the
|
||||
* chips' one-based ids counted across the message. */
|
||||
function messageSegments(text: string): MessageSegment[] {
|
||||
const segments: MessageSegment[] = []
|
||||
let chip = 0
|
||||
for (const part of pasteParts(text)) {
|
||||
if (part.kind === 'text') {
|
||||
if (part.text !== '') segments.push(part)
|
||||
} else {
|
||||
segments.push({ kind: 'chip', label: chipLabel(part.text, chip++) })
|
||||
}
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
const displayName = computed(() => props.activeKey.replace(/^interactive:/, ''))
|
||||
|
||||
// The dispatch internals live behind the info modal, not in the header.
|
||||
const infoOpen = ref(false)
|
||||
|
||||
const infoRows = computed(() => [
|
||||
{ label: 'state', value: props.session?.state ?? 'unknown' },
|
||||
{ label: 'revision', value: props.session ? String(props.session.revision) : '—' },
|
||||
{ label: 'inbox depth', value: props.session ? String(props.session.inbox_depth) : '—' },
|
||||
{ label: 'last outcome', value: props.session?.last_outcome ?? '—' },
|
||||
{ label: 'key', value: props.activeKey },
|
||||
])
|
||||
|
||||
const streamLabel = computed(() =>
|
||||
props.streamStatus === 'open' ? 'live' : props.streamStatus,
|
||||
)
|
||||
|
||||
function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
|
||||
if (status === 'open') return 'ok'
|
||||
if (status === 'reconnecting') return 'error'
|
||||
return 'warn'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="chat">
|
||||
<header class="head">
|
||||
<AgentAvatar :avatar-url="avatarUrl" :mark="identity?.mark" :initials="identity?.initials ?? identity?.name?.slice(0, 1)" :size="34" />
|
||||
<div class="text">
|
||||
<h2>{{ displayName }}</h2>
|
||||
</div>
|
||||
<span class="stream-status" :title="`event stream: ${streamStatus}`">
|
||||
<StatusDot :tone="streamTone(streamStatus)" />
|
||||
{{ streamLabel }}
|
||||
</span>
|
||||
<button class="ghost info" title="session info" @click="infoOpen = true">ⓘ</button>
|
||||
</header>
|
||||
|
||||
<AppModal :open="infoOpen" title="Session info" @close="infoOpen = false">
|
||||
<dl class="info-rows">
|
||||
<div v-for="row in infoRows" :key="row.label" class="info-row">
|
||||
<dt class="label">{{ row.label }}</dt>
|
||||
<dd class="mono">{{ row.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</AppModal>
|
||||
|
||||
<div ref="scroller" class="stream-area" @scroll.passive="onScroll">
|
||||
<p v-if="items.length === 0 && !live" class="empty">
|
||||
nothing streamed yet; messages post to the claw and replies arrive here live
|
||||
</p>
|
||||
|
||||
<template v-for="item in items" :key="item.id">
|
||||
<div v-if="item.kind === 'sent'" class="msg">
|
||||
<div class="avatar user" aria-hidden="true">·</div>
|
||||
<div class="msg-body">
|
||||
<div class="who">you</div>
|
||||
<div class="reply plain">
|
||||
<template v-for="(segment, index) in messageSegments(item.text)" :key="index">
|
||||
<span v-if="segment.kind === 'chip'" class="badge paste-chip">{{ segment.label }}</span>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="item.kind === 'turn'" class="msg">
|
||||
<AgentAvatar :avatar-url="avatarUrl" :mark="identity?.mark" :initials="identity?.initials ?? identity?.name?.slice(0, 1)" :size="32" />
|
||||
<div class="msg-body">
|
||||
<div class="who">{{ agentName }}</div>
|
||||
<details v-if="item.turn.thinking" class="thinking">
|
||||
<summary>thinking</summary>
|
||||
<pre>{{ item.turn.thinking }}</pre>
|
||||
</details>
|
||||
<TurnEntries
|
||||
:entries="item.turn.entries"
|
||||
@consent-answer="(id, answer) => emit('consentAnswer', id, answer)"
|
||||
@consent-deny="(id) => emit('consentDeny', id)"
|
||||
@download-attachment="(id, name) => emit('downloadAttachment', id, name)"
|
||||
/>
|
||||
<AppMarkdown v-if="item.turn.reply" :text="item.turn.reply" class="reply" />
|
||||
<div
|
||||
v-if="item.turn.outcome"
|
||||
class="outcome mono"
|
||||
:class="{ failed: item.turn.outcome === 'failed' }"
|
||||
>
|
||||
{{ item.turn.outcome }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="msg" :class="item.kind">
|
||||
<div class="msg-body">
|
||||
<TurnEntries
|
||||
:entries="[item.kind === 'consent' ? { kind: 'consent', consent: item.consent } : { kind: 'attachment', attachment: item.attachment }]"
|
||||
@consent-answer="(id, answer) => emit('consentAnswer', id, answer)"
|
||||
@consent-deny="(id) => emit('consentDeny', id)"
|
||||
@download-attachment="(id, name) => emit('downloadAttachment', id, name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="live" class="msg live">
|
||||
<AgentAvatar :avatar-url="avatarUrl" :mark="identity?.mark" :initials="identity?.initials ?? identity?.name?.slice(0, 1)" :size="32" />
|
||||
<div class="msg-body">
|
||||
<div class="who">{{ agentName }}</div>
|
||||
<details v-if="live.thinking" class="thinking" open>
|
||||
<summary>thinking</summary>
|
||||
<pre>{{ live.thinking }}</pre>
|
||||
</details>
|
||||
<TurnEntries
|
||||
:entries="live.entries"
|
||||
@consent-answer="(id, answer) => emit('consentAnswer', id, answer)"
|
||||
@consent-deny="(id) => emit('consentDeny', id)"
|
||||
@download-attachment="(id, name) => emit('downloadAttachment', id, name)"
|
||||
/>
|
||||
<AppMarkdown v-if="live.reply" :text="live.reply" streaming class="reply" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="composer">
|
||||
<div class="chips">
|
||||
<span class="badge">{{ activeKey }}</span>
|
||||
<button
|
||||
class="badge chip-btn"
|
||||
title="switch the model backend in settings"
|
||||
:disabled="!activeBackend"
|
||||
@click="emit('openProviders')"
|
||||
>
|
||||
{{ activeBackend ? `${activeBackend.provider} · ${activeBackend.model}` : 'no backend' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="pastes.length > 0" class="paste-chips">
|
||||
<span v-for="(paste, index) in pastes" :key="index" class="badge paste-chip">
|
||||
{{ chipLabel(paste, index) }}
|
||||
<button class="chip-x" :title="`drop paste ${index + 1}`" @click="pastes.splice(index, 1)">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<textarea
|
||||
ref="prompt"
|
||||
v-model="draft"
|
||||
rows="1"
|
||||
:placeholder="canSend ? `Message ${agentName}…` : 'only interactive:* sessions accept messages'"
|
||||
:disabled="!canSend"
|
||||
@keydown="onPromptKeydown"
|
||||
@paste="onPaste"
|
||||
@input="resizePrompt"
|
||||
/>
|
||||
<button class="primary send" :disabled="!canSend || busy || draft.trim() === ''" title="send" @click="submit">
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
background: var(--ground);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.avatar.user {
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.text h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.head .info {
|
||||
flex: none;
|
||||
padding: 0 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-rows {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.info-row + .info-row {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.info-row dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.stream-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.stream-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text3);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.msg-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.who {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.live .who {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.thinking {
|
||||
margin-bottom: 8px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.thinking summary {
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.thinking pre {
|
||||
margin: 6px 0 0;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text3);
|
||||
font-style: italic;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.reply {
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* User lines are verbatim input; agent replies render markdown (see
|
||||
main.css .md) and wrap by its rules instead. */
|
||||
.plain {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.outcome {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.outcome.failed {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* A consent card: the agent asking its human something — distinct
|
||||
from a tool row, toned by its resolution. */
|
||||
.consent-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--warn);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.consent-card.ok {
|
||||
border-left-color: var(--ok);
|
||||
}
|
||||
|
||||
.consent-card.denied {
|
||||
border-left-color: var(--error);
|
||||
}
|
||||
|
||||
.consent-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.consent-summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.denied .consent-summary {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.consent-state {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.denied .consent-state {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.consent-detail {
|
||||
margin: 0;
|
||||
color: var(--text2);
|
||||
font-size: 12.5px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.consent-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.consent-actions input {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
/* An attachment card: the agent handing a file over — head row names
|
||||
it, the image (when there is one) rides under it, capped so a
|
||||
screenshot cannot take the stream. */
|
||||
.attachment-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 480px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.attachment-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachment-glyph {
|
||||
flex: none;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.attachment-size {
|
||||
flex: none;
|
||||
color: var(--text3);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.attachment-image {
|
||||
max-width: 100%;
|
||||
max-height: 240px;
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.attachment-caption {
|
||||
margin: 0;
|
||||
color: var(--text3);
|
||||
font-size: 12.5px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.composer {
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
padding: 10px 16px 14px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chip-btn {
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chip-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.paste-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* One embedded paste, standing in for its payload — composer staging
|
||||
and transcript rendering share the shape. */
|
||||
.paste-chip {
|
||||
gap: 2px;
|
||||
padding-right: 4px;
|
||||
background: var(--elevated);
|
||||
}
|
||||
|
||||
.chip-x {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.chip-x:hover:not(:disabled) {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-row textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 9px 12px;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
/* MAX_PROMPT_ROWS rows, then the box scrolls instead of growing. */
|
||||
max-height: calc(10 * 1.55em + 18px);
|
||||
}
|
||||
|
||||
.send {
|
||||
width: 38px;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
border-radius: 9px;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
// The one banner surface: claw down, auth required, stream drops. An
|
||||
// inline band, dismissible when the caller allows it.
|
||||
defineProps<{
|
||||
variant: 'error' | 'warn' | 'info'
|
||||
dismissible?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ dismiss: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="banner" :class="variant" role="status">
|
||||
<span class="banner-mark" aria-hidden="true">{{
|
||||
variant === 'error' ? '✗' : variant === 'warn' ? '△' : '·'
|
||||
}}</span>
|
||||
<span class="banner-text"><slot /></span>
|
||||
<slot name="actions" />
|
||||
<button v-if="dismissible" class="banner-dismiss" title="dismiss" @click="$emit('dismiss')">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.banner-mark {
|
||||
color: inherit;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.banner.error .banner-mark {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.banner.warn .banner-mark {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.banner.info .banner-mark {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.banner-dismiss {
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text3);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
// The one page header: title, an optional subtitle line, and the page's
|
||||
// actions (Save/Discard-style buttons) on the right.
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-head">
|
||||
<div class="text">
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 24px 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
// The session inventory: the dispatch keys a human converses with,
|
||||
// interactive first. Clicking selects the session the chat mirrors.
|
||||
// Displayed names drop the `interactive:` prefix — the full key stays
|
||||
// in the tooltip. Creating is intent only: the name flows up, the
|
||||
// shell answers, and the form closes when the new key lands in the
|
||||
// inventory.
|
||||
import { SESSION_NAME_PATTERN } from '~/composables/useClaw'
|
||||
import type { ClawSession } from '~/composables/useClaw'
|
||||
|
||||
const props = defineProps<{
|
||||
sessions: ClawSession[]
|
||||
activeKey: string
|
||||
loading: boolean
|
||||
identity?: { name: string | null; tagline: string | null; mark: string | null; initials: string | null } | null
|
||||
avatarUrl?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [key: string]; refresh: []; create: [name: string] }>()
|
||||
|
||||
interface Group {
|
||||
label: string
|
||||
rows: { key: string; name: string; session: ClawSession }[]
|
||||
}
|
||||
|
||||
const groups = computed<Group[]>(() => {
|
||||
const byKey = (a: ClawSession, b: ClawSession) => a.key.localeCompare(b.key)
|
||||
// `phone:<device>:<topic>` keys carry the phone client's status
|
||||
// reporting (battery, network, dnd), not conversation; the inventory
|
||||
// hides them rather than the API narrowing.
|
||||
const conversable = props.sessions.filter((session) => !session.key.startsWith('phone:'))
|
||||
const interactive = conversable
|
||||
.filter((session) => session.key.startsWith('interactive:'))
|
||||
.sort(byKey)
|
||||
const others = conversable
|
||||
.filter((session) => !session.key.startsWith('interactive:'))
|
||||
.sort(byKey)
|
||||
const row = (session: ClawSession) => ({
|
||||
key: session.key,
|
||||
name: session.key.replace(/^interactive:/, ''),
|
||||
session,
|
||||
})
|
||||
const groups: Group[] = []
|
||||
if (interactive.length > 0)
|
||||
groups.push({ label: 'interactive', rows: interactive.map(row) })
|
||||
if (others.length > 0) groups.push({ label: 'other keys', rows: others.map(row) })
|
||||
return groups
|
||||
})
|
||||
|
||||
const shownCount = computed(() =>
|
||||
groups.value.reduce((total, group) => total + group.rows.length, 0),
|
||||
)
|
||||
|
||||
function stateTone(state: string): 'ok' | 'warn' | 'idle' {
|
||||
if (state === 'leased') return 'ok'
|
||||
if (state === 'reacquire') return 'warn'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
// --- new sessions -----------------------------------------------------
|
||||
const creating = ref(false)
|
||||
const nameDraft = ref('')
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
/** The name this form is waiting on; the create is in flight. */
|
||||
const pendingCreate = ref<string | null>(null)
|
||||
|
||||
const nameValid = computed(() => SESSION_NAME_PATTERN.test(nameDraft.value))
|
||||
|
||||
function openCreate(): void {
|
||||
creating.value = true
|
||||
nameDraft.value = ''
|
||||
void nextTick(() => nameInput.value?.focus())
|
||||
}
|
||||
|
||||
function closeCreate(): void {
|
||||
creating.value = false
|
||||
nameDraft.value = ''
|
||||
pendingCreate.value = null
|
||||
}
|
||||
|
||||
function submitCreate(): void {
|
||||
if (!nameValid.value) return
|
||||
pendingCreate.value = nameDraft.value
|
||||
emit('create', nameDraft.value)
|
||||
}
|
||||
|
||||
// The form closes when the claw answers with the session in the
|
||||
// inventory; a failed create answers nothing, and the form stays.
|
||||
watch(
|
||||
() => props.sessions,
|
||||
(sessions) => {
|
||||
if (
|
||||
pendingCreate.value !== null &&
|
||||
sessions.some((session) => session.key === `interactive:${pendingCreate.value}`)
|
||||
) {
|
||||
closeCreate()
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<header class="agent">
|
||||
<AgentAvatar
|
||||
:avatar-url="avatarUrl"
|
||||
:initials="identity?.initials ?? identity?.name?.slice(0, 1)"
|
||||
:size="34"
|
||||
/>
|
||||
<div class="agent-text">
|
||||
<strong>{{ identity?.name?.trim() || 'rhema' }}</strong>
|
||||
<span>{{ identity?.tagline?.trim() || 'the spoken word' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<header class="head">
|
||||
<span class="label">Sessions</span>
|
||||
<span class="count">{{ shownCount }}</span>
|
||||
<button class="ghost" title="new session" :disabled="creating" @click="creating ? closeCreate() : openCreate()">
|
||||
+
|
||||
</button>
|
||||
<button class="ghost" title="refresh sessions" :disabled="loading" @click="$emit('refresh')">
|
||||
↻
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form v-if="creating" class="new-session" @submit.prevent="submitCreate">
|
||||
<input
|
||||
ref="nameInput"
|
||||
v-model="nameDraft"
|
||||
type="text"
|
||||
class="mono"
|
||||
placeholder="session name"
|
||||
autocomplete="off"
|
||||
maxlength="64"
|
||||
/>
|
||||
<div class="new-actions">
|
||||
<button type="button" class="ghost" @click="closeCreate">cancel</button>
|
||||
<button type="submit" class="primary" :disabled="!nameValid">create</button>
|
||||
</div>
|
||||
<p v-if="nameDraft !== '' && !nameValid" class="new-note">
|
||||
letters, digits, dots, dashes, underscores — up to 64.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p v-if="shownCount === 0" class="empty">
|
||||
{{ loading ? 'loading…' : 'no sessions yet; send the first message' }}
|
||||
</p>
|
||||
|
||||
<div v-for="group in groups" :key="group.label" class="group">
|
||||
<span class="label">{{ group.label }}</span>
|
||||
<button
|
||||
v-for="row in group.rows"
|
||||
:key="row.key"
|
||||
class="entry"
|
||||
:class="{ active: row.key === activeKey }"
|
||||
:title="row.key"
|
||||
@click="$emit('select', row.key)"
|
||||
>
|
||||
<StatusDot :tone="stateTone(row.session.state)" />
|
||||
<span class="name">{{ row.name }}</span>
|
||||
<span v-if="row.session.inbox_depth > 0" class="depth" title="inbox depth">
|
||||
{{ row.session.inbox_depth }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 240px;
|
||||
flex: none;
|
||||
padding: 14px 10px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.agent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.agent-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-text strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-text span {
|
||||
color: var(--text3);
|
||||
font-size: 11.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px 10px;
|
||||
}
|
||||
|
||||
.count {
|
||||
flex: 1;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.head .ghost {
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.new-session {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 0 8px 10px;
|
||||
}
|
||||
|
||||
.new-session input {
|
||||
width: 100%;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.new-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.new-actions button {
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.new-note {
|
||||
margin: 0;
|
||||
color: var(--text3);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 4px 8px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.group .label {
|
||||
padding: 0 8px 4px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 7px 10px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.entry:hover:not(:disabled) {
|
||||
background: var(--elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.entry.active {
|
||||
background: color-mix(in srgb, var(--accent) 17%, transparent);
|
||||
color: var(--accent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.depth {
|
||||
flex: none;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-family: var(--mono);
|
||||
color: var(--info);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
// One titled card of setting rows or static facts — the repeating
|
||||
// surface of every settings page. The header may carry a meta note on
|
||||
// the right ("4 unset — inherit the primary model"-style).
|
||||
defineProps<{
|
||||
title: string
|
||||
meta?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card">
|
||||
<header class="card-head">
|
||||
<span class="title">{{ title }}</span>
|
||||
<span v-if="meta || $slots.meta" class="meta"><slot name="meta">{{ meta }}</slot></span>
|
||||
</header>
|
||||
<div class="card-body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
// One row inside a SettingCard: name and explanation on the left, the
|
||||
// control (or value) on the right. Controls align center; tall custom
|
||||
// content can opt out with `top`.
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
/** Align the control to the row's top instead of its center. */
|
||||
top?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row" :class="{ top }">
|
||||
<div class="text">
|
||||
<span class="name">{{ title }}</span>
|
||||
<span v-if="description" class="description">{{ description }}</span>
|
||||
</div>
|
||||
<div class="control">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 13px 16px;
|
||||
}
|
||||
|
||||
.row.top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.row.top .control {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text3);
|
||||
font-size: 12.5px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.control {
|
||||
flex: none;
|
||||
max-width: 55%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control :deep(select),
|
||||
.control :deep(input) {
|
||||
width: 100%;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control,
|
||||
.control :deep(select),
|
||||
.control :deep(input) {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
// The settings nav: searchable sections of pages, the design's left
|
||||
// column. The search is a client-side filter over these entries — it
|
||||
// hides nothing the claw cannot serve.
|
||||
export interface SettingsEntry {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface SettingsSection {
|
||||
label: string
|
||||
entries: SettingsEntry[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
sections: SettingsSection[]
|
||||
active: string
|
||||
/** Health echo for the footer, so the nav answers "am I connected". */
|
||||
clawUp: boolean | null
|
||||
}>()
|
||||
|
||||
defineEmits<{ select: [id: string] }>()
|
||||
|
||||
const query = ref('')
|
||||
|
||||
const filtered = computed<SettingsSection[]>(() => {
|
||||
const needle = query.value.trim().toLowerCase()
|
||||
if (!needle) return props.sections
|
||||
return props.sections
|
||||
.map((section) => ({
|
||||
label: section.label,
|
||||
entries: section.entries.filter((entry) =>
|
||||
entry.label.toLowerCase().includes(needle),
|
||||
),
|
||||
}))
|
||||
.filter((section) => section.entries.length > 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="nav">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m20 20-3.8-3.8" />
|
||||
</svg>
|
||||
<input v-model="query" type="text" placeholder="Search settings" />
|
||||
</div>
|
||||
|
||||
<p v-if="filtered.length === 0" class="empty">no settings match</p>
|
||||
|
||||
<div v-for="section in filtered" :key="section.label" class="section">
|
||||
<span class="label">{{ section.label }}</span>
|
||||
<button
|
||||
v-for="entry in section.entries"
|
||||
:key="entry.id"
|
||||
class="entry"
|
||||
:class="{ active: entry.id === active }"
|
||||
@click="$emit('select', entry.id)"
|
||||
>
|
||||
{{ entry.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="spacer" />
|
||||
|
||||
<footer class="foot">
|
||||
<StatusDot :tone="clawUp === null ? 'idle' : clawUp ? 'ok' : 'error'" />
|
||||
<span>{{ clawUp === null ? 'checking claw' : clawUp ? 'claw connected' : 'claw unreachable' }}</span>
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 232px;
|
||||
flex: none;
|
||||
min-height: 0;
|
||||
padding: 14px 10px 10px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search svg {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
padding: 6px 10px 6px 28px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 4px 6px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section .label {
|
||||
padding: 0 10px 4px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.entry:hover:not(:disabled) {
|
||||
background: var(--elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.entry.active {
|
||||
background: color-mix(in srgb, var(--accent) 17%, transparent);
|
||||
color: var(--accent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
// One small round state dot with an honest tone: ok, warn, error, or
|
||||
// idle gray. The only place status color lives at a glance.
|
||||
defineProps<{
|
||||
tone: 'ok' | 'warn' | 'error' | 'idle'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="dot" :class="tone" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dot {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text3);
|
||||
}
|
||||
|
||||
.dot.ok {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.dot.warn {
|
||||
background: var(--warn);
|
||||
}
|
||||
|
||||
.dot.error {
|
||||
background: var(--error);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
// The agent handing a file over: a head row naming it, the image (when
|
||||
// there is one) under it, capped so a screenshot cannot take the
|
||||
// stream; code and file download on click.
|
||||
import type { AttachmentCard } from '~/composables/useTranscript'
|
||||
|
||||
defineProps<{ attachment: AttachmentCard }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: [id: string, name: string]
|
||||
}>()
|
||||
|
||||
const MEDIA_GLYPHS: Record<AttachmentCard['media'], string> = {
|
||||
image: '▣',
|
||||
code: '⌗',
|
||||
file: '▤',
|
||||
}
|
||||
|
||||
/** B / KB / MB, one decimal past 1 KB — a card label, not precision. */
|
||||
function formatBytes(size: number): string {
|
||||
if (size < 1024) return `${size} B`
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="attachment-card">
|
||||
<div class="attachment-head">
|
||||
<span
|
||||
class="attachment-glyph"
|
||||
aria-hidden="true"
|
||||
:title="`${attachment.media} attachment`"
|
||||
>{{ MEDIA_GLYPHS[attachment.media] }}</span>
|
||||
<span class="attachment-name mono">{{ attachment.name }}</span>
|
||||
<span class="attachment-size mono">{{ formatBytes(attachment.size) }}</span>
|
||||
<button
|
||||
v-if="attachment.media !== 'image'"
|
||||
class="ghost"
|
||||
:title="`download ${attachment.name}`"
|
||||
@click="emit('download', attachment.id, attachment.name)"
|
||||
>
|
||||
↓ download
|
||||
</button>
|
||||
</div>
|
||||
<img
|
||||
v-if="attachment.media === 'image' && attachment.url"
|
||||
:src="attachment.url"
|
||||
:alt="attachment.caption ?? attachment.name"
|
||||
class="attachment-image"
|
||||
/>
|
||||
<p v-if="attachment.caption" class="attachment-caption">
|
||||
{{ attachment.caption }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.attachment-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 480px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.attachment-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachment-glyph {
|
||||
flex: none;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.attachment-size {
|
||||
flex: none;
|
||||
color: var(--text3);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.attachment-image {
|
||||
max-width: 100%;
|
||||
max-height: 240px;
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.attachment-caption {
|
||||
margin: 0;
|
||||
color: var(--text3);
|
||||
font-size: 12.5px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
// The agent asking its human something: a secret to store, permission
|
||||
// to act. Answering is intent only — the shell POSTs, and the card
|
||||
// flips on the local echo or the stream's consent_resolved.
|
||||
import { ref } from 'vue'
|
||||
import type { ConsentCard } from '~/composables/useTranscript'
|
||||
import type { ConsentAnswer } from '~/composables/useClaw'
|
||||
|
||||
const props = defineProps<{ consent: ConsentCard }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
answer: [id: string, answer: ConsentAnswer]
|
||||
deny: [id: string]
|
||||
}>()
|
||||
|
||||
const draft = ref('')
|
||||
|
||||
function tone(consent: ConsentCard): 'pending' | 'ok' | 'denied' {
|
||||
if (consent.granted === null) return 'pending'
|
||||
return consent.granted ? 'ok' : 'denied'
|
||||
}
|
||||
|
||||
function answerSecret(): void {
|
||||
const secret = draft.value.trim()
|
||||
if (secret === '') return
|
||||
emit('answer', props.consent.id, { secret })
|
||||
draft.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="consent-card" :class="tone(consent)">
|
||||
<div class="consent-head">
|
||||
<span class="badge">{{ consent.kind }}</span>
|
||||
<strong class="consent-summary">{{ consent.summary }}</strong>
|
||||
<span v-if="consent.granted !== null" class="consent-state mono">
|
||||
{{ consent.granted ? 'granted' : 'denied' }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="consent.detail" class="consent-detail">{{ consent.detail }}</p>
|
||||
<div v-if="consent.granted === null" class="consent-actions">
|
||||
<template v-if="consent.kind === 'secret'">
|
||||
<input
|
||||
v-model="draft"
|
||||
type="password"
|
||||
placeholder="the secret, never shown again"
|
||||
autocomplete="off"
|
||||
@keydown.enter="answerSecret"
|
||||
/>
|
||||
<button
|
||||
class="primary"
|
||||
:disabled="draft.trim() === ''"
|
||||
@click="answerSecret"
|
||||
>
|
||||
submit
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="primary" @click="emit('answer', consent.id, { grant: true })">
|
||||
Approve
|
||||
</button>
|
||||
<button @click="emit('answer', consent.id, { grant: false })">
|
||||
Deny
|
||||
</button>
|
||||
</template>
|
||||
<button class="ghost" title="cancel without answering" @click="emit('deny', consent.id)">
|
||||
deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.consent-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--warn);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.consent-card.ok {
|
||||
border-left-color: var(--ok);
|
||||
}
|
||||
|
||||
.consent-card.denied {
|
||||
border-left-color: var(--error);
|
||||
}
|
||||
|
||||
.consent-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.consent-summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.denied .consent-summary {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.consent-state {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.denied .consent-state {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.consent-detail {
|
||||
margin: 0;
|
||||
color: var(--text2);
|
||||
font-size: 12.5px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.consent-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.consent-actions input {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
// A turn's stream-ordered middle: tool rows, the agent's asks, its
|
||||
// handovers — each at the position the stream delivered it, so an ask
|
||||
// sits at the tool call that raised it rather than at the turn's top.
|
||||
import type { TurnEntry } from '~/composables/useTranscript'
|
||||
import type { ConsentAnswer } from '~/composables/useClaw'
|
||||
|
||||
defineProps<{ entries: TurnEntry[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
consentAnswer: [id: string, answer: ConsentAnswer]
|
||||
consentDeny: [id: string]
|
||||
downloadAttachment: [id: string, name: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="entries.length > 0" class="entries">
|
||||
<template v-for="(entry, index) in entries" :key="index">
|
||||
<TurnToolRow v-if="entry.kind === 'tool'" :row="entry.row" />
|
||||
<TurnConsentCard
|
||||
v-else-if="entry.kind === 'consent'"
|
||||
:consent="entry.consent"
|
||||
@answer="(id, answer) => emit('consentAnswer', id, answer)"
|
||||
@deny="(id) => emit('consentDeny', id)"
|
||||
/>
|
||||
<TurnAttachmentCard
|
||||
v-else
|
||||
:attachment="entry.attachment"
|
||||
@download="(id, name) => emit('downloadAttachment', id, name)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
// One tool call in a turn: running, completed, or failed — shell lines
|
||||
// for bash (the TUI's tool_row), glyph rows for everything else.
|
||||
import type { ToolRow } from '~/composables/useTranscript'
|
||||
|
||||
defineProps<{ row: ToolRow }>()
|
||||
|
||||
/** Command rows render as shell lines, not glyph rows; run_command is
|
||||
* the bash tool's other name. */
|
||||
function isShellTool(name: string): boolean {
|
||||
return name === 'bash' || name === 'run_command'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tool-row" :class="{ error: row.isError === true }">
|
||||
<template v-if="isShellTool(row.name)">
|
||||
<span class="shell-mark" aria-hidden="true">$ </span>
|
||||
<span class="shell-command mono">{{ row.command ?? '…' }}</span>
|
||||
<span
|
||||
v-if="typeof row.code === 'number'"
|
||||
class="shell-code mono"
|
||||
:class="row.code === 0 ? 'ok' : 'failed'"
|
||||
>[{{ row.code }}]</span>
|
||||
<span v-else-if="row.isError === true" class="shell-code failed">✗</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="glyph" aria-hidden="true">{{
|
||||
row.isError === null ? '…' : row.isError ? '✗' : '✓'
|
||||
}}</span>
|
||||
<span class="name mono">{{ row.name }}</span>
|
||||
<span v-if="row.summary" class="summary mono">{{ row.summary }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
padding: 5px 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--ok);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.tool-row.error {
|
||||
border-left-color: var(--error);
|
||||
}
|
||||
|
||||
.glyph {
|
||||
flex: none;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.tool-row.error .glyph {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.summary {
|
||||
color: var(--text3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A bash row reads as the shell line it ran: dim $ prefix, the
|
||||
command, and the exit code pushed to the row's right end. */
|
||||
.shell-mark {
|
||||
flex: none;
|
||||
color: var(--text3);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.shell-command {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.shell-code {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.shell-code.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.shell-code.failed {
|
||||
color: var(--error);
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,716 @@
|
||||
// The typed client for the claw's HTTP surface (crates/rhema-claw/src/
|
||||
// http.rs): sessions inventory, message ingest, turn-event streaming,
|
||||
// and watch CRUD. One consumer, one contract — everything here mirrors
|
||||
// the wire shapes the Rust side serializes; nothing else exists.
|
||||
//
|
||||
// Why fetch-based SSE: the claw's auth middleware reads only the
|
||||
// `Authorization: Bearer` header (http.rs `require_auth`); it has no
|
||||
// query-param fallback, and `EventSource` cannot set headers. So the
|
||||
// event stream is a plain GET with the auth header, read as a
|
||||
// ReadableStream and parsed by hand into `data:` frames — the cost of
|
||||
// keeping the token out of URLs and access logs.
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
/** What a consent asks of the human: a secret string, or an approve/
|
||||
* deny on a phone control action. */
|
||||
export type ConsentKind = 'secret' | 'phone'
|
||||
|
||||
/** One consent_request event, and one entry of `GET /v1/consents`. */
|
||||
export interface ConsentRequest {
|
||||
id: string
|
||||
kind: ConsentKind
|
||||
summary: string
|
||||
detail: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
/** The body `POST /v1/consents/{id}` takes: per kind, exactly one of
|
||||
* the two shapes. */
|
||||
export type ConsentAnswer = { secret: string } | { grant: boolean }
|
||||
|
||||
/** What an attachment's bytes are: an image to render inline, a code
|
||||
* or generic file to download. */
|
||||
export type AttachmentMedia = 'image' | 'code' | 'file'
|
||||
|
||||
/** One attachment event. */
|
||||
export interface ClawAttachment {
|
||||
id: string
|
||||
name: string
|
||||
media: AttachmentMedia
|
||||
size: number
|
||||
caption: string | null
|
||||
}
|
||||
|
||||
/** One turn event, exactly `hub.rs`'s `TurnEvent` (serde type tag, snake case). */
|
||||
export type TurnEvent =
|
||||
| { type: 'user_message'; text: string }
|
||||
| { type: 'thinking_delta'; text: string }
|
||||
| { type: 'text_delta'; text: string }
|
||||
| { type: 'tool_start'; name: string }
|
||||
| {
|
||||
type: 'tool_result'
|
||||
name: string
|
||||
is_error: boolean
|
||||
summary: string
|
||||
/** Process exit code; only command-like tools carry one. */
|
||||
code: number | null
|
||||
/** The shell command line; only the bash tool carries one. */
|
||||
command: string | null
|
||||
}
|
||||
| ({ type: 'consent_request' } & ConsentRequest)
|
||||
| { type: 'consent_resolved'; id: string; granted: boolean }
|
||||
| ({ type: 'attachment' } & ClawAttachment)
|
||||
| { type: 'turn_done'; reply: string | null; outcome: string }
|
||||
|
||||
/** One entry of `GET /v1/sessions` (http.rs `sessions`). */
|
||||
export interface ClawSession {
|
||||
key: string
|
||||
state: string
|
||||
revision: number
|
||||
inbox_depth: number
|
||||
last_outcome: string | null
|
||||
}
|
||||
|
||||
/** One watch (config.rs `Watch`, as the watches routes accept and return it). */
|
||||
/** One row of a provider's live model listing (`ModelCard` server-side). */
|
||||
export interface ModelCard {
|
||||
id: string
|
||||
display_name: string
|
||||
description: string
|
||||
input_token_limit?: number | null
|
||||
}
|
||||
|
||||
/** One account of `GET /v1/accounts` (rhema-identity's `Account`):
|
||||
* redacted by construction — the wire shape has no secret field. */
|
||||
export interface ClawAccount {
|
||||
id: string
|
||||
label: string
|
||||
service: string
|
||||
url: string | null
|
||||
username: string | null
|
||||
secret_kind: 'token' | 'password'
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
/** The payload `POST /v1/accounts` takes. The secret is write-only: it
|
||||
* is stored and never served back. */
|
||||
export interface NewAccount {
|
||||
label: string
|
||||
service: string
|
||||
url?: string
|
||||
username?: string
|
||||
secret: string
|
||||
secret_kind?: 'token' | 'password'
|
||||
/** Also follow this forge identity's notifications. */
|
||||
watch?: boolean
|
||||
}
|
||||
|
||||
/** The `POST /v1/accounts` answer: the save verdict plus whether the
|
||||
* forge watch got registered. */
|
||||
export interface SavedAccount {
|
||||
saved: boolean
|
||||
id: string
|
||||
label: string
|
||||
service: string
|
||||
secret_kind: 'token' | 'password'
|
||||
watched: boolean
|
||||
watchable_forge: boolean
|
||||
}
|
||||
|
||||
/** The `DELETE /v1/accounts/{id}` answer: whether the account existed,
|
||||
* and how many derived watches went with it. */
|
||||
export interface ForgotAccount {
|
||||
forgot: boolean
|
||||
watches_removed: number
|
||||
}
|
||||
|
||||
/** One backend instance of the providers roster: named by its `id` —
|
||||
* the kind unless a login named it — with the credential's origin. */
|
||||
export interface ClawProvider {
|
||||
/** The instance id: the kind unless the login named it. */
|
||||
id: string
|
||||
/** The backend kind the instance serves as. */
|
||||
kind: string
|
||||
default_model: string
|
||||
/** Where the credential resolves from: `env` (survives a removal
|
||||
* until restart), `stored` (this surface persisted it), `profile`
|
||||
* (the stored rhema config), or `keyless`. */
|
||||
source?: 'env' | 'stored' | 'profile' | 'keyless'
|
||||
}
|
||||
|
||||
/** One field of a kind's login form: the name the credentials body
|
||||
* spells it with, and whether the daemon refuses the login without it. */
|
||||
export interface ProviderField {
|
||||
name: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
/** One kind a runtime login can add, with the fields its form takes. */
|
||||
export interface AddableKind {
|
||||
kind: string
|
||||
fields: ProviderField[]
|
||||
}
|
||||
|
||||
export interface ProvidersState {
|
||||
/** `provider` is the active instance's id. */
|
||||
active: { provider: string; model: string }
|
||||
providers: ClawProvider[]
|
||||
addable?: AddableKind[]
|
||||
}
|
||||
|
||||
/** The agent's presentation identity as `GET /v1/agent` serves it:
|
||||
* unset text fields read as null, the images report presence. */
|
||||
export interface AgentIdentity {
|
||||
name: string | null
|
||||
mark: string | null
|
||||
initials: string | null
|
||||
tagline: string | null
|
||||
signoff: string | null
|
||||
avatar: boolean
|
||||
logo: boolean
|
||||
}
|
||||
|
||||
/** The identity's text fields, as a save carries them: an empty
|
||||
* string clears the field. */
|
||||
export interface IdentityTexts {
|
||||
name: string
|
||||
mark: string
|
||||
initials: string
|
||||
tagline: string
|
||||
signoff: string
|
||||
}
|
||||
|
||||
/** One of the two identity images. */
|
||||
export type AgentImageKind = 'avatar' | 'logo'
|
||||
|
||||
/** The credentials a provider login carries: the instance name (the
|
||||
* kind when omitted), the kind (required on an id's first login), an
|
||||
* API key (a GitHub token for copilot), and a server URL for
|
||||
* llama.cpp. Empty optionals are omitted, never sent as "" strings. */
|
||||
export interface ProviderCredentials {
|
||||
name?: string
|
||||
kind?: string
|
||||
api_key?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
/** The `PUT /v1/providers/active` answer: the new pair only — the
|
||||
* provider list is not echoed and the caller carries it forward. */
|
||||
export interface ActiveBackend {
|
||||
active: { provider: string; model: string }
|
||||
}
|
||||
|
||||
/** A failed API call: the status plus the claw's `error` string when it sent one. */
|
||||
export class ClawError extends Error {
|
||||
/** HTTP status; 0 when the claw was unreachable at all. */
|
||||
readonly status: number
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/** Lifecycle of the event stream; anything but `open` is banner-worthy. */
|
||||
export type StreamStatus = 'connecting' | 'open' | 'reconnecting'
|
||||
|
||||
const INTERACTIVE_PREFIX = 'interactive:'
|
||||
|
||||
/** Interactive keys are the only ones message ingest accepts (http.rs `messages`). */
|
||||
export function isInteractive(key: string): boolean {
|
||||
return key.startsWith(INTERACTIVE_PREFIX)
|
||||
}
|
||||
|
||||
/** The session name `POST /v1/sessions` accepts; the key becomes
|
||||
* `interactive:<name>`. Anything else is a 400. */
|
||||
export const SESSION_NAME_PATTERN = /^[A-Za-z0-9._-]{1,64}$/
|
||||
|
||||
/** The `POST /v1/sessions` answer: the new session's dispatch key. */
|
||||
export interface NewSession {
|
||||
key: string
|
||||
}
|
||||
|
||||
function base(): string {
|
||||
const config = useRuntimeConfig()
|
||||
return config.public.clawBase.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function authHeaders(token: string | null): HeadersInit {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
async function unwrap(response: Response): Promise<unknown> {
|
||||
if (response.ok) return response.json()
|
||||
let detail = `HTTP ${response.status}`
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string }
|
||||
if (typeof body.error === 'string') detail = body.error
|
||||
} catch {
|
||||
// Non-JSON error body: the status line is the message.
|
||||
}
|
||||
throw new ClawError(response.status, detail)
|
||||
}
|
||||
|
||||
async function send(path: string, init: RequestInit): Promise<unknown> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(base() + path, init)
|
||||
} catch (cause) {
|
||||
throw new ClawError(0, `claw unreachable: ${String(cause)}`)
|
||||
}
|
||||
return unwrap(response)
|
||||
}
|
||||
|
||||
function get(path: string, token: string | null): Promise<unknown> {
|
||||
return send(path, { headers: authHeaders(token) })
|
||||
}
|
||||
|
||||
/** `GET /healthz` — open even when /v1 is tokened. Resolves true when the claw answers. */
|
||||
export async function clawHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${base()}/healthz`)
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Handlers for a turn-event stream. */
|
||||
export interface StreamHandlers {
|
||||
/** One turn event, in stream order. */
|
||||
onEvent: (event: TurnEvent) => void
|
||||
/** Connection lifecycle; `reconnecting` follows a drop, with backoff. */
|
||||
onStatus?: (status: StreamStatus) => void
|
||||
/**
|
||||
* Best-effort delivery notices from the stream itself — the hub's
|
||||
* `lagged` comment (events dropped) and drop/retry chatter. Never
|
||||
* turn events, never silent.
|
||||
*/
|
||||
onNotice?: (notice: string) => void
|
||||
}
|
||||
|
||||
export interface ClawApi {
|
||||
/** `GET /v1/sessions` — every dispatch key with its status. */
|
||||
sessions(): Promise<ClawSession[]>
|
||||
/** `POST /v1/sessions` — create an interactive session from a name;
|
||||
* 400 for a name off `SESSION_NAME_PATTERN`, 409 when it exists. */
|
||||
createSession(name: string): Promise<NewSession>
|
||||
/** `GET /v1/consents` — the pending consents, oldest first. */
|
||||
listConsents(): Promise<ConsentRequest[]>
|
||||
/** `POST /v1/consents/{id}` — answer one consent; 409 when already
|
||||
* resolved, 422 for an empty secret. */
|
||||
resolveConsent(id: string, answer: ConsentAnswer): Promise<void>
|
||||
/** `DELETE /v1/consents/{id}` — deny or cancel a consent. */
|
||||
denyConsent(id: string): Promise<void>
|
||||
/** `GET /v1/attachments/{id}` — the attachment's bytes as an object
|
||||
* URL for an `<img>` or a download (the route is bearer-guarded, so
|
||||
* a plain src will not do). Cached per id: attachments are immutable,
|
||||
* so a replayed event never refetches. */
|
||||
attachmentUrl(id: string): Promise<string>
|
||||
/** `POST /v1/sessions/{key}/messages` — fire-and-forget: the claw answers 202 and the reply arrives on the event stream. */
|
||||
sendMessage(key: string, text: string): Promise<void>
|
||||
/** `GET /v1/providers` — the constructible backends and the active pair. */
|
||||
providers(): Promise<ProvidersState>
|
||||
/** `PUT /v1/providers/active` — switch the backend, persisted claw-side. Answers the new active pair only. */
|
||||
setActive(provider: string, model?: string): Promise<ActiveBackend>
|
||||
/** `GET /v1/providers/{id}/models` — the live listing; 404 when none. */
|
||||
models(id: string): Promise<ModelCard[]>
|
||||
/** `PUT /v1/providers/{id}/credentials` — add a backend instance (or
|
||||
* replace an id's credentials) at runtime; answers the new providers
|
||||
* state. */
|
||||
setCredentials(id: string, credentials: ProviderCredentials): Promise<ProvidersState>
|
||||
/** `DELETE /v1/providers/{id}` — remove a backend instance; answers
|
||||
* the new providers state. The last backend is refused claw-side. */
|
||||
removeProvider(id: string): Promise<ProvidersState>
|
||||
/** `GET /v1/accounts` — the stored accounts, redacted. */
|
||||
accounts(): Promise<ClawAccount[]>
|
||||
/** `POST /v1/accounts` — store an account (the secret is write-only),
|
||||
* optionally registering its forge notifications watch. */
|
||||
addAccount(account: NewAccount): Promise<SavedAccount>
|
||||
/** `DELETE /v1/accounts/{id}` — forget an account; its derived
|
||||
* watches go with it. */
|
||||
removeAccount(id: string): Promise<ForgotAccount>
|
||||
/** `GET /v1/agent` — the agent's presentation identity. */
|
||||
agent(): Promise<AgentIdentity>
|
||||
/** `PUT /v1/agent` — save the named text fields (empty clears);
|
||||
* fields left out are untouched claw-side. */
|
||||
setAgent(texts: Partial<IdentityTexts>): Promise<AgentIdentity>
|
||||
/** `PUT /v1/agent/{kind}` — upload the avatar or logo from a file.
|
||||
* The claw refuses non-images and bodies over 2 MiB. */
|
||||
setAgentImage(kind: AgentImageKind, file: File): Promise<AgentIdentity>
|
||||
/** `DELETE /v1/agent/{kind}` — drop the image. */
|
||||
clearAgentImage(kind: AgentImageKind): Promise<AgentIdentity>
|
||||
/** `GET /v1/agent/{kind}` — the image as an object URL for an
|
||||
* `<img>` (the route is bearer-guarded, so a plain src will not do).
|
||||
* The caller revokes the URL when it replaces it. */
|
||||
agentImage(kind: AgentImageKind): Promise<string>
|
||||
/**
|
||||
* Stream one session's turns. Resolves with a handle once the fetch
|
||||
* is started; the claw keeps the stream open forever, and a drop is
|
||||
* retried with backoff until `close()` is called. Every (re)connection
|
||||
* opens with the session's recorded turns replayed first, then the
|
||||
* live feed — so a consumer resets its mirror on reconnect and lets
|
||||
* the stream rebuild it.
|
||||
*/
|
||||
subscribeEvents(key: string, handlers: StreamHandlers): { close: () => void }
|
||||
}
|
||||
|
||||
/** Build the client bound to one bearer token (null = unguarded claw). */
|
||||
export function useClaw(token: Ref<string | null>): ClawApi {
|
||||
/** Object URLs already minted for attachments, by id. Attachments are
|
||||
* immutable, so the URLs live as long as the page does. */
|
||||
const attachmentUrls = new Map<string, string>()
|
||||
|
||||
/** The bytes behind a bearer-guarded route, as a blob. */
|
||||
async function fetchBlob(path: string): Promise<Blob> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${base()}${path}`, {
|
||||
headers: authHeaders(token.value),
|
||||
})
|
||||
} catch (cause) {
|
||||
throw new ClawError(0, `claw unreachable: ${String(cause)}`)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ClawError(response.status, `HTTP ${response.status}`)
|
||||
}
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
async function sessions(): Promise<ClawSession[]> {
|
||||
return (await get('/v1/sessions', token.value)) as ClawSession[]
|
||||
}
|
||||
|
||||
async function createSession(name: string): Promise<NewSession> {
|
||||
return (await send('/v1/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token.value) },
|
||||
body: JSON.stringify({ name }),
|
||||
})) as NewSession
|
||||
}
|
||||
|
||||
async function listConsents(): Promise<ConsentRequest[]> {
|
||||
const body = (await get('/v1/consents', token.value)) as { consents: ConsentRequest[] }
|
||||
return body.consents
|
||||
}
|
||||
|
||||
async function resolveConsent(id: string, answer: ConsentAnswer): Promise<void> {
|
||||
await send(`/v1/consents/${encodeURIComponent(id)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token.value) },
|
||||
body: JSON.stringify(answer),
|
||||
})
|
||||
}
|
||||
|
||||
async function denyConsent(id: string): Promise<void> {
|
||||
await send(`/v1/consents/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token.value),
|
||||
})
|
||||
}
|
||||
|
||||
async function sendMessage(key: string, text: string): Promise<void> {
|
||||
await send(`/v1/sessions/${encodeURIComponent(key)}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token.value) },
|
||||
body: JSON.stringify({ text }),
|
||||
})
|
||||
}
|
||||
|
||||
async function providers(): Promise<ProvidersState> {
|
||||
return (await get('/v1/providers', token.value)) as ProvidersState
|
||||
}
|
||||
|
||||
async function setActive(provider: string, model?: string): Promise<ActiveBackend> {
|
||||
return (await send('/v1/providers/active', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token.value) },
|
||||
body: JSON.stringify({ provider, ...(model ? { model } : {}) }),
|
||||
})) as ActiveBackend
|
||||
}
|
||||
|
||||
async function models(id: string): Promise<ModelCard[]> {
|
||||
const body = (await get(`/v1/providers/${encodeURIComponent(id)}/models`, token.value)) as {
|
||||
models: ModelCard[]
|
||||
}
|
||||
return body.models
|
||||
}
|
||||
|
||||
async function setCredentials(id: string, credentials: ProviderCredentials): Promise<ProvidersState> {
|
||||
return (await send(`/v1/providers/${encodeURIComponent(id)}/credentials`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token.value) },
|
||||
body: JSON.stringify(credentials),
|
||||
})) as ProvidersState
|
||||
}
|
||||
|
||||
async function removeProvider(id: string): Promise<ProvidersState> {
|
||||
return (await send(`/v1/providers/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token.value),
|
||||
})) as ProvidersState
|
||||
}
|
||||
|
||||
async function accounts(): Promise<ClawAccount[]> {
|
||||
const body = (await get('/v1/accounts', token.value)) as { accounts: ClawAccount[] }
|
||||
return body.accounts
|
||||
}
|
||||
|
||||
async function addAccount(account: NewAccount): Promise<SavedAccount> {
|
||||
return (await send('/v1/accounts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token.value) },
|
||||
body: JSON.stringify(account),
|
||||
})) as SavedAccount
|
||||
}
|
||||
|
||||
async function removeAccount(id: string): Promise<ForgotAccount> {
|
||||
return (await send(`/v1/accounts/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token.value),
|
||||
})) as ForgotAccount
|
||||
}
|
||||
|
||||
async function agent(): Promise<AgentIdentity> {
|
||||
return (await get('/v1/agent', token.value)) as AgentIdentity
|
||||
}
|
||||
|
||||
async function setAgent(texts: Partial<IdentityTexts>): Promise<AgentIdentity> {
|
||||
return (await send('/v1/agent', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token.value) },
|
||||
body: JSON.stringify(texts),
|
||||
})) as AgentIdentity
|
||||
}
|
||||
|
||||
async function setAgentImage(kind: AgentImageKind, file: File): Promise<AgentIdentity> {
|
||||
return (await send(`/v1/agent/${kind}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': file.type || 'application/octet-stream', ...authHeaders(token.value) },
|
||||
body: file,
|
||||
})) as AgentIdentity
|
||||
}
|
||||
|
||||
async function clearAgentImage(kind: AgentImageKind): Promise<AgentIdentity> {
|
||||
return (await send(`/v1/agent/${kind}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(token.value),
|
||||
})) as AgentIdentity
|
||||
}
|
||||
|
||||
async function agentImage(kind: AgentImageKind): Promise<string> {
|
||||
return URL.createObjectURL(await fetchBlob(`/v1/agent/${kind}`))
|
||||
}
|
||||
|
||||
async function attachmentUrl(id: string): Promise<string> {
|
||||
const cached = attachmentUrls.get(id)
|
||||
if (cached) return cached
|
||||
const url = URL.createObjectURL(await fetchBlob(`/v1/attachments/${encodeURIComponent(id)}`))
|
||||
attachmentUrls.set(id, url)
|
||||
return url
|
||||
}
|
||||
|
||||
function subscribeEvents(key: string, handlers: StreamHandlers): { close: () => void } {
|
||||
const controller = new AbortController()
|
||||
let closed = false
|
||||
let attempt = 0
|
||||
|
||||
const status = (next: StreamStatus) => {
|
||||
if (!closed) handlers.onStatus?.(next)
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
while (!closed) {
|
||||
status(attempt === 0 ? 'connecting' : 'reconnecting')
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${base()}/v1/sessions/${encodeURIComponent(key)}/events`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
...authHeaders(token.value),
|
||||
},
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
if (!response.ok || !response.body) {
|
||||
throw new ClawError(response.status, `HTTP ${response.status}`)
|
||||
}
|
||||
attempt = 0
|
||||
status('open')
|
||||
await readFrames(
|
||||
response.body,
|
||||
(data) => {
|
||||
const event = parseTurnEvent(data)
|
||||
if (event) handlers.onEvent(event)
|
||||
},
|
||||
(comment) => {
|
||||
// Keep-alives come through here too; only the hub's
|
||||
// dropped-events notice is worth raising.
|
||||
if (comment.startsWith('lagged')) {
|
||||
handlers.onNotice?.(`stream lagged — ${comment.replace(/^lagged;\s*/, '')}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (closed) break
|
||||
throw new ClawError(0, 'stream closed by the claw')
|
||||
} catch (cause) {
|
||||
if (closed || controller.signal.aborted) break
|
||||
// Backoff caps at 15s: a live stream of a running turn is the
|
||||
// UI's whole job, so retries stay eager but not tight-looped.
|
||||
const backoff = Math.min(15000, 1000 * 2 ** attempt++)
|
||||
handlers.onNotice?.(
|
||||
`event stream dropped (${cause instanceof Error ? cause.message : String(cause)}); retrying in ${backoff / 1000}s`,
|
||||
)
|
||||
status('reconnecting')
|
||||
await new Promise((resolve) => setTimeout(resolve, backoff))
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
close() {
|
||||
closed = true
|
||||
controller.abort()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions,
|
||||
createSession,
|
||||
listConsents,
|
||||
resolveConsent,
|
||||
denyConsent,
|
||||
sendMessage,
|
||||
providers,
|
||||
setActive,
|
||||
models,
|
||||
setCredentials,
|
||||
removeProvider,
|
||||
accounts,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
agent,
|
||||
setAgent,
|
||||
setAgentImage,
|
||||
clearAgentImage,
|
||||
agentImage,
|
||||
attachmentUrl,
|
||||
subscribeEvents,
|
||||
}
|
||||
}
|
||||
|
||||
/** One parsed SSE frame: the joined `data:` payload, comments excluded. */
|
||||
type FrameHandler = (data: string) => void
|
||||
|
||||
/**
|
||||
* Reads an SSE body into frames. Frames split on a blank line; `data:`
|
||||
* lines carry payload (joined with newlines) and `:`-prefixed lines are
|
||||
* comments (the claw's keep-alive and its lag notice). `event:`/`id:`
|
||||
* lines do not occur on this surface.
|
||||
*/
|
||||
async function readFrames(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
onFrame: FrameHandler,
|
||||
onComment: (comment: string) => void,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
const step = (frame: string) => {
|
||||
const data = frame
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.replace(/^data: ?/, ''))
|
||||
.join('\n')
|
||||
if (data) {
|
||||
onFrame(data)
|
||||
return
|
||||
}
|
||||
const comment = frame
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith(':'))
|
||||
.map((line) => line.slice(1).trimStart())
|
||||
.join('\n')
|
||||
if (comment) onComment(comment)
|
||||
}
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let boundary = buffer.indexOf('\n\n')
|
||||
while (boundary !== -1) {
|
||||
step(buffer.slice(0, boundary))
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
boundary = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses one `data:` payload into a `TurnEvent`; unknown shapes are dropped, not guessed. */
|
||||
function parseTurnEvent(data: string): TurnEvent | null {
|
||||
try {
|
||||
const raw = JSON.parse(data) as Record<string, unknown>
|
||||
switch (raw.type) {
|
||||
case 'user_message':
|
||||
case 'thinking_delta':
|
||||
case 'text_delta':
|
||||
return { type: raw.type, text: String(raw.text ?? '') }
|
||||
case 'tool_start':
|
||||
return { type: 'tool_start', name: String(raw.name ?? '') }
|
||||
case 'tool_result':
|
||||
return {
|
||||
type: 'tool_result',
|
||||
name: String(raw.name ?? ''),
|
||||
is_error: Boolean(raw.is_error),
|
||||
summary: String(raw.summary ?? ''),
|
||||
code: typeof raw.code === 'number' ? raw.code : null,
|
||||
command: typeof raw.command === 'string' ? raw.command : null,
|
||||
}
|
||||
case 'consent_request': {
|
||||
// A consent of an unknown kind is an unknown shape: dropped,
|
||||
// not guessed.
|
||||
if (raw.kind !== 'secret' && raw.kind !== 'phone') return null
|
||||
return {
|
||||
type: 'consent_request',
|
||||
id: String(raw.id ?? ''),
|
||||
kind: raw.kind,
|
||||
summary: String(raw.summary ?? ''),
|
||||
detail: String(raw.detail ?? ''),
|
||||
created_at: typeof raw.created_at === 'number' ? raw.created_at : 0,
|
||||
}
|
||||
}
|
||||
case 'consent_resolved':
|
||||
return {
|
||||
type: 'consent_resolved',
|
||||
id: String(raw.id ?? ''),
|
||||
granted: Boolean(raw.granted),
|
||||
}
|
||||
case 'attachment': {
|
||||
// An attachment of an unknown media kind is an unknown shape:
|
||||
// dropped, not guessed.
|
||||
if (raw.media !== 'image' && raw.media !== 'code' && raw.media !== 'file') return null
|
||||
return {
|
||||
type: 'attachment',
|
||||
id: String(raw.id ?? ''),
|
||||
name: String(raw.name ?? ''),
|
||||
media: raw.media,
|
||||
size: typeof raw.size === 'number' ? raw.size : 0,
|
||||
caption: typeof raw.caption === 'string' ? raw.caption : null,
|
||||
}
|
||||
}
|
||||
case 'turn_done':
|
||||
return {
|
||||
type: 'turn_done',
|
||||
reply: typeof raw.reply === 'string' ? raw.reply : null,
|
||||
outcome: String(raw.outcome ?? ''),
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// The client-side mirror of a claw session's turns. The daemon owns the
|
||||
// conversation — this composable only accumulates what the event stream
|
||||
// delivers, and the stream now opens with the session's recorded turns
|
||||
// replayed before the live feed. So a reload (and every reconnect)
|
||||
// rebuilds the transcript from the replay alone: the consumer resets the
|
||||
// mirror when the stream reopens and folds what arrives, never the other
|
||||
// way around.
|
||||
import { ref } from 'vue'
|
||||
import type { AttachmentMedia, ConsentKind, TurnEvent } from './useClaw'
|
||||
|
||||
/** One tool call inside a turn: started, then completed by its result. */
|
||||
export interface ToolRow {
|
||||
name: string
|
||||
/** null until the result lands. */
|
||||
isError: boolean | null
|
||||
summary: string
|
||||
/** The process exit code a command-like result carries. */
|
||||
code: number | null
|
||||
/** The command line a bash result carries. */
|
||||
command: string | null
|
||||
}
|
||||
|
||||
/** One stream-ordered thing inside a turn: a tool call, the agent's ask
|
||||
* (a consent card), or a handover (an attachment). The turn renders its
|
||||
* entries in stream order, so a card sits where the event that raised
|
||||
* it happened — an ask appears at its tool call, not at the turn's top. */
|
||||
export type TurnEntry =
|
||||
| { kind: 'tool'; row: ToolRow }
|
||||
| { kind: 'consent'; consent: ConsentCard }
|
||||
| { kind: 'attachment'; attachment: AttachmentCard }
|
||||
|
||||
/** One turn as the UI renders it: thinking trace, the stream-ordered
|
||||
* entries, the streamed reply. */
|
||||
export interface TurnBlock {
|
||||
thinking: string
|
||||
reply: string
|
||||
entries: TurnEntry[]
|
||||
/** Set by `turn_done`: `handled`, `failed`, or whatever the claw reports. */
|
||||
outcome: string | null
|
||||
}
|
||||
|
||||
/** One thing the agent asked its human, as the card renders it. */
|
||||
export interface ConsentCard {
|
||||
id: string
|
||||
kind: ConsentKind
|
||||
summary: string
|
||||
detail: string
|
||||
/** null while the human has not answered. */
|
||||
granted: boolean | null
|
||||
}
|
||||
|
||||
/** One file the agent handed over, as the card renders it. The bytes
|
||||
* arrive as an object URL on request — the route is bearer-guarded. */
|
||||
export interface AttachmentCard {
|
||||
id: string
|
||||
name: string
|
||||
media: AttachmentMedia
|
||||
size: number
|
||||
caption: string | null
|
||||
/** The object URL once fetched; null until then. */
|
||||
url: string | null
|
||||
}
|
||||
|
||||
/** A transcript line: a message the local user sent, or one streamed turn. */
|
||||
export type TranscriptItem =
|
||||
| { kind: 'sent'; id: number; text: string }
|
||||
| { kind: 'turn'; id: number; turn: TurnBlock }
|
||||
| { kind: 'consent'; id: number; consent: ConsentCard }
|
||||
| { kind: 'attachment'; id: number; attachment: AttachmentCard }
|
||||
|
||||
// --- embedded pastes --------------------------------------------------
|
||||
// The wire form a long paste travels in: MARK, payload, MARK — one
|
||||
// logical unit inside otherwise plain text (rhema-core's paste module
|
||||
// is the definition; these are its client-side shape).
|
||||
|
||||
/** The object-replacement character: opens and closes one embedded
|
||||
* paste. Two marks always bracket a payload; a lone mark is literal. */
|
||||
export const PASTE_MARK = ''
|
||||
|
||||
/** A paste at or under this many lines joins the input inline; longer
|
||||
* ones become chips (the TUI's PASTE_INLINE_LINES). */
|
||||
export const PASTE_INLINE_LINES = 3
|
||||
|
||||
/** Text that travels beside or inside marks: carriage returns to
|
||||
* newlines, stray marks dropped — the scan must stay unambiguous
|
||||
* (paste::normalize). */
|
||||
export function normalizePaste(text: string): string {
|
||||
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').replaceAll(PASTE_MARK, '')
|
||||
}
|
||||
|
||||
/** Rust `str::lines`: \n-split, and a trailing newline ends the last
|
||||
* line instead of opening an empty one. */
|
||||
export function lineCount(text: string): number {
|
||||
if (text === '') return 0
|
||||
const lines = text.split('\n')
|
||||
if (lines[lines.length - 1] === '') lines.pop()
|
||||
return lines.length
|
||||
}
|
||||
|
||||
/** A chip's label: `▣1 5L` — the anchor's one-based id and the
|
||||
* payload's line count (the TUI's chip_label). */
|
||||
export function chipLabel(paste: string, index: number): string {
|
||||
return `▣${index + 1} ${lineCount(paste)}L`
|
||||
}
|
||||
|
||||
/** Appends each paste as one MARK-payload-MARK embed, in order — the
|
||||
* session's record of the prompt (paste::embed; a web draft holds no
|
||||
* anchors, so every payload lands at the end). */
|
||||
export function embedPastes(draft: string, pastes: string[]): string {
|
||||
return (
|
||||
draft +
|
||||
pastes.map((paste) => `${PASTE_MARK}${normalizePaste(paste)}${PASTE_MARK}`).join('')
|
||||
)
|
||||
}
|
||||
|
||||
/** One scanned piece of embedded-form text. */
|
||||
export type PastePart = { kind: 'text'; text: string } | { kind: 'paste'; text: string }
|
||||
|
||||
/** Splits embedded-form text into its parts, left to right. A mark
|
||||
* with no partner is literal text; malformed input never throws
|
||||
* (paste::parts). */
|
||||
export function pasteParts(text: string): PastePart[] {
|
||||
const parts: PastePart[] = []
|
||||
let rest = text
|
||||
for (;;) {
|
||||
const start = rest.indexOf(PASTE_MARK)
|
||||
if (start === -1) break
|
||||
const after = rest.slice(start + PASTE_MARK.length)
|
||||
const end = after.indexOf(PASTE_MARK)
|
||||
if (end === -1) {
|
||||
parts.push({ kind: 'text', text: rest.slice(0, start + PASTE_MARK.length) })
|
||||
rest = after
|
||||
continue
|
||||
}
|
||||
if (start > 0) parts.push({ kind: 'text', text: rest.slice(0, start) })
|
||||
parts.push({ kind: 'paste', text: after.slice(0, end) })
|
||||
rest = after.slice(end + PASTE_MARK.length)
|
||||
}
|
||||
if (rest !== '') parts.push({ kind: 'text', text: rest })
|
||||
return parts
|
||||
}
|
||||
|
||||
export function useTranscript() {
|
||||
const items = ref<TranscriptItem[]>([])
|
||||
// A deep ref on purpose: deltas mutate fields in place (reply
|
||||
// appends, tool rows completing) and each must re-render.
|
||||
const live = ref<TurnBlock | null>(null)
|
||||
let nextId = 1
|
||||
|
||||
function ensureLive(): TurnBlock {
|
||||
if (!live.value) live.value = { thinking: '', reply: '', entries: [], outcome: null }
|
||||
return live.value
|
||||
}
|
||||
|
||||
/** A detached copy of one entry: the record of a finished turn must
|
||||
* not alias the live slot's objects. */
|
||||
function cloneEntry(entry: TurnEntry): TurnEntry {
|
||||
if (entry.kind === 'tool') return { kind: 'tool', row: { ...entry.row } }
|
||||
if (entry.kind === 'consent') return { kind: 'consent', consent: { ...entry.consent } }
|
||||
return { kind: 'attachment', attachment: { ...entry.attachment } }
|
||||
}
|
||||
|
||||
/** Folds one stream event into the mirror. */
|
||||
function applyEvent(event: TurnEvent): void {
|
||||
switch (event.type) {
|
||||
case 'user_message': {
|
||||
// The replayed question. A live echo of a message this surface
|
||||
// just sent arrives after the optimistic `sent` row: identical
|
||||
// text as the last item means the row already stands in for it.
|
||||
const last = items.value[items.value.length - 1]
|
||||
if (last?.kind === 'sent' && last.text === event.text) return
|
||||
items.value.push({ kind: 'sent', id: nextId++, text: event.text })
|
||||
break
|
||||
}
|
||||
case 'thinking_delta':
|
||||
ensureLive().thinking += event.text
|
||||
break
|
||||
case 'text_delta':
|
||||
ensureLive().reply += event.text
|
||||
break
|
||||
case 'tool_start':
|
||||
ensureLive().entries.push({
|
||||
kind: 'tool',
|
||||
row: { name: event.name, isError: null, summary: '', code: null, command: null },
|
||||
})
|
||||
break
|
||||
case 'tool_result': {
|
||||
const entries = ensureLive().entries
|
||||
// Results pair with the most recent still-running call of the
|
||||
// same name; a result with no start still shows, never drops.
|
||||
const pending = [...entries]
|
||||
.reverse()
|
||||
.find((entry) => entry.kind === 'tool' && entry.row.name === event.name && entry.row.isError === null)
|
||||
if (pending && pending.kind === 'tool') {
|
||||
pending.row.isError = event.is_error
|
||||
pending.row.summary = event.summary
|
||||
pending.row.code = event.code
|
||||
pending.row.command = event.command
|
||||
} else {
|
||||
entries.push({
|
||||
kind: 'tool',
|
||||
row: {
|
||||
name: event.name,
|
||||
isError: event.is_error,
|
||||
summary: event.summary,
|
||||
code: event.code,
|
||||
command: event.command,
|
||||
},
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'consent_request':
|
||||
// One card per consent id, wherever the turn stands: mid-turn
|
||||
// the card joins the entries at its tool call; a card with no
|
||||
// turn around it (a defensive shape — asks fire mid-turn)
|
||||
// falls back to a top-level row.
|
||||
if (!findConsent(event.id)) {
|
||||
const card: ConsentCard = {
|
||||
id: event.id,
|
||||
kind: event.kind,
|
||||
summary: event.summary,
|
||||
detail: event.detail,
|
||||
granted: null,
|
||||
}
|
||||
if (live.value) {
|
||||
live.value.entries.push({ kind: 'consent', consent: card })
|
||||
} else {
|
||||
items.value.push({ kind: 'consent', id: nextId++, consent: card })
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'consent_resolved':
|
||||
resolveConsent(event.id, event.granted)
|
||||
break
|
||||
case 'attachment':
|
||||
// One card per attachment id; the same dual placement as an
|
||||
// ask — mid-turn it rides the entries.
|
||||
if (!findAttachment(event.id)) {
|
||||
const card: AttachmentCard = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
media: event.media,
|
||||
size: event.size,
|
||||
caption: event.caption,
|
||||
url: null,
|
||||
}
|
||||
if (live.value) {
|
||||
live.value.entries.push({ kind: 'attachment', attachment: card })
|
||||
} else {
|
||||
items.value.push({ kind: 'attachment', id: nextId++, attachment: card })
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'turn_done': {
|
||||
const turn = ensureLive()
|
||||
turn.outcome = event.outcome
|
||||
// The clean end marker wins for the reply: deltas may have been
|
||||
// dropped (best-effort stream), turn_done carries the final text.
|
||||
if (event.reply !== null) turn.reply = event.reply
|
||||
// A detached copy: the live slot resets and the record is final.
|
||||
items.value.push({
|
||||
kind: 'turn',
|
||||
id: nextId++,
|
||||
turn: { ...turn, entries: turn.entries.map(cloneEntry) },
|
||||
})
|
||||
live.value = null
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A locally sent message, shown immediately; the reply streams after. */
|
||||
function addSent(text: string): void {
|
||||
items.value.push({ kind: 'sent', id: nextId++, text })
|
||||
}
|
||||
|
||||
/** A consent card resolved: flipped in place, or ignored when no
|
||||
* card carries the id. The in-flight turn's entries hold the cards a
|
||||
* running turn asked for; finished turns keep their own copies. */
|
||||
function resolveConsent(id: string, granted: boolean): void {
|
||||
if (live.value) {
|
||||
for (const entry of live.value.entries) {
|
||||
if (entry.kind === 'consent' && entry.consent.id === id) {
|
||||
entry.consent.granted = granted
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of items.value) {
|
||||
if (item.kind === 'consent' && item.consent.id === id) {
|
||||
item.consent.granted = granted
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An attachment's object URL landed; stored on its card. The useClaw
|
||||
* cache keeps a reconnect's replay from refetching the bytes. */
|
||||
function setAttachmentUrl(id: string, url: string): void {
|
||||
if (live.value) {
|
||||
for (const entry of live.value.entries) {
|
||||
if (entry.kind === 'attachment' && entry.attachment.id === id) {
|
||||
entry.attachment.url = url
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of items.value) {
|
||||
if (item.kind === 'attachment' && item.attachment.id === id) {
|
||||
item.attachment.url = url
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a card for this consent id is already on the mirror — a
|
||||
* replayed request never stacks a second card beside the first. */
|
||||
function findConsent(id: string): boolean {
|
||||
if (live.value?.entries.some((e) => e.kind === 'consent' && e.consent.id === id)) return true
|
||||
return items.value.some((item) => item.kind === 'consent' && item.consent.id === id)
|
||||
}
|
||||
|
||||
/** Whether a card for this attachment id is already on the mirror. */
|
||||
function findAttachment(id: string): boolean {
|
||||
if (live.value?.entries.some((e) => e.kind === 'attachment' && e.attachment.id === id)) {
|
||||
return true
|
||||
}
|
||||
return items.value.some((item) => item.kind === 'attachment' && item.attachment.id === id)
|
||||
}
|
||||
|
||||
/** Session switched, or the stream reopened: the mirror is per-key,
|
||||
* and the replay that follows rebuilds it either way. */
|
||||
function reset(): void {
|
||||
items.value = []
|
||||
live.value = null
|
||||
}
|
||||
|
||||
return { items, live, applyEvent, addSent, resolveConsent, setAttachmentUrl, reset }
|
||||
}
|
||||
Reference in new issue
Block a user