A reload of a weeks-old session replayed the whole record before the first pixel, and every reconnect reset the mirror and replayed it again. The stream now names each frame's seq and the client keeps it, so a retry sends since=<seq> and receives only the gap, and the mirror is left alone: its items are still true. A first open with no cursor gets the tail, cut to a turn boundary, which paints the latest conversation at once. The turns above it come from the history route, one whole turn per page, fetched when the reader nears the top or when the transcript is too short to fill the screen; a page folds through the same reducer as the live stream and joins above, and the scroll position moves down by the page's height so the reader keeps their place. A reset comment (the record no longer holds the cursor) drops the mirror; the tail that follows rebuilds it. The dev proxy rebuilt the upstream URL from the pathname alone, so since and before never reached the daemon under nuxt dev: every resume was a full replay and every history page was the tail. It forwards the query string now.
792 lines
24 KiB
Vue
792 lines
24 KiB
Vue
<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'
|
|
import type { StagedImage } 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,
|
|
loadingOlder,
|
|
prependCount,
|
|
} = transcript
|
|
|
|
// Whether the record still holds turns above what the mirror shows.
|
|
// The stream opens on the newest turn and the reader walks back page
|
|
// by page (ChatPanel asks when the scroller nears its top, or when the
|
|
// transcript is too short to fill the screen), so a session that ran
|
|
// for weeks costs nothing up front and still reaches its first message.
|
|
const olderAvailable = ref(true)
|
|
|
|
// 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.
|
|
// A replayed question's images fetch the same way — the stream carries
|
|
// their attachment ids, never the bytes; locally sent rows hold their
|
|
// own object URLs.
|
|
//
|
|
// The watch is deep (rows mutate in place as their bytes land), and it
|
|
// fires on every streamed delta, so the scan covers only the items the
|
|
// last pass did not see — the transcript only ever appends.
|
|
let scannedItems = 0
|
|
|
|
watch(
|
|
transcriptItems,
|
|
(items) => {
|
|
for (const item of items.slice(scannedItems)) {
|
|
if (item.kind === 'attachment') {
|
|
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))
|
|
} else if (item.kind === 'sent') {
|
|
for (const image of item.images) {
|
|
const { id, url } = image
|
|
if (id === null || url !== null || attemptedAttachments.has(id)) continue
|
|
attemptedAttachments.add(id)
|
|
claw
|
|
.attachmentUrl(id)
|
|
.then((fetched) => transcript.setSentImageUrl(id, fetched))
|
|
.catch((err) => noteApiError(err))
|
|
}
|
|
}
|
|
}
|
|
scannedItems = items.length
|
|
},
|
|
{ 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(message: { text: string; images: StagedImage[] }): Promise<void> {
|
|
// The optimistic row renders the staged previews; the stream's
|
|
// user_message echo carries the stored ids and dedupes against it.
|
|
const rowId = transcript.addSent(
|
|
message.text,
|
|
message.images.map(({ media_type, url, name }) => ({ id: null, media_type, url, name })),
|
|
)
|
|
busy.value = true
|
|
try {
|
|
// Fire-and-forget by design: the claw answers 202 and the reply
|
|
// arrives on the event stream. The body carries the images only
|
|
// when there are some.
|
|
await claw.sendMessage(
|
|
activeKey.value,
|
|
message.text,
|
|
message.images.map(({ media_type, data }) => ({ media_type, data })),
|
|
)
|
|
apiError.value = null
|
|
} catch (err) {
|
|
// The claw never took the message: retract the optimistic row, or
|
|
// it would sit there forever waiting for a reply that cannot come.
|
|
transcript.removeSent(rowId)
|
|
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; everything the 401 froze reloads now — the
|
|
// polls confirm the token works, and the identity and accounts pages
|
|
// were left empty by the failed fetches that raised the banner.
|
|
void checkHealth()
|
|
void refreshProviders()
|
|
void refreshSessions()
|
|
void refreshIdentity()
|
|
void refreshAccounts()
|
|
}
|
|
|
|
// --- the one live subscription ---------------------------------------
|
|
let stream: { close: () => void } | null = null
|
|
|
|
function resubscribe(): void {
|
|
stream?.close()
|
|
transcript.reset()
|
|
olderAvailable.value = true
|
|
attemptedAttachments.clear()
|
|
notices.value = []
|
|
streamStatus.value = 'connecting'
|
|
stream = claw.subscribeEvents(activeKey.value, {
|
|
onEvent: (event, seq) => transcript.applyEvent(event, seq),
|
|
// The record lost what this mirror holds (a wiped or reset
|
|
// database): drop it, and the tail the claw sends next rebuilds it.
|
|
onReset: () => {
|
|
transcript.reset()
|
|
olderAvailable.value = true
|
|
attemptedAttachments.clear()
|
|
scannedItems = 0
|
|
},
|
|
onStatus: (status) => {
|
|
streamStatus.value = status
|
|
// A retry resumes from the last seq this mirror holds, so the
|
|
// claw sends the gap and nothing else: the mirror stays as it
|
|
// is. Only the attachment set is cleared — a fetch that failed
|
|
// during the outage should retry against the recovered stream.
|
|
if (status === 'reconnecting') {
|
|
attemptedAttachments.clear()
|
|
scannedItems = 0
|
|
}
|
|
if (status === 'open') void refreshSessions()
|
|
},
|
|
onNotice: pushNotice,
|
|
})
|
|
}
|
|
|
|
/** Fetches one page of turns older than the mirror's first and stacks
|
|
* it above. Whole turns only, so the page never duplicates or skips:
|
|
* the daemon ends it exactly where the mirror begins. */
|
|
async function loadOlder(): Promise<void> {
|
|
const before = transcript.firstSeq.value
|
|
if (before === null || !olderAvailable.value || transcript.loadingOlder.value) return
|
|
transcript.loadingOlder.value = true
|
|
const key = activeKey.value
|
|
try {
|
|
const page = await claw.history(key, before)
|
|
// The reader may have switched sessions while the page was in
|
|
// flight; a page belongs to the mirror it was asked for.
|
|
if (key !== activeKey.value) return
|
|
transcript.prependOlder(page.events)
|
|
olderAvailable.value = page.more
|
|
} catch (err) {
|
|
noteApiError(err)
|
|
} finally {
|
|
transcript.loadingOlder.value = false
|
|
}
|
|
}
|
|
|
|
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"
|
|
:loading-older="loadingOlder"
|
|
:older-available="olderAvailable"
|
|
:prepend-count="prependCount"
|
|
:identity="identity"
|
|
:avatar-url="identityAvatarUrl"
|
|
@older="loadOlder"
|
|
@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>
|