Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/366-model-labels.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
You can now see which model each turn used and which model each Bot runs on: a Timeline turn's tooltip ends with the model that turn ran on (`· deepseek-v4-pro`), and hovering a Bot in the sidebar tree or Bot list shows its model (`Coder · DeepSeek V4 Pro`). Nothing new stores keys; models are still chosen per Bot in its settings ([#366](https://github.com/ThinkFlowLab/FlyBot/issues/366)).
21 changes: 13 additions & 8 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2677,6 +2677,17 @@ function faceRowTitle(title) {
if (named) return `${named[2]} · ${named[1]}`;
return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text;
}
function modelOf(bot, roster) {
const ref = bot.model ?? bot.appliedModel;
if (!ref) return void 0;
const served = (roster.models ?? []).find((model) => model.ref === ref);
const [provider, ...rest] = ref.split("/");
return { ref, name: served?.name ?? rest.join("/"), provider: served?.providerName ?? provider, served: served !== void 0 };
}
function botRowTitle(name, model, wide) {
if (model) return `${name} · ${model}`;
return wide ? void 0 : name;
}
function awayGroups(groups, roomsById) {
return (groups ?? []).filter((entry) => roomsById?.[entry.id]?.origin !== "face-to-face");
}
Expand Down Expand Up @@ -6950,8 +6961,9 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio
className: "bt-row",
"aria-label": label,
"aria-current": active ? "page" : void 0,
// An own Bot's hover text names its model (#366); a friend's Bot runs on its owner's model.
"data-bot-id": id,
title: remoteRecord2 ? shown : wide ? void 0 : bot.name,
title: remoteRecord2 ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide),
onClick: () => actions.openSession(id),
onContextMenu
},
Expand Down Expand Up @@ -8800,13 +8812,6 @@ function groupModels(models) {
});
}
var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref);
function modelOf(bot, roster) {
const ref = bot.model ?? bot.appliedModel;
if (!ref) return void 0;
const served = servedOf(roster, ref);
const [provider, ...rest] = ref.split("/");
return { ref, name: served?.name ?? rest.join("/"), provider: served?.providerName ?? provider, served: served !== void 0 };
}
var effortCache = /* @__PURE__ */ new Map();
function useEfforts(served, ref, actions) {
const [fetched, setFetched] = (0, import_react16.useState)(void 0);
Expand Down
12 changes: 3 additions & 9 deletions core/src/client/model-menu.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createElement as h, Fragment, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { createPortal } from 'react-dom'
import { currentPart } from './text.js'
import { currentPart, modelOf } from './text.js'
import { CheckIcon, SlidersIcon } from './icons.js'
import { BotMark } from './mark.js'
import { openHarnessModels, openHarnessOnboarding } from './sidebar.js'
Expand Down Expand Up @@ -33,14 +33,8 @@ export function groupModels(models) {

const servedOf = (roster, ref) => (roster.models ?? []).find(model => model.ref === ref)

// What a Bot runs on, for display: the chosen model, else the one it last ran on.
export function modelOf(bot, roster) {
const ref = bot.model ?? bot.appliedModel
if (!ref) return undefined
const served = servedOf(roster, ref)
const [provider, ...rest] = ref.split('/')
return { ref, name: served?.name ?? rest.join('/'), provider: served?.providerName ?? provider, served: served !== undefined }
}
// `modelOf` lives in text.js so the sidebar can name a Bot's model without this module (#366).
export { modelOf }

// The thinking levels of one model, `{efforts, defaultEffort?}` or undefined when it
// has none. The catalog carries them for models Bots use; any other ref asks the host.
Expand Down
5 changes: 3 additions & 2 deletions core/src/client/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Component, createElement as h, Fragment, useEffect, useLayoutEffect, us
import { createPortal } from 'react-dom'
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
import { isMainOf } from './sources.js'
import { awayGroups, currentPart, faceRowTitle, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js'
import { awayGroups, botRowTitle, currentPart, faceRowTitle, modelOf, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js'
import { PlusIcon, SearchIcon, GaugeIcon, PlugIcon, ClockIcon, GearIcon, PinMarkIcon, ConnectorIcon, SwitchIcon, CloudDownloadIcon, FlyBotMark } from './icons.js'
import { MAIN_LOOK } from './characters.js'
import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js'
Expand Down Expand Up @@ -90,7 +90,8 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio
: wide && unread && !active ? h('span', { className: 'bt-unread', 'aria-hidden': true }) : null
const row = h('button', {
type: 'button', className: 'bt-row', 'aria-label': label, 'aria-current': active ? 'page' : undefined,
'data-bot-id': id, title: remoteRecord ? shown : wide ? undefined : bot.name, onClick: () => actions.openSession(id), onContextMenu,
// An own Bot's hover text names its model (#366); a friend's Bot runs on its owner's model.
'data-bot-id': id, title: remoteRecord ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), onClick: () => actions.openSession(id), onContextMenu,
},
h(BotAvatar, { bot, size: 36, state, main }),
pinned && !wide ? h(PinMark) : null,
Expand Down
16 changes: 16 additions & 0 deletions core/src/client/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,22 @@ export function faceRowTitle(title) {
return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text
}

// What a Bot runs on, for display: the chosen model, else the one it last ran on.
export function modelOf(bot, roster) {
const ref = bot.model ?? bot.appliedModel
if (!ref) return undefined
const served = (roster.models ?? []).find(model => model.ref === ref)
const [provider, ...rest] = ref.split('/')
return { ref, name: served?.name ?? rest.join('/'), provider: served?.providerName ?? provider, served: served !== undefined }
}

// A Bot row's hover title names the model the Bot runs on (#366): "Coder · DeepSeek V4 Pro".
// Without a model, a wide row needs none (its name is on it) and a narrow one names the Bot.
export function botRowTitle(name, model, wide) {
if (model) return `${name} · ${model}`
return wide ? undefined : name
}

// The group chats whose work shows on a Bot's row as "<group> · Thinking…". A face room has its
// own row, so its turns never take over the Bot's preview (#458).
export function awayGroups(groups, roomsById) {
Expand Down
31 changes: 29 additions & 2 deletions core/src/host/flybot-feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ export function installFeed(rt, fire) {
if (entry === undefined) sessions.set(id, entry = { calls: new Map() })
return entry
}
// The model each Session's latest request header named (`provider/model`). DSH logs a header
// only when it changes, so the last one seen is the one every later step runs on.
/** @type {Map<string, string>} */
const models = new Map()
const refOf = config => (typeof config?.provider === 'string' && typeof config?.model === 'string' && config.provider && config.model
? cleanLabel(`${config.provider}/${config.model}`, 120) : undefined)
// The model a turn ran on: only a turn that made a model call has one. A friend's Bot chat runs
// no model of yours. Before this process saw a header, the Session's own latest header answers.
const turnModel = (session, id, entry, where) => {
if (!entry.stepped || where.place === 'remote') return undefined
if (!models.has(id)) {
let ref
try { ref = refOf(session?.requestHeader?.()?.config) } catch { /* no header yet */ }
if (ref !== undefined) models.set(id, ref)
}
return models.get(id)
}

// What the user calls a friend's Bot ("Chief (UiA1415)", "Helper (UiB1415)", #384), as the sidebar
// does, from the published roster or the friend's chat. Untrusted (a peer chose it): cleaned, capped.
Expand Down Expand Up @@ -170,14 +187,23 @@ export function installFeed(rt, fire) {
// A turn that never ended (a crash) is closed by the next one.
if (entry.turn !== undefined) {
closeCalls(id, entry, where, turnId, timeOf(event), true)
fire('turn.end', omitUndefined({ ...base, turnId, turn: entry.turn, time: timeOf(event), outcome: 'aborted', interrupted: true }))
fire('turn.end', omitUndefined({ ...base, turnId, turn: entry.turn, time: timeOf(event), outcome: 'aborted', interrupted: true, model: turnModel(session, id, entry, where) }))
}
entry.turn = turn
entry.stepped = false
const trigger = triggerOf(entry.pending)
entry.pending = undefined
fire('turn.start', omitUndefined({ ...base, turnId: `${id}#${turn}`, turn, time: timeOf(event), seq: seqOf(event), trigger }))
return
}
case 'step/start':
if (entry.turn !== undefined) entry.stepped = true
return
case 'request/header': {
const ref = refOf(event.data?.header?.config)
if (ref !== undefined) models.set(id, ref)
return
}
case 'tool/call': {
const callId = cleanId(event.data?.callId)
if (callId === undefined || entry.turn === undefined) return
Expand All @@ -203,8 +229,9 @@ export function installFeed(rt, fire) {
const kind = event.data?.reason?.kind
const outcome = OUTCOMES.has(kind) ? kind : 'completed'
const errorCode = outcome === 'error' ? cleanCode(event.data?.reason?.error?.code) : undefined
fire('turn.end', omitUndefined({ ...base, turnId, turn: entry.turn, time, seq: seqOf(event), outcome, errorCode }))
fire('turn.end', omitUndefined({ ...base, turnId, turn: entry.turn, time, seq: seqOf(event), outcome, errorCode, model: turnModel(session, id, entry, where) }))
entry.turn = undefined
entry.stepped = false
if (entry.pending === undefined) sessions.delete(id)
return
}
Expand Down
28 changes: 28 additions & 0 deletions core/test/bot-row-model.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { botRowTitle, modelOf } from '../src/client/text.js'

test('#366: a Bot row\'s hover title names the model it runs on', () => {
assert.equal(botRowTitle('Coder', 'DeepSeek V4 Pro', true), 'Coder · DeepSeek V4 Pro')
assert.equal(botRowTitle('Coder', 'DeepSeek V4 Pro', false), 'Coder · DeepSeek V4 Pro')
// No model yet: a wide row shows its name already, a narrow (avatar-only) row still names the Bot.
assert.equal(botRowTitle('Coder', undefined, true), undefined)
assert.equal(botRowTitle('Coder', '', false), 'Coder')
})

test('#366: the model a row names is the served name of the chosen model, else the one it last ran on', () => {
const roster = { models: [{ ref: 'deepseek/deepseek-v4-pro', name: 'DeepSeek V4 Pro', providerName: 'DeepSeek' }] }
assert.equal(modelOf({ model: 'deepseek/deepseek-v4-pro' }, roster).name, 'DeepSeek V4 Pro')
assert.equal(modelOf({ appliedModel: 'deepseek/deepseek-v4-pro' }, roster).name, 'DeepSeek V4 Pro')
// A model no provider serves anymore is still named, by its id.
assert.deepEqual(modelOf({ model: 'kimi/kimi-k2' }, roster), { ref: 'kimi/kimi-k2', name: 'kimi-k2', provider: 'kimi', served: false })
assert.equal(modelOf({}, roster), undefined)
})

test('#366: every own Bot row (the tree draws the same row) takes that title; a friend\'s Bot keeps its own', () => {
const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8')
const row = sidebar.slice(sidebar.indexOf('function BotRow('), sidebar.indexOf('function RemoteMark('))
assert.match(row, /className: 'bt-row', [^\n]*\n[^\n]*\n\s*'data-bot-id': id, title: remoteRecord \? shown : botRowTitle\(shown, modelOf\(bot, roster\)\?\.name, wide\), onClick/)
assert.match(sidebar, /function TreeNode\([\s\S]*?h\(BotRow, \{ \.\.\.rowProps, bot, asRow: true/)
})
55 changes: 55 additions & 0 deletions core/test/flybot-feed.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,58 @@ test('a friend\'s request into a team Bot names the friend as the sidebar does,
emit('a', 'user/message', { source: { kind: 'bot', form: 'relay', role: 'request', exchangeId: 'q2', hop: 0, remote: { handle: '@stranger.zed.home' } }, content: [] }, 7100)
assert.deepEqual(only(feed, 'exchange.delivered')[1].from, { kind: 'remote', handle: '@stranger.zed.home' })
})

const header = (provider, model) => ({ header: { config: { provider, model } }, reason: 'change' })

test('turn.end names the model of the turn\'s latest request header (#366)', async (t) => {
const { feed, emit, stop } = await boot()
t.after(stop)
emit('a', 'turn/start', { turn: 1 })
emit('a', 'step/start', { turn: 1, step: 1 })
emit('a', 'request/header', header('deepseek', 'deepseek-v4-pro'))
emit('a', 'turn/end', { turn: 1, reason: { kind: 'completed' } })
// DSH logs no new header while the model stays: the next turn still ran on it.
emit('a', 'turn/start', { turn: 2 })
emit('a', 'step/start', { turn: 2, step: 1 })
emit('a', 'turn/end', { turn: 2, reason: { kind: 'completed' } })
// A change of model mid-session is the next turn's label, not the earlier ones'.
emit('a', 'turn/start', { turn: 3 })
emit('a', 'step/start', { turn: 3, step: 1 })
emit('a', 'request/header', header('kimi', 'kimi-k2'))
emit('a', 'turn/end', { turn: 3, reason: { kind: 'error', error: { code: 'RATE_LIMIT' } } })
assert.deepEqual(only(feed, 'turn.end').map(end => end.model), ['deepseek/deepseek-v4-pro', 'deepseek/deepseek-v4-pro', 'kimi/kimi-k2'])
assert.equal(only(feed, 'turn.start').some(start => 'model' in start), false)
})

test('turn.end: no model for a turn without a model call, and the Session\'s own header before the feed saw one (#366)', async (t) => {
const { ctx, feed, stop } = await boot()
t.after(stop)
let seq = 0
const session = { id: 'b', requestHeader: () => ({ config: { provider: 'deepseek', model: 'deepseek-v4-flash' } }) }
const emit = (type, data) => ctx.emitArgs('session/event', session, { type, seq: ++seq, time: 5000 + seq, data })
emit('turn/start', { turn: 1 })
emit('turn/end', { turn: 1, reason: { kind: 'aborted' } })
emit('turn/start', { turn: 2 })
emit('step/start', { turn: 2, step: 1 })
emit('turn/end', { turn: 2, reason: { kind: 'completed' } })
const ends = only(feed, 'turn.end')
assert.equal('model' in ends[0], false)
assert.equal(ends[1].model, 'deepseek/deepseek-v4-flash')
})

test('negative: a model name is cleaned, and a friend\'s Bot chat never names one (#366)', async (t) => {
const { feed, emit, stop } = await boot()
t.after(stop)
emit('a', 'turn/start', { turn: 1 })
emit('a', 'step/start', { turn: 1, step: 1 })
emit('a', 'request/header', header('deepseek', 'v4\n<untrusted-message from="x">'))
emit('a', 'turn/end', { turn: 1, reason: { kind: 'completed' } })
emit('rc', 'turn/start', { turn: 1 })
emit('rc', 'step/start', { turn: 1, step: 1 })
emit('rc', 'request/header', header('flybot-remote', '@helper.uib.home'))
emit('rc', 'turn/end', { turn: 1, reason: { kind: 'completed' } })
const [mine, remote] = only(feed, 'turn.end')
assert.doesNotMatch(mine.model, /untrusted|\n/i)
assert.equal(remote.place, 'remote')
assert.equal('model' in remote, false)
})
5 changes: 5 additions & 0 deletions core/types/flybot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ export interface TurnEnded extends FeedPlace {
errorCode?: string
/** True when the turn never reported its end and a newer turn closed it. */
interrupted?: boolean
/**
* `provider/model` of the turn's latest request header: the model it ran on. Absent for a
* turn that made no model call, for place 'remote', and when DSH logged no header.
*/
model?: string
}

export interface ToolStarted extends FeedPlace {
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second

### Bot tree chevron

- The sidebar Bot-list tree (`sidebar.js` `TreeNode`, from slot `flybot.sidebar.structure`; optional `include(botId)` filters the default list, #452) draws collapse/expand as `.bt-tree-toggle`: a 28×28 hit target with a 16px `.bt-tree-chev` glyph, hover background, and a `:focus-visible` accent ring. The glyph rotates when `aria-expanded` is false; the button stays upright so hover and focus stay square. Expand/Collapse `{name}` labels and `aria-expanded` are unchanged. Leaf rows use the same 28px width as a spacer (`.bt-tree-leaf`) so names stay aligned; indent is still `depth * 16px` and the Bot row keeps its height.
- The sidebar Bot-list tree (`sidebar.js` `TreeNode`, from slot `flybot.sidebar.structure`; optional `include(botId)` filters the default list, #452) draws collapse/expand as `.bt-tree-toggle`: a 28×28 hit target with a 16px `.bt-tree-chev` glyph, hover background, and a `:focus-visible` accent ring. The glyph rotates when `aria-expanded` is false; the button stays upright so hover and focus stay square. Expand/Collapse `{name}` labels and `aria-expanded` are unchanged. Leaf rows use the same 28px width as a spacer (`.bt-tree-leaf`) so names stay aligned; indent is still `depth * 16px` and the Bot row keeps its height. An own Bot's row, in the tree and in the flat list, names the model the Bot runs on in its hover text (`botRowTitle` in `text.js`: `Coder · DeepSeek V4 Pro`, the model's served name from `modelOf`, also in `text.js` so the sidebar does not import the model menu, #366); a friend's Bot row keeps its `remoteBotTitle`, since it runs on its owner's model.

### New-chat menu

Expand Down
Loading
Loading