openvisio-agent 0.24.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/USER_GUIDE.md +2 -0
- package/package.json +1 -1
- package/src/runtime-control.mjs +16 -5
- package/src/studio-server.mjs +8 -3
- package/src/watch.mjs +18 -5
- package/studio/app.mjs +17 -4
- package/studio/bot-avatar.mjs +70 -0
- package/studio/style.css +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.24.1] — 2026-09-10
|
|
4
|
+
|
|
5
|
+
- Deliver agent-authored acknowledgements and short plans to the originating channel or thread when a request moves into a coding workspace.
|
|
6
|
+
- Highlight the selected agent in Studio and show embedded avatars shared with the main app.
|
|
7
|
+
|
|
3
8
|
## [0.24.0] — 2026-09-10
|
|
4
9
|
|
|
5
10
|
- Acknowledge accepted coding tasks with the agent's short native plan, then deliver its final response separately. Deduplicate plans through reconnects and use guarded source-thread delivery for every BYO runtime.
|
package/USER_GUIDE.md
CHANGED
|
@@ -78,6 +78,8 @@ Replies stay in their source thread. New completion messages use the agent’s d
|
|
|
78
78
|
|
|
79
79
|
For accepted coding tasks, the agent starts by publishing a short plan. You receive one acknowledgement with up to three steps, followed later by its result. The agent can delegate independent pieces to native sub-agents and remains responsible for checking their work. Thinking, working, and typing indicators follow runtime activity; they expire after work stops. A reconnect does not repeat the plan or cancel healthy work.
|
|
80
80
|
|
|
81
|
+
When a conversation turns into coding work, the agent includes its acknowledgement and two or three next steps in the workspace handoff. They are posted to the original channel thread before coding starts. Private continuation context stays internal, and the work session does not send a second pickup message.
|
|
82
|
+
|
|
81
83
|
## Open Agent Studio
|
|
82
84
|
|
|
83
85
|
In the app, choose **Agents → Open Agent Studio**. Updated backend watchers start Studio automatically. Agent chat replies can also show this button and open the relevant agent’s settings directly.
|
package/package.json
CHANGED
package/src/runtime-control.mjs
CHANGED
|
@@ -4,18 +4,25 @@ export function runtimeControlTools({ canCode = false, workspaceAvailable = fals
|
|
|
4
4
|
if (canCode || !workspaceAvailable) return []
|
|
5
5
|
return [{
|
|
6
6
|
name: WORK_SESSION_TOOL,
|
|
7
|
-
description: 'Continue this same request in your configured coding workspace. Use when investigation reveals that local execution or edits are needed. You remain the same agent and retain the original task, recipient, and permissions. Supply
|
|
8
|
-
inputSchema: { type: 'object', properties: {
|
|
7
|
+
description: 'Continue this same request in your configured coding workspace. Use when investigation reveals that local execution or edits are needed. You remain the same agent and retain the original task, recipient, and permissions. Supply private continuation context plus a teammate-facing acknowledgement and 2-3 concrete plan steps. The watcher posts that acknowledgement and plan in the original channel/thread before starting your work session, then delivers your eventual result separately. End this turn after requesting continuation; do not repeat the acknowledgement or ask for reassignment.',
|
|
8
|
+
inputSchema: { type: 'object', properties: {
|
|
9
|
+
context: { type: 'string', minLength: 1, maxLength: 12000, description: 'Private findings, decisions, remaining work, and context for your continuation. This field is not posted to chat.' },
|
|
10
|
+
acknowledgement: { type: 'string', minLength: 1, maxLength: 1200, description: 'A short, natural first-person acknowledgement for the teammate. Say what you have accepted; do not claim completion.' },
|
|
11
|
+
plan: { type: 'array', minItems: 2, maxItems: 3, items: { type: 'string', minLength: 1, maxLength: 240 }, description: '2-3 concrete next steps to post alongside your acknowledgement before doing the work.' },
|
|
12
|
+
}, required: ['context', 'acknowledgement', 'plan'], additionalProperties: false },
|
|
9
13
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
10
14
|
}]
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
export function requestWorkSession(args, capabilities) {
|
|
14
18
|
if (!runtimeControlTools(capabilities).length) throw new Error('No additional coding workspace is available in this session')
|
|
15
|
-
if (!args || Object.keys(args).some((key) =>
|
|
16
|
-
|
|
19
|
+
if (!args || Object.keys(args).some((key) => !['context', 'acknowledgement', 'plan'].includes(key)) || typeof args.context !== 'string' || !args.context.trim() || args.context.length > 12000) throw new Error('Provide a nonempty context string of at most 12000 characters and only the advertised acknowledgement and plan fields')
|
|
20
|
+
if (typeof args.acknowledgement !== 'string' || !args.acknowledgement.trim() || args.acknowledgement.length > 1200 || !validPlan(args.plan)) throw new Error('Include a short acknowledgement and 2-3 nonempty plan steps of at most 240 characters each for delivery to the original conversation')
|
|
21
|
+
return { content: [{ type: 'text', text: JSON.stringify({ openvisioControl: { action: 'request_work_session', context: args.context.trim(), acknowledgement: args.acknowledgement.trim(), plan: args.plan.map(step => step.trim()) } }) }] }
|
|
17
22
|
}
|
|
18
23
|
|
|
24
|
+
const validPlan = (plan) => Array.isArray(plan) && plan.length >= 2 && plan.length <= 3 && plan.every(step => typeof step === 'string' && step.trim() && step.length <= 240)
|
|
25
|
+
|
|
19
26
|
// Only inspect outputs of our named local control tool, never arbitrary model
|
|
20
27
|
// prose, commands, ticket text, or repository content.
|
|
21
28
|
export function workSessionRequest(output, depth = 0) {
|
|
@@ -28,7 +35,11 @@ export function workSessionRequest(output, depth = 0) {
|
|
|
28
35
|
return null
|
|
29
36
|
}
|
|
30
37
|
const request = output.openvisioControl
|
|
31
|
-
if (request?.action === 'request_work_session' && typeof request.context === 'string' && request.context.trim() && request.context.length <= 12000) return {
|
|
38
|
+
if (request?.action === 'request_work_session' && typeof request.context === 'string' && request.context.trim() && request.context.length <= 12000) return {
|
|
39
|
+
context: request.context.trim(),
|
|
40
|
+
...(typeof request.acknowledgement === 'string' && request.acknowledgement.trim() && request.acknowledgement.length <= 1200 ? { acknowledgement: request.acknowledgement.trim() } : {}),
|
|
41
|
+
...(validPlan(request.plan) ? { plan: request.plan.map(step => step.trim()) } : {}),
|
|
42
|
+
}
|
|
32
43
|
for (const key of ['content', 'text', 'result', 'structuredContent']) {
|
|
33
44
|
const nested = workSessionRequest(output[key], depth + 1)
|
|
34
45
|
if (nested) return nested
|
package/src/studio-server.mjs
CHANGED
|
@@ -105,14 +105,19 @@ export async function readStudioSnapshot({ stateDir, now = Date.now }) {
|
|
|
105
105
|
const age = now() - Date.parse(event.timestamp)
|
|
106
106
|
const online = age >= -5000 && age <= FRESH_MS && event.type !== 'runtime.stopped' && pidAlive(event.pid)
|
|
107
107
|
const existing = agentsById.get(event.agent.identifier)
|
|
108
|
-
agentsById.set(event.agent.identifier, { ...existing, id: event.agent.identifier, identifier: event.agent.identifier, slug: event.agent.slug || event.agent.identifier, name: existing?.name || event.agent.slug || event.agent.identifier, provider: event.agent.provider || 'unknown', runId: event.runId, pid: Number.isSafeInteger(event.pid) ? event.pid : null, watcherAlive: online, status: online ? 'online' : 'offline', lastSeen: event.timestamp, lastEventType: event.type })
|
|
108
|
+
agentsById.set(event.agent.identifier, { ...existing, id: event.agent.identifier, identifier: event.agent.identifier, slug: existing?.slug || event.agent.slug || event.agent.identifier, name: existing?.name || event.agent.slug || event.agent.identifier, provider: event.agent.provider || 'unknown', runId: event.runId, pid: Number.isSafeInteger(event.pid) ? event.pid : null, watcherAlive: online, status: online ? 'online' : 'offline', lastSeen: event.timestamp, lastEventType: event.type })
|
|
109
109
|
}
|
|
110
|
-
const agents = [...agentsById.values()]
|
|
110
|
+
const agents = [...agentsById.values()]
|
|
111
111
|
for (const agent of agents) {
|
|
112
|
+
const profileEvent = events.findLast(event => event.agent.identifier === agent.identifier && (event.type === 'agent.profile' || event.type === 'watcher.heartbeat' && event.data?.profile))
|
|
113
|
+
const profile = profileEvent?.type === 'agent.profile' ? profileEvent.data : profileEvent?.data?.profile
|
|
114
|
+
if (typeof profile?.name === 'string' && profile.name.trim()) agent.name = profile.name.trim()
|
|
115
|
+
if (typeof profile?.avatarSeed === 'string' && profile.avatarSeed) agent.avatarSeed = profile.avatarSeed
|
|
112
116
|
const applied = events.findLast(event => event.agent.identifier === agent.identifier && event.runId === agent.runId && typeof event.data?.modelSettingsRevision === 'string')
|
|
113
117
|
agent.appliedModelRevision = applied?.data.modelSettingsRevision || ''
|
|
114
118
|
agent.modelControlsSupported = !!applied
|
|
115
119
|
}
|
|
120
|
+
agents.sort((a, b) => a.name.localeCompare(b.name))
|
|
116
121
|
return { schemaVersion: 1, demo: false, generatedAt: new Date(now()).toISOString(), agents, events: events.slice(-MAX_EVENTS), limits: { maxEvents: MAX_EVENTS, maxFiles: MAX_FILES, maxTotalBytes: MAX_TOTAL_BYTES }, warnings }
|
|
117
122
|
}
|
|
118
123
|
|
|
@@ -219,7 +224,7 @@ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4
|
|
|
219
224
|
sendSnapshot(res, await snapshot())
|
|
220
225
|
return
|
|
221
226
|
}
|
|
222
|
-
const file = ({ '/': 'index.html', '/index.html': 'index.html', '/guide': 'guide.html', '/guide.html': 'guide.html', '/style.css': 'style.css', '/app.mjs': 'app.mjs', '/satoshi-400.woff2': 'satoshi-400.woff2', '/satoshi-500.woff2': 'satoshi-500.woff2', '/openvisio.svg': 'openvisio.svg' })[path]
|
|
227
|
+
const file = ({ '/': 'index.html', '/index.html': 'index.html', '/guide': 'guide.html', '/guide.html': 'guide.html', '/style.css': 'style.css', '/app.mjs': 'app.mjs', '/bot-avatar.mjs': 'bot-avatar.mjs', '/satoshi-400.woff2': 'satoshi-400.woff2', '/satoshi-500.woff2': 'satoshi-500.woff2', '/openvisio.svg': 'openvisio.svg' })[path]
|
|
223
228
|
if (!file) { res.writeHead(404); res.end('Not found'); return }
|
|
224
229
|
const target = await realpath(resolve(root, file))
|
|
225
230
|
const rel = relative(root, target)
|
package/src/watch.mjs
CHANGED
|
@@ -630,6 +630,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
630
630
|
if (!live || live.control.cancelled || stopping) return
|
|
631
631
|
if (type === 'tool.started' || type === 'tool.updated') activity.set(data.cycleId, live.control.statusTargets, 'working')
|
|
632
632
|
if (type === 'output.progress') activity.set(data.cycleId, live.control.statusTargets, 'typing')
|
|
633
|
+
// Reply sessions may discover work themselves. Keep their native plan for
|
|
634
|
+
// an explicit continuation, without announcing plans for ordinary answers.
|
|
635
|
+
if (type === 'plan.updated') live.control.planEntries = data.entries
|
|
633
636
|
if (type === 'plan.updated' && live.data.kind === 'full' && data.entries?.some(entry => typeof entry?.content === 'string' && entry.content.trim()) && !live.control.planDelivery) {
|
|
634
637
|
live.control.planDelivery = trackOperation(publishWorkPlan(live, data.entries)).catch(error => log('Task plan delivery unavailable: ' + (error?.message || error)))
|
|
635
638
|
}
|
|
@@ -878,13 +881,17 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
878
881
|
let selfAgentId = null
|
|
879
882
|
let selfOrganizationId = null
|
|
880
883
|
let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
|
|
884
|
+
let agentAppearance = memory.get?.(`identity:${identifier}`)?.meta?.appearance || null
|
|
885
|
+
if (agentAppearance) emit('agent.profile', agentAppearance)
|
|
881
886
|
const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
|
|
882
887
|
const rememberSelfAgent = (self) => {
|
|
883
888
|
if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
|
|
884
889
|
if (validBackendId(self.id)) selfAgentId = Number(self.id)
|
|
885
890
|
for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
|
|
886
891
|
identityContext = agentProfileContext(self, identifier)
|
|
887
|
-
|
|
892
|
+
const appearance = { name: String(self.name || identifier).slice(0, 160), avatarSeed: String(self.slug || self.identifier || identifier).slice(0, 240) }
|
|
893
|
+
if (JSON.stringify(appearance) !== JSON.stringify(agentAppearance)) { agentAppearance = appearance; emit('agent.profile', appearance) }
|
|
894
|
+
memory.remember({ key: `identity:${identifier}`, kind: 'identity', state: 'configured', summary: self.name || identifier, meta: { context: identityContext, appearance } })
|
|
888
895
|
const organizationId = self.organization_id ?? self.organizationId
|
|
889
896
|
if (validBackendId(organizationId)) {
|
|
890
897
|
selfOrganizationId = Number(organizationId)
|
|
@@ -1165,11 +1172,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1165
1172
|
return run
|
|
1166
1173
|
}
|
|
1167
1174
|
|
|
1168
|
-
async function publishWorkPlan(live, entries) {
|
|
1175
|
+
async function publishWorkPlan(live, entries, acknowledgement = '') {
|
|
1169
1176
|
const { control, delivery, taskRef } = live
|
|
1170
1177
|
const steps = (Array.isArray(entries) ? entries : []).filter(entry => entry && typeof entry.content === 'string' && entry.content.trim()).slice(0, 3).map(entry => entry.content.trim().slice(0, 240))
|
|
1171
1178
|
if (!steps.length || control.cancelled || !control.planKey || memory.has(control.planKey)) return
|
|
1172
|
-
const
|
|
1179
|
+
const introduction = String(acknowledgement || '').trim().slice(0, 1200) || (control.taskLabel ? `I'm on ${control.taskLabel}.` : "I'll take this on.")
|
|
1180
|
+
const content = `${introduction}\n\n${steps.map((step, index) => `${index + 1}. ${step}`).join('\n')}`
|
|
1173
1181
|
const channelId = delivery?.channelId ?? taskRef?.channelId ?? await projectStatusChannel(taskRef?.projectId)
|
|
1174
1182
|
if (channelId == null || control.cancelled) return
|
|
1175
1183
|
const refs = { channelId: Number(channelId), ...(delivery?.parentId != null ? { threadId: Number(delivery.parentId) } : {}) }
|
|
@@ -1618,7 +1626,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1618
1626
|
try { recalled = await memory.context(memoryRefs) }
|
|
1619
1627
|
catch (error) { log('Optional history unavailable; agent can retrieve context with its tools: ' + (error?.message || error)) }
|
|
1620
1628
|
const capabilityContext = kind !== 'full' && canCode
|
|
1621
|
-
? 'YOUR CODING WORKSPACE IS AVAILABLE. If this request needs local execution or edits, call openvisio_request_work_session with the
|
|
1629
|
+
? 'YOUR CODING WORKSPACE IS AVAILABLE. If this request needs local execution or edits, call openvisio_request_work_session with private continuation context, a short teammate-facing acknowledgement, and 2-3 concrete plan steps. The watcher posts the acknowledgement and plan in this source conversation before starting the work session. Plain final text and internal context are not posted during this handoff: put your public acknowledgement and plan in the tool fields. Then end this turn. The same agent continues the same request in its coding workspace. Do not claim to be a chat-only agent or ask anyone to reassign the ticket.'
|
|
1622
1630
|
: canCode ? 'This session has your configured coding workspace. Choose the tools and context appropriate to the request.' : 'This connection has no configured local coding workspace. Use your available tools for the request; do not claim local changes you cannot perform.'
|
|
1623
1631
|
const prompt = identityContext + '\n\n' + capabilityContext + '\n\n' + (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
|
|
1624
1632
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
@@ -1689,6 +1697,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1689
1697
|
// An agent-selected continuation, not a heuristic retry or a new task.
|
|
1690
1698
|
// Preserve the authoritative recipient and cancellation key; the tool
|
|
1691
1699
|
// cannot select another task, channel, identity, or permission scope.
|
|
1700
|
+
const entries = Array.isArray(result.workRequest.plan)
|
|
1701
|
+
? result.workRequest.plan.map(content => ({ content })) : cycleControl.planEntries
|
|
1702
|
+
try { await publishWorkPlan({ control: cycleControl, delivery, taskRef: activeTaskRef }, entries, result.workRequest.acknowledgement) }
|
|
1703
|
+
catch (error) { log('Task plan delivery unavailable: ' + (error?.message || error)) }
|
|
1704
|
+
if (cycleControl.cancelled || stopping) return
|
|
1692
1705
|
cycleControl.outcome = 'continued'
|
|
1693
1706
|
cycleControl.finishedBy = 'agent'
|
|
1694
1707
|
const continuation = String(result.workRequest.context || '').slice(0, 12000)
|
|
@@ -2106,7 +2119,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2106
2119
|
modelSettingsTimer?.unref?.()
|
|
2107
2120
|
emit('models.updated', { model: codeModel, chatModel: liteModel, modelSettingsRevision })
|
|
2108
2121
|
const heartbeatTimer = autoStart ? setInterval(() => {
|
|
2109
|
-
emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, modelSettingsRevision, workdir: workdir || '' })
|
|
2122
|
+
emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, modelSettingsRevision, workdir: workdir || '', ...(agentAppearance ? { profile: agentAppearance } : {}) })
|
|
2110
2123
|
// The Studio reads a bounded journal tail. Re-state current queue ownership
|
|
2111
2124
|
// so a long-running cycle stays visible after its start event rotates out.
|
|
2112
2125
|
for (const { data, control } of liveCycles.values()) {
|
package/studio/app.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// The studio displays recorded, visible actions. It never starts work or infers a plan.
|
|
2
|
+
import { botAvatarUrl } from './bot-avatar.mjs';
|
|
2
3
|
const MAX_EVENTS = 1000;
|
|
3
4
|
const PAGE_SIZE = 40;
|
|
4
5
|
const terminalStatuses = new Set(['completed', 'failed', 'cancelled', 'offline', 'skipped', 'blocked', 'timeout', 'continued']);
|
|
@@ -287,6 +288,14 @@ function initializeStudio() {
|
|
|
287
288
|
const element = node('span', `badge ${status}`, label);
|
|
288
289
|
return element;
|
|
289
290
|
}
|
|
291
|
+
function agentImage(agent, size = 31) {
|
|
292
|
+
const profile = state.model.agents.find(item => agentId(item) === agentId(agent)) || agent;
|
|
293
|
+
const image = node('img', 'bot-avatar');
|
|
294
|
+
image.src = botAvatarUrl(profile.avatarSeed || profile.slug || profile.identifier || profile.id);
|
|
295
|
+
image.alt = ''; image.setAttribute('aria-hidden', 'true');
|
|
296
|
+
image.width = size; image.height = size;
|
|
297
|
+
return image;
|
|
298
|
+
}
|
|
290
299
|
function toast(message) {
|
|
291
300
|
clearTimeout(toastTimer);
|
|
292
301
|
$('toast').textContent = message;
|
|
@@ -352,10 +361,12 @@ function initializeStudio() {
|
|
|
352
361
|
button.setAttribute('aria-pressed', String(state.selectedAgent === id));
|
|
353
362
|
const avatar = node('span', 'agent-avatar');
|
|
354
363
|
if (id === 'all') avatar.append(icon('agents'));
|
|
355
|
-
else { avatar.
|
|
364
|
+
else { avatar.classList.add('has-image'); avatar.append(agentImage(agent), node('span', `avatar-status ${agent.status}`)); }
|
|
356
365
|
const body = node('span', 'agent-text');
|
|
357
366
|
body.append(node('span', 'agent-name', name), node('span', 'agent-caption', caption));
|
|
358
|
-
|
|
367
|
+
const selection = node('span', 'agent-selection'); selection.append(icon('check'));
|
|
368
|
+
selection.setAttribute('aria-hidden', 'true');
|
|
369
|
+
button.append(avatar, body, selection);
|
|
359
370
|
button.addEventListener('click', () => { state.selectedAgent = id; resetSelection(); scheduleRender(); });
|
|
360
371
|
return button;
|
|
361
372
|
}
|
|
@@ -704,9 +715,11 @@ function initializeStudio() {
|
|
|
704
715
|
branch.setAttribute('aria-pressed', String(item.id === cycle.id));
|
|
705
716
|
const marker = node('span', `fanout-marker ${item.status}`);
|
|
706
717
|
marker.append(icon(item.status === 'completed' ? 'check' : ['failed', 'cancelled', 'blocked', 'timeout'].includes(item.status) ? 'close' : ['active', 'recovering'].includes(item.status) ? 'loader' : 'clock'));
|
|
707
|
-
|
|
718
|
+
const identity = node('span', 'fanout-identity');
|
|
719
|
+
identity.append(agentImage(item.agent, 20), node('span', 'fanout-name', agentName(item.agent)));
|
|
720
|
+
branch.append(marker, identity);
|
|
708
721
|
branch.addEventListener('click', () => {
|
|
709
|
-
state.selectedCycle = item.id; state.selectedEvent = null; state.selectedAgent =
|
|
722
|
+
state.selectedCycle = item.id; state.selectedEvent = null; state.selectedAgent = agentId(item.agent);
|
|
710
723
|
state.view = 'cycles'; state.search = ''; state.kind = 'all'; state.status = 'all';
|
|
711
724
|
$('event-search').value = ''; $('kind-filter').value = 'all'; $('status-filter').value = 'all';
|
|
712
725
|
state.follow = false; $('follow-latest').checked = false; scheduleRender();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Embedded version of frontend/components/team/BotAvatar.tsx. Keep the seed,
|
|
2
|
+
// palettes and SVG geometry identical so an agent looks the same in both apps.
|
|
3
|
+
// Only generated numbers and fixed shapes enter the SVG; no remote images.
|
|
4
|
+
function hashSeed(seed) {
|
|
5
|
+
let h = 2166136261 >>> 0;
|
|
6
|
+
for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
7
|
+
return h >>> 0;
|
|
8
|
+
}
|
|
9
|
+
function mulberry32(seed) {
|
|
10
|
+
let a = seed >>> 0;
|
|
11
|
+
return () => {
|
|
12
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
13
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
14
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
15
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const catalogHues = { sentinel: 0, forge: 40, pulse: 80, nova: 120, sage: 160, atlas: 200, vera: 240, relay: 280, quill: 320 };
|
|
19
|
+
function botSpec(seed) {
|
|
20
|
+
const h = Object.hasOwn(catalogHues, seed) ? catalogHues[seed] : hashSeed(seed + '#hue') % 360;
|
|
21
|
+
const v = hashSeed(seed + '#tone') % 97, saturation = 58 + v % 20, lightness = 50 + v % 9;
|
|
22
|
+
const rng = mulberry32(hashSeed(seed));
|
|
23
|
+
const pick = choices => choices[Math.floor(rng() * choices.length)];
|
|
24
|
+
return {
|
|
25
|
+
p: {
|
|
26
|
+
tile: `hsl(${h} ${Math.min(saturation + 6, 84)}% 90%)`,
|
|
27
|
+
body: `hsl(${h} ${saturation}% ${lightness}%)`,
|
|
28
|
+
dark: `hsl(${h} ${Math.min(saturation + 4, 82)}% ${lightness - 17}%)`,
|
|
29
|
+
face: `hsl(${h} 48% 97%)`,
|
|
30
|
+
accent: `hsl(${(h + 150 + v % 61) % 360} 82% 56%)`,
|
|
31
|
+
},
|
|
32
|
+
headRx: pick([14, 20, 26, 26]),
|
|
33
|
+
antenna: pick(['none', 'single', 'double', 'dish']),
|
|
34
|
+
eyes: pick(['round', 'square', 'visor', 'cyclops', 'happy']),
|
|
35
|
+
mouth: pick(['grid', 'smile', 'flat', 'dots', 'wave']),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function botAvatarSvg(seed) {
|
|
40
|
+
const spec = botSpec(String(seed || 'bot')), { p } = spec;
|
|
41
|
+
const element = (tag, attributes, children = '') => `<${tag} ${Object.entries(attributes).map(([key, value]) => `${key}="${value}"`).join(' ')}>${children}</${tag}>`;
|
|
42
|
+
const circle = (cx, cy, r, fill, more = {}) => element('circle', { cx, cy, r, fill, ...more });
|
|
43
|
+
const rect = (x, y, width, height, rx, fill) => element('rect', { x, y, width, height, rx, fill });
|
|
44
|
+
const line = (x1, y1, x2, y2, more = {}) => element('line', { x1, y1, x2, y2, ...more });
|
|
45
|
+
const stroke = (color, width) => ({ stroke: color, 'stroke-width': width, 'stroke-linecap': 'round' });
|
|
46
|
+
const antenna = {
|
|
47
|
+
single: element('g', stroke(p.dark, 3), line(50, 26, 50, 15) + circle(50, 12, 4, p.accent, { stroke: 'none' })),
|
|
48
|
+
double: element('g', stroke(p.dark, 2.6), line(40, 26, 37, 16) + line(60, 26, 63, 16) + circle(36, 14, 3.2, p.accent, { stroke: 'none' }) + circle(64, 14, 3.2, p.accent, { stroke: 'none' })),
|
|
49
|
+
dish: element('g', {}, line(50, 26, 50, 16, stroke(p.dark, 3)) + element('ellipse', { cx: 50, cy: 14, rx: 10, ry: 3.4, fill: p.dark }) + circle(50, 12, 2.4, p.accent)),
|
|
50
|
+
}[spec.antenna] || '';
|
|
51
|
+
const eyes = {
|
|
52
|
+
round: circle(42, 48, 5, p.dark) + circle(58, 48, 5, p.dark) + circle(43.4, 46.6, 1.6, '#fff') + circle(59.4, 46.6, 1.6, '#fff'),
|
|
53
|
+
square: rect(37, 43, 9, 9, 2.5, p.dark) + rect(54, 43, 9, 9, 2.5, p.dark),
|
|
54
|
+
visor: element('g', {}, rect(36, 44, 28, 9, 4.5, p.dark) + circle(44, 48.5, 2.4, p.accent) + circle(56, 48.5, 2.4, p.accent)),
|
|
55
|
+
cyclops: circle(50, 48, 8, p.dark) + circle(50, 48, 3.4, p.accent),
|
|
56
|
+
happy: element('g', { ...stroke(p.dark, 3), fill: 'none' }, element('path', { d: 'M37 49 q5 -6 10 0' }) + element('path', { d: 'M53 49 q5 -6 10 0' })),
|
|
57
|
+
}[spec.eyes];
|
|
58
|
+
const mouth = {
|
|
59
|
+
grid: element('g', {}, rect(42, 58, 16, 6.5, 2, p.dark) + [47, 50, 53].map(x => line(x, 58, x, 64.5, { stroke: p.face, 'stroke-width': 1.2 })).join('')),
|
|
60
|
+
smile: element('path', { d: 'M42 59 q8 7 16 0', ...stroke(p.dark, 3), fill: 'none' }),
|
|
61
|
+
flat: rect(43, 60.5, 14, 3, 1.5, p.dark),
|
|
62
|
+
dots: element('g', { fill: p.dark }, [44, 50, 56].map(cx => element('circle', { cx, cy: 61, r: 1.8 })).join('')),
|
|
63
|
+
wave: element('path', { d: 'M42 61 q3 -4 6 0 t6 0', ...stroke(p.dark, 2.6), fill: 'none' }),
|
|
64
|
+
}[spec.mouth];
|
|
65
|
+
return element('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 100 100' }, rect(0, 0, 100, 100, 24, p.tile) + antenna + rect(18, 46, 5, 16, 2.5, p.dark) + rect(77, 46, 5, 16, 2.5, p.dark) + rect(24, 26, 52, 50, spec.headRx, p.body) + rect(31, 35, 38, 32, 9, p.face) + eyes + mouth);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function botAvatarUrl(seed) {
|
|
69
|
+
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(botAvatarSvg(seed));
|
|
70
|
+
}
|
package/studio/style.css
CHANGED
|
@@ -82,8 +82,14 @@
|
|
|
82
82
|
.agent-list { display: flex; flex-direction: column; gap: 5px; }
|
|
83
83
|
.agent-item { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; border: 0; background: transparent; padding: 10px; border-radius: var(--radius-md); corner-shape: squircle; }
|
|
84
84
|
.agent-item:hover { background: var(--hover); }
|
|
85
|
-
.agent-item.is-selected { background: var(--
|
|
85
|
+
.agent-item.is-selected { background: color-mix(in oklab, var(--ink) 8%, transparent); box-shadow: inset 0 0 0 1px var(--border); }
|
|
86
|
+
.agent-selection { display: grid; place-items: center; width: 15px; flex-shrink: 0; visibility: hidden; }
|
|
87
|
+
.agent-selection .icon { width: 14px; height: 14px; }
|
|
88
|
+
.agent-item.is-selected .agent-selection { visibility: visible; }
|
|
86
89
|
.agent-avatar { width: 31px; height: 31px; display: grid; place-items: center; position: relative; flex-shrink: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; corner-shape: squircle; color: var(--muted); font-size: 12px; font-weight: 500; }
|
|
90
|
+
.agent-avatar.has-image { border: 0; background: transparent; }
|
|
91
|
+
.bot-avatar { display: block; flex-shrink: 0; }
|
|
92
|
+
.fanout-identity { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 0; max-width: 100%; }
|
|
87
93
|
.avatar-status { position: absolute; width: 7px; height: 7px; right: -1px; bottom: -1px; border-radius: 50%; border: 2px solid var(--background); box-sizing: content-box; background: var(--subtle); }
|
|
88
94
|
.avatar-status.online { background: var(--success); }
|
|
89
95
|
.agent-text { flex: 1; min-width: 0; }
|