feat(web): attach images from the composer
The (+) door and the clipboard door both stage files as removable chips, the claw's own budget enforced client-side (eight a message, 5 MiB decoded each, errors surfaced, never silent drops). The sent row renders local previews at once; the stream's user_message echo carries the stored ids and dedupes against it, so a replayed message renders its thumbnails through the same bearer-guarded fetch as an attachment card. Model options mark vision when the listing knows.
This commit is contained in:
1 parent
cfb349508f
commit
6efb69331d
6 files changed
+388
-31
No files matched your search
+12
-1
@@ -91,6 +91,14 @@ Two consequences worth knowing before changing this code:
|
|||||||
like the identity images) and render capped at 240px; code and file
|
like the identity images) and render capped at 240px; code and file
|
||||||
cards download on click. Object URLs cache per id, so a replay never
|
cards download on click. Object URLs cache per id, so a replay never
|
||||||
refetches.
|
refetches.
|
||||||
|
- **Your images ride along.** The composer's `+` — and an image on the
|
||||||
|
clipboard — stage as removable chips (`▣1 photo.png 1.2 MB`), the
|
||||||
|
claw's own budget enforced client-side (8 a message, 5 MiB decoded
|
||||||
|
each; the draft stays required, the route 422s an empty text). The
|
||||||
|
sent row renders the local previews at once; the stream's
|
||||||
|
`user_message` echo carries the stored attachment ids and dedupes
|
||||||
|
against that row, so a replayed message renders its own thumbnails
|
||||||
|
through the same bearer-guarded fetch as an attachment card.
|
||||||
- **Sessions are created in place.** The sidebar's `+` form posts
|
- **Sessions are created in place.** The sidebar's `+` form posts
|
||||||
`POST /v1/sessions` with a client-validated name
|
`POST /v1/sessions` with a client-validated name
|
||||||
(`[A-Za-z0-9._-]{1,64}`), switches the chat to the new key, and
|
(`[A-Za-z0-9._-]{1,64}`), switches the chat to the new key, and
|
||||||
@@ -116,7 +124,10 @@ from the `addable` kinds the claw serves; each kind's `fields` carry
|
|||||||
`{name, required}`, `required` marking what the daemon refuses the login
|
`{name, required}`, `required` marking what the daemon refuses the login
|
||||||
without (llama.cpp needs neither of its fields, it serves keyless on a
|
without (llama.cpp needs neither of its fields, it serves keyless on a
|
||||||
default url); the form demands only those, marks the rest optional, and
|
default url); the form demands only those, marks the rest optional, and
|
||||||
takes an optional instance name; unnamed, the id is the kind. A login
|
takes an optional instance name; unnamed, the id is the kind. Model
|
||||||
|
options carry a `◉ vision` mark when the live listing knows the model
|
||||||
|
takes images (`supports_vision: true`); the key's absence is unknown,
|
||||||
|
never a no, so it renders nothing. A login
|
||||||
is `PUT /v1/providers/{id}/credentials`: apply immediately,
|
is `PUT /v1/providers/{id}/credentials`: apply immediately,
|
||||||
credentials persist; roster rows are backend instances (`id`, the kind
|
credentials persist; roster rows are backend instances (`id`, the kind
|
||||||
unless the login named it, plus the credential's `source`
|
unless the login named it, plus the credential's `source`
|
||||||
|
|||||||
+30
-5
@@ -20,6 +20,7 @@ import type {
|
|||||||
StreamStatus,
|
StreamStatus,
|
||||||
} from '~/composables/useClaw'
|
} from '~/composables/useClaw'
|
||||||
import { useTranscript } from '~/composables/useTranscript'
|
import { useTranscript } from '~/composables/useTranscript'
|
||||||
|
import type { StagedImage } from '~/composables/useTranscript'
|
||||||
|
|
||||||
const TOKEN_STORE_KEY = 'claw.token'
|
const TOKEN_STORE_KEY = 'claw.token'
|
||||||
const ACTIVE_STORE_KEY = 'claw.activeKey'
|
const ACTIVE_STORE_KEY = 'claw.activeKey'
|
||||||
@@ -106,11 +107,14 @@ const attemptedAttachments = new Set<string>()
|
|||||||
|
|
||||||
// Image cards need their bytes before they can render: fetched as the
|
// Image cards need their bytes before they can render: fetched as the
|
||||||
// card appears, not on any timer. code/file fetch on download instead.
|
// 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.
|
||||||
watch(
|
watch(
|
||||||
transcriptItems,
|
transcriptItems,
|
||||||
(items) => {
|
(items) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.kind !== 'attachment') continue
|
if (item.kind === 'attachment') {
|
||||||
const { id, media, url } = item.attachment
|
const { id, media, url } = item.attachment
|
||||||
if (media !== 'image' || url !== null || attemptedAttachments.has(id)) continue
|
if (media !== 'image' || url !== null || attemptedAttachments.has(id)) continue
|
||||||
attemptedAttachments.add(id)
|
attemptedAttachments.add(id)
|
||||||
@@ -118,6 +122,17 @@ watch(
|
|||||||
.attachmentUrl(id)
|
.attachmentUrl(id)
|
||||||
.then((fetched) => transcript.setAttachmentUrl(id, fetched))
|
.then((fetched) => transcript.setAttachmentUrl(id, fetched))
|
||||||
.catch((err) => noteApiError(err))
|
.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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
@@ -417,13 +432,23 @@ async function removeProvider(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send(text: string): Promise<void> {
|
async function send(message: { text: string; images: StagedImage[] }): Promise<void> {
|
||||||
transcript.addSent(text)
|
// The optimistic row renders the staged previews; the stream's
|
||||||
|
// user_message echo carries the stored ids and dedupes against it.
|
||||||
|
transcript.addSent(
|
||||||
|
message.text,
|
||||||
|
message.images.map(({ media_type, url, name }) => ({ id: null, media_type, url, name })),
|
||||||
|
)
|
||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
// Fire-and-forget by design: the claw answers 202 and the reply
|
// Fire-and-forget by design: the claw answers 202 and the reply
|
||||||
// arrives on the event stream.
|
// arrives on the event stream. The body carries the images only
|
||||||
await claw.sendMessage(activeKey.value, text)
|
// when there are some.
|
||||||
|
await claw.sendMessage(
|
||||||
|
activeKey.value,
|
||||||
|
message.text,
|
||||||
|
message.images.map(({ media_type, data }) => ({ media_type, data })),
|
||||||
|
)
|
||||||
apiError.value = null
|
apiError.value = null
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
noteApiError(err)
|
noteApiError(err)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
pasteParts,
|
pasteParts,
|
||||||
} from '~/composables/useTranscript'
|
} from '~/composables/useTranscript'
|
||||||
import type { ClawSession, ConsentAnswer, StreamStatus } from '~/composables/useClaw'
|
import type { ClawSession, ConsentAnswer, StreamStatus } from '~/composables/useClaw'
|
||||||
import type { PastePart, TranscriptItem, TurnBlock } from '~/composables/useTranscript'
|
import type { PastePart, SentImage, StagedImage, TranscriptItem, TurnBlock } from '~/composables/useTranscript'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: TranscriptItem[]
|
items: TranscriptItem[]
|
||||||
@@ -31,8 +31,15 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const agentName = computed(() => props.identity?.name?.trim() || 'rhema')
|
const agentName = computed(() => props.identity?.name?.trim() || 'rhema')
|
||||||
|
|
||||||
|
/** One send as the panel hands it up: the text (pastes embedded) and
|
||||||
|
* the staged images, previews included for the optimistic row. */
|
||||||
|
interface SendPayload {
|
||||||
|
text: string
|
||||||
|
images: StagedImage[]
|
||||||
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
send: [text: string]
|
send: [message: SendPayload]
|
||||||
openProviders: []
|
openProviders: []
|
||||||
consentAnswer: [id: string, answer: ConsentAnswer]
|
consentAnswer: [id: string, answer: ConsentAnswer]
|
||||||
consentDeny: [id: string]
|
consentDeny: [id: string]
|
||||||
@@ -74,10 +81,15 @@ watch(
|
|||||||
|
|
||||||
function submit(): void {
|
function submit(): void {
|
||||||
const text = draft.value.trim()
|
const text = draft.value.trim()
|
||||||
|
// The draft stays the gate: the claw 422s an empty text even with
|
||||||
|
// images aboard.
|
||||||
if (!text || !props.canSend || props.busy) return
|
if (!text || !props.canSend || props.busy) return
|
||||||
emit('send', embedPastes(text, pastes.value))
|
emit('send', { text: embedPastes(text, pastes.value), images: stagedImages.value })
|
||||||
|
stageGeneration++
|
||||||
draft.value = ''
|
draft.value = ''
|
||||||
pastes.value = []
|
pastes.value = []
|
||||||
|
stagedImages.value = []
|
||||||
|
imageError.value = null
|
||||||
pinned.value = true
|
pinned.value = true
|
||||||
void nextTick(resizePrompt)
|
void nextTick(resizePrompt)
|
||||||
}
|
}
|
||||||
@@ -119,14 +131,22 @@ function onPaste(event: ClipboardEvent): void {
|
|||||||
if (absorbPaste(text)) event.preventDefault()
|
if (absorbPaste(text)) event.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A file paste reads as its text when it claims a textual type; the
|
// A file paste: text files read inline or chip, images stage for the
|
||||||
// rest the composer ignores.
|
// next send — the same door the (+) button opens; anything else the
|
||||||
const file = transfer.files[0]
|
// composer still ignores.
|
||||||
if (!file || !file.type.startsWith('text/')) return
|
let handled = false
|
||||||
event.preventDefault()
|
for (const file of Array.from(transfer.files)) {
|
||||||
void file.text().then((text) => {
|
if (file.type.startsWith('text/')) {
|
||||||
if (!absorbPaste(text)) insertInline(text)
|
handled = true
|
||||||
|
void file.text().then((pasted) => {
|
||||||
|
if (!absorbPaste(pasted)) insertInline(pasted)
|
||||||
})
|
})
|
||||||
|
} else if (mediaTypeOf(file) !== null) {
|
||||||
|
stageImage(file)
|
||||||
|
handled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (handled) event.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A long paste becomes one chip; whether it did is the answer. */
|
/** A long paste becomes one chip; whether it did is the answer. */
|
||||||
@@ -149,6 +169,119 @@ function insertInline(text: string): void {
|
|||||||
resizePrompt()
|
resizePrompt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- staged images ----------------------------------------------------
|
||||||
|
// The (+) button and the clipboard both land here: files held for the
|
||||||
|
// next send, previewed from object URLs, read to base64 at stage time
|
||||||
|
// so a bad file surfaces before submit, not inside it. The budget is
|
||||||
|
// the claw's own (http.rs): eight images, 5 MiB decoded each.
|
||||||
|
|
||||||
|
const MAX_IMAGES = 8
|
||||||
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||||
|
const IMAGE_TYPES: readonly string[] = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']
|
||||||
|
const IMAGE_ACCEPT = IMAGE_TYPES.join(',')
|
||||||
|
|
||||||
|
const stagedImages = ref<StagedImage[]>([])
|
||||||
|
const imageError = ref<string | null>(null)
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
/** Bumped on submit: a read still in flight when the draft cleared
|
||||||
|
* must not stage into a message that already left. */
|
||||||
|
let stageGeneration = 0
|
||||||
|
|
||||||
|
/** The wire media type a file stages as, or null when it is not one
|
||||||
|
* the claw accepts. The type usually rides the file; browsers that
|
||||||
|
* cannot name one leave it empty and the extension decides. */
|
||||||
|
function mediaTypeOf(file: File): string | null {
|
||||||
|
if (IMAGE_TYPES.includes(file.type)) return file.type
|
||||||
|
const byExt: Record<string, string> = {
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif',
|
||||||
|
webp: 'image/webp',
|
||||||
|
}
|
||||||
|
return byExt[file.name.split('.').pop()?.toLowerCase() ?? ''] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** B / KB / MB, one decimal past 1 KB — a chip 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`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickImages(): void {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPick(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
for (const file of Array.from(input.files ?? [])) stageImage(file)
|
||||||
|
// Cleared so picking the same file again still fires `change`.
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stages one file, or surfaces why not: never a silent drop. */
|
||||||
|
function stageImage(file: File): void {
|
||||||
|
const label = file.name || 'clipboard image'
|
||||||
|
imageError.value = null
|
||||||
|
if (stagedImages.value.length >= MAX_IMAGES) {
|
||||||
|
imageError.value = `${label}: at most ${MAX_IMAGES} images a message`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const media_type = mediaTypeOf(file)
|
||||||
|
if (!media_type) {
|
||||||
|
imageError.value = `${label}: not a png, jpeg, gif, or webp image`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const generation = stageGeneration
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
if (generation !== stageGeneration) return
|
||||||
|
// The data URL's head carries the mime back; the payload after the
|
||||||
|
// first comma is the bare base64 the body wants.
|
||||||
|
const dataUrl = typeof reader.result === 'string' ? reader.result : ''
|
||||||
|
const comma = dataUrl.indexOf(',')
|
||||||
|
const data = comma === -1 ? '' : dataUrl.slice(comma + 1)
|
||||||
|
if (data === '') {
|
||||||
|
imageError.value = `${label}: could not be read`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Four base64 characters decode to three bytes — the same budget
|
||||||
|
// check the claw runs, no decode needed.
|
||||||
|
if (data.length * 3 / 4 > MAX_IMAGE_BYTES) {
|
||||||
|
imageError.value = `${label}: over the 5 MiB decoded budget`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stagedImages.value.push({ media_type, data, url: URL.createObjectURL(file), name: label })
|
||||||
|
}
|
||||||
|
reader.onerror = () => {
|
||||||
|
if (generation === stageGeneration) imageError.value = `${label}: could not be read`
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(index: number): void {
|
||||||
|
const [gone] = stagedImages.value.splice(index, 1)
|
||||||
|
if (gone) URL.revokeObjectURL(gone.url)
|
||||||
|
imageError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored image opens the same download path an attachment card
|
||||||
|
* uses; a local preview has nothing behind it to download. */
|
||||||
|
function downloadImage(image: SentImage): void {
|
||||||
|
if (image.id === null) return
|
||||||
|
emit('downloadAttachment', image.id, storedImageName(image))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored image's download name: the staged file's when the row is
|
||||||
|
* local, derived from the id when only the claw knows the bytes. */
|
||||||
|
function storedImageName(image: SentImage): string {
|
||||||
|
if (image.name) return image.name
|
||||||
|
const ext = image.media_type.replace('image/', '')
|
||||||
|
return `image-${image.id}.${ext === 'jpeg' ? 'jpg' : ext}`
|
||||||
|
}
|
||||||
|
|
||||||
/** One render piece of a user message: literal text or a paste chip. */
|
/** One render piece of a user message: literal text or a paste chip. */
|
||||||
type MessageSegment = PastePart | { kind: 'chip'; label: string }
|
type MessageSegment = PastePart | { kind: 'chip'; label: string }
|
||||||
|
|
||||||
@@ -224,6 +357,18 @@ function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
|
|||||||
<div class="avatar user" aria-hidden="true">·</div>
|
<div class="avatar user" aria-hidden="true">·</div>
|
||||||
<div class="msg-body">
|
<div class="msg-body">
|
||||||
<div class="who">you</div>
|
<div class="who">you</div>
|
||||||
|
<div v-if="item.images.length > 0" class="sent-images">
|
||||||
|
<button
|
||||||
|
v-for="(image, index) in item.images"
|
||||||
|
:key="index"
|
||||||
|
class="sent-image"
|
||||||
|
:disabled="image.url === null || image.id === null"
|
||||||
|
:title="image.url !== null && image.id !== null ? `download ${storedImageName(image)}` : (image.name ?? 'attached image')"
|
||||||
|
@click="downloadImage(image)"
|
||||||
|
>
|
||||||
|
<img v-if="image.url" :src="image.url" :alt="image.name ?? 'attached image'" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="reply plain">
|
<div class="reply plain">
|
||||||
<template v-for="(segment, index) in messageSegments(item.text)" :key="index">
|
<template v-for="(segment, index) in messageSegments(item.text)" :key="index">
|
||||||
<span v-if="segment.kind === 'chip'" class="badge paste-chip">{{ segment.label }}</span>
|
<span v-if="segment.kind === 'chip'" class="badge paste-chip">{{ segment.label }}</span>
|
||||||
@@ -300,7 +445,22 @@ function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="stagedImages.length > 0" class="paste-chips">
|
||||||
|
<span v-for="(image, index) in stagedImages" :key="image.url" class="badge paste-chip">
|
||||||
|
▣{{ index + 1 }} {{ image.name }} {{ formatBytes(Math.round((image.data.length * 3) / 4)) }}
|
||||||
|
<button class="chip-x" :title="`drop ${image.name}`" @click="removeImage(index)">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="imageError" class="image-error" role="alert">
|
||||||
|
{{ imageError }}
|
||||||
|
<button class="chip-x" title="dismiss" @click="imageError = null">×</button>
|
||||||
|
</p>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
|
<button class="ghost attach" title="attach images" :disabled="!canSend" @click="pickImages">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
<textarea
|
<textarea
|
||||||
ref="prompt"
|
ref="prompt"
|
||||||
v-model="draft"
|
v-model="draft"
|
||||||
@@ -314,6 +474,14 @@ function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
|
|||||||
<button class="primary send" :disabled="!canSend || busy || draft.trim() === ''" title="send" @click="submit">
|
<button class="primary send" :disabled="!canSend || busy || draft.trim() === ''" title="send" @click="submit">
|
||||||
↑
|
↑
|
||||||
</button>
|
</button>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
class="file-input"
|
||||||
|
type="file"
|
||||||
|
:accept="IMAGE_ACCEPT"
|
||||||
|
multiple
|
||||||
|
@change="onPick"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
@@ -646,6 +814,57 @@ function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attach {
|
||||||
|
width: 38px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 9px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Images a user message carried: the attachment-card visual, sized to
|
||||||
|
the sent row. A stored image downloads on click; a local preview has
|
||||||
|
nothing behind it. */
|
||||||
|
.sent-images {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sent-image {
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sent-image:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sent-image img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 240px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.input-row textarea {
|
.input-row textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -215,6 +215,16 @@ watch(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** The listing's vision verdict, spelled the way the TUI's picker
|
||||||
|
* spells it: a mark when the adapter knows the model takes images, a
|
||||||
|
* bare "no vision" when it knows it does not, nothing when the key is
|
||||||
|
* absent — unknown is no verdict at all, never a `false`. */
|
||||||
|
function visionLabel(model: ModelCard): string {
|
||||||
|
if (model.supports_vision === true) return ' ◉ vision'
|
||||||
|
if (model.supports_vision === false) return ' no vision'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -259,7 +269,7 @@ watch(
|
|||||||
<select v-model="draftModel" :disabled="saving || !draftProvider">
|
<select v-model="draftModel" :disabled="saving || !draftProvider">
|
||||||
<option value="">(provider default)</option>
|
<option value="">(provider default)</option>
|
||||||
<option v-for="m in models" :key="m.id" :value="m.id">
|
<option v-for="m in models" :key="m.id" :value="m.id">
|
||||||
{{ m.id }}
|
{{ m.id }}{{ visionLabel(m) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|||||||
@@ -41,9 +41,24 @@ export interface ClawAttachment {
|
|||||||
caption: string | null
|
caption: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One image a sent message carries, as the `POST .../messages` body
|
||||||
|
* takes it: the full MIME type plus bare base64 (http.rs `WireImage`). */
|
||||||
|
export interface OutboundImage {
|
||||||
|
media_type: string
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One image a `user_message` event carries: the attachment id the
|
||||||
|
* bytes live under (fetch them via `attachmentUrl`) plus the MIME type
|
||||||
|
* — megabytes never ride the stream (hub.rs `UserImage`). */
|
||||||
|
export interface UserImage {
|
||||||
|
id: string
|
||||||
|
media_type: string
|
||||||
|
}
|
||||||
|
|
||||||
/** One turn event, exactly `hub.rs`'s `TurnEvent` (serde type tag, snake case). */
|
/** One turn event, exactly `hub.rs`'s `TurnEvent` (serde type tag, snake case). */
|
||||||
export type TurnEvent =
|
export type TurnEvent =
|
||||||
| { type: 'user_message'; text: string }
|
| { type: 'user_message'; text: string; images: UserImage[] }
|
||||||
| { type: 'thinking_delta'; text: string }
|
| { type: 'thinking_delta'; text: string }
|
||||||
| { type: 'text_delta'; text: string }
|
| { type: 'text_delta'; text: string }
|
||||||
| { type: 'tool_start'; name: string }
|
| { type: 'tool_start'; name: string }
|
||||||
@@ -78,6 +93,9 @@ export interface ModelCard {
|
|||||||
display_name: string
|
display_name: string
|
||||||
description: string
|
description: string
|
||||||
input_token_limit?: number | null
|
input_token_limit?: number | null
|
||||||
|
/** Whether the model takes image input, when the adapter knows:
|
||||||
|
* absent means unknown — no verdict, never a `false`. */
|
||||||
|
supports_vision?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One account of `GET /v1/accounts` (rhema-identity's `Account`):
|
/** One account of `GET /v1/accounts` (rhema-identity's `Account`):
|
||||||
@@ -308,8 +326,8 @@ export interface ClawApi {
|
|||||||
* a plain src will not do). Cached per id: attachments are immutable,
|
* a plain src will not do). Cached per id: attachments are immutable,
|
||||||
* so a replayed event never refetches. */
|
* so a replayed event never refetches. */
|
||||||
attachmentUrl(id: string): Promise<string>
|
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. */
|
/** `POST /v1/sessions/{key}/messages` — fire-and-forget: the claw answers 202 and the reply arrives on the event stream. Images ride the body only when there are some; without them the body is the old `{"text"}` shape byte-for-byte. The route 422s an empty text even with images aboard. */
|
||||||
sendMessage(key: string, text: string): Promise<void>
|
sendMessage(key: string, text: string, images?: OutboundImage[]): Promise<void>
|
||||||
/** `GET /v1/providers` — the constructible backends and the active pair. */
|
/** `GET /v1/providers` — the constructible backends and the active pair. */
|
||||||
providers(): Promise<ProvidersState>
|
providers(): Promise<ProvidersState>
|
||||||
/** `PUT /v1/providers/active` — switch the backend, persisted claw-side. Answers the new active pair only. */
|
/** `PUT /v1/providers/active` — switch the backend, persisted claw-side. Answers the new active pair only. */
|
||||||
@@ -410,11 +428,11 @@ export function useClaw(token: Ref<string | null>): ClawApi {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage(key: string, text: string): Promise<void> {
|
async function sendMessage(key: string, text: string, images?: OutboundImage[]): Promise<void> {
|
||||||
await send(`/v1/sessions/${encodeURIComponent(key)}/messages`, {
|
await send(`/v1/sessions/${encodeURIComponent(key)}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token.value) },
|
headers: { 'Content-Type': 'application/json', ...authHeaders(token.value) },
|
||||||
body: JSON.stringify({ text }),
|
body: JSON.stringify(images?.length ? { text, images } : { text }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,12 +667,33 @@ async function readFrames(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The images a `user_message` carries: rows recorded before images
|
||||||
|
* rode the transcript carry no field at all, and an entry without an
|
||||||
|
* id and an image media type is not one — both fold to fewer (or no)
|
||||||
|
* images rather than a guessed shape. */
|
||||||
|
function parseUserImages(raw: unknown): UserImage[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const images: UserImage[] = []
|
||||||
|
for (const entry of raw) {
|
||||||
|
const image = entry as Record<string, unknown>
|
||||||
|
if (typeof image.id !== 'string' || typeof image.media_type !== 'string') continue
|
||||||
|
if (!image.media_type.startsWith('image/')) continue
|
||||||
|
images.push({ id: image.id, media_type: image.media_type })
|
||||||
|
}
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
/** Parses one `data:` payload into a `TurnEvent`; unknown shapes are dropped, not guessed. */
|
/** Parses one `data:` payload into a `TurnEvent`; unknown shapes are dropped, not guessed. */
|
||||||
function parseTurnEvent(data: string): TurnEvent | null {
|
function parseTurnEvent(data: string): TurnEvent | null {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(data) as Record<string, unknown>
|
const raw = JSON.parse(data) as Record<string, unknown>
|
||||||
switch (raw.type) {
|
switch (raw.type) {
|
||||||
case 'user_message':
|
case 'user_message':
|
||||||
|
return {
|
||||||
|
type: 'user_message',
|
||||||
|
text: String(raw.text ?? ''),
|
||||||
|
images: parseUserImages(raw.images),
|
||||||
|
}
|
||||||
case 'thinking_delta':
|
case 'thinking_delta':
|
||||||
case 'text_delta':
|
case 'text_delta':
|
||||||
return { type: raw.type, text: String(raw.text ?? '') }
|
return { type: raw.type, text: String(raw.text ?? '') }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// mirror when the stream reopens and folds what arrives, never the other
|
// mirror when the stream reopens and folds what arrives, never the other
|
||||||
// way around.
|
// way around.
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { AttachmentMedia, ConsentKind, TurnEvent } from './useClaw'
|
import type { AttachmentMedia, ConsentKind, OutboundImage, TurnEvent } from './useClaw'
|
||||||
|
|
||||||
/** One tool call inside a turn: started, then completed by its result. */
|
/** One tool call inside a turn: started, then completed by its result. */
|
||||||
export interface ToolRow {
|
export interface ToolRow {
|
||||||
@@ -63,9 +63,33 @@ export interface AttachmentCard {
|
|||||||
url: string | null
|
url: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One image a user message carried, as the transcript renders it. A
|
||||||
|
* locally sent row holds the browser's own object URL — no fetch, no
|
||||||
|
* id; a replayed row holds the attachment id the claw stored and gets
|
||||||
|
* its bytes bearer-guarded, like an attachment card. */
|
||||||
|
export interface SentImage {
|
||||||
|
/** The attachment id behind a replayed row; null for a local one. */
|
||||||
|
id: string | null
|
||||||
|
media_type: string
|
||||||
|
/** The object URL once resolved: local at stage time, fetched for a replay. */
|
||||||
|
url: string | null
|
||||||
|
/** The file's name when the browser knew one; a replay has only an id. */
|
||||||
|
name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One image staged in the composer, as the send path hands it up: the
|
||||||
|
* wire payload plus the local bits the optimistic row needs and the
|
||||||
|
* claw never sees. */
|
||||||
|
export interface StagedImage extends OutboundImage {
|
||||||
|
/** The local preview URL the optimistic row renders. */
|
||||||
|
url: string
|
||||||
|
/** The file's name, as the chip and the sent row label it. */
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
/** A transcript line: a message the local user sent, or one streamed turn. */
|
/** A transcript line: a message the local user sent, or one streamed turn. */
|
||||||
export type TranscriptItem =
|
export type TranscriptItem =
|
||||||
| { kind: 'sent'; id: number; text: string }
|
| { kind: 'sent'; id: number; text: string; images: SentImage[] }
|
||||||
| { kind: 'turn'; id: number; turn: TurnBlock }
|
| { kind: 'turn'; id: number; turn: TurnBlock }
|
||||||
| { kind: 'consent'; id: number; consent: ConsentCard }
|
| { kind: 'consent'; id: number; consent: ConsentCard }
|
||||||
| { kind: 'attachment'; id: number; attachment: AttachmentCard }
|
| { kind: 'attachment'; id: number; attachment: AttachmentCard }
|
||||||
@@ -169,10 +193,22 @@ export function useTranscript() {
|
|||||||
case 'user_message': {
|
case 'user_message': {
|
||||||
// The replayed question. A live echo of a message this surface
|
// The replayed question. A live echo of a message this surface
|
||||||
// just sent arrives after the optimistic `sent` row: identical
|
// just sent arrives after the optimistic `sent` row: identical
|
||||||
// text as the last item means the row already stands in for it.
|
// text and the same image count as the last item means the row
|
||||||
|
// already stands in for it, previews and all.
|
||||||
const last = items.value[items.value.length - 1]
|
const last = items.value[items.value.length - 1]
|
||||||
if (last?.kind === 'sent' && last.text === event.text) return
|
if (
|
||||||
items.value.push({ kind: 'sent', id: nextId++, text: event.text })
|
last?.kind === 'sent' &&
|
||||||
|
last.text === event.text &&
|
||||||
|
last.images.length === event.images.length
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.value.push({
|
||||||
|
kind: 'sent',
|
||||||
|
id: nextId++,
|
||||||
|
text: event.text,
|
||||||
|
images: event.images.map((image) => ({ ...image, url: null, name: null })),
|
||||||
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'thinking_delta': {
|
case 'thinking_delta': {
|
||||||
@@ -280,9 +316,11 @@ export function useTranscript() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A locally sent message, shown immediately; the reply streams after. */
|
/** A locally sent message, shown immediately; the reply streams after.
|
||||||
function addSent(text: string): void {
|
* Its images render from the staged previews — the claw stores them
|
||||||
items.value.push({ kind: 'sent', id: nextId++, text })
|
* and the stream's echo dedupes against this row. */
|
||||||
|
function addSent(text: string, images: SentImage[] = []): void {
|
||||||
|
items.value.push({ kind: 'sent', id: nextId++, text, images })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A consent card resolved: flipped in place, or ignored when no
|
/** A consent card resolved: flipped in place, or ignored when no
|
||||||
@@ -324,6 +362,21 @@ export function useTranscript() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A replayed sent image's object URL landed; stored on its row. The
|
||||||
|
* useClaw cache keeps a reconnect's replay from refetching the bytes.
|
||||||
|
* A user message is always a top-level row — it never rides a live
|
||||||
|
* turn — so the scan stays over `items`. */
|
||||||
|
function setSentImageUrl(id: string, url: string): void {
|
||||||
|
for (const item of items.value) {
|
||||||
|
if (item.kind !== 'sent') continue
|
||||||
|
const image = item.images.find((candidate) => candidate.id === id)
|
||||||
|
if (image) {
|
||||||
|
image.url = url
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a card for this consent id is already on the mirror — a
|
/** Whether a card for this consent id is already on the mirror — a
|
||||||
* replayed request never stacks a second card beside the first. */
|
* replayed request never stacks a second card beside the first. */
|
||||||
function findConsent(id: string): boolean {
|
function findConsent(id: string): boolean {
|
||||||
@@ -346,5 +399,5 @@ export function useTranscript() {
|
|||||||
live.value = null
|
live.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items, live, applyEvent, addSent, resolveConsent, setAttachmentUrl, reset }
|
return { items, live, applyEvent, addSent, resolveConsent, setAttachmentUrl, setSentImageUrl, reset }
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user