Files
orca/src/shared/native-chat/input/native-chat-session-option-state.ts
T

188 lines
5.8 KiB
TypeScript

import type { AgentType } from '../../agent-status-types'
import type {
CatalogMidSessionApply,
AgentSessionOptionCatalog
} from '../../agent-session-option-catalog-types'
import type { SessionOptionValue, SessionOptionValueSource } from './native-chat-session-options'
export type TrackedNativeChatSessionOption = {
value: SessionOptionValue
source: Exclude<SessionOptionValueSource, 'unknown'>
}
export type NativeChatSessionOptionRecord = {
agent: AgentType
model?: TrackedNativeChatSessionOption
valuesByModel: Record<string, Record<string, TrackedNativeChatSessionOption>>
}
export function createNativeChatSessionOptionRecord(
agent: AgentType
): NativeChatSessionOptionRecord {
return { agent, valuesByModel: {} }
}
export function cloneNativeChatSessionOptionRecord(
record: NativeChatSessionOptionRecord
): NativeChatSessionOptionRecord {
return {
agent: record.agent,
...(record.model ? { model: { ...record.model } } : {}),
valuesByModel: Object.fromEntries(
Object.entries(record.valuesByModel).map(([modelId, values]) => [
modelId,
Object.fromEntries(Object.entries(values).map(([id, tracked]) => [id, { ...tracked }]))
])
)
}
}
export function isFlipOnlyMidSession(
midSession: CatalogMidSessionApply | undefined
): midSession is Extract<CatalogMidSessionApply, { kind: 'toggle-command' }> {
return midSession?.kind === 'toggle-command'
}
export function getTrackedSessionOption(
record: NativeChatSessionOptionRecord,
modelId: string | null,
optionId: string
): TrackedNativeChatSessionOption | undefined {
return modelId ? record.valuesByModel[modelId]?.[optionId] : undefined
}
export function clearTrackedSessionOption(
record: NativeChatSessionOptionRecord,
modelId: string | null,
optionId: string
): void {
if (!modelId) {
return
}
const current = record.valuesByModel[modelId]
if (!current || !(optionId in current)) {
return
}
const next = { ...current }
delete next[optionId]
if (Object.keys(next).length === 0) {
delete record.valuesByModel[modelId]
} else {
record.valuesByModel[modelId] = next
}
}
export function clearNativeChatSessionModel(record: NativeChatSessionOptionRecord): void {
const modelId = typeof record.model?.value === 'string' ? record.model.value : null
record.model = undefined
if (modelId) {
delete record.valuesByModel[modelId]
}
}
export function setTrackedSessionOption(
record: NativeChatSessionOptionRecord,
optionId: string,
value: SessionOptionValue,
source: TrackedNativeChatSessionOption['source'],
/** Picker model when none is tracked; retain options dispatched under the CLI default. */
fallbackModelId: string | null = null
): string | null {
if (optionId === 'model') {
record.model = { value, source }
return typeof value === 'string' ? value : null
}
const modelId =
(typeof record.model?.value === 'string' ? record.model.value : null) ?? fallbackModelId
if (!modelId) {
return null
}
record.valuesByModel[modelId] = {
...record.valuesByModel[modelId],
[optionId]: { value, source }
}
return modelId
}
export function flattenNativeChatSessionOptionRecord(
record: NativeChatSessionOptionRecord,
modelId: string
): Record<string, SessionOptionValue> {
return {
model: modelId,
...Object.fromEntries(
Object.entries(record.valuesByModel[modelId] ?? {}).map(([id, tracked]) => [
id,
tracked.value
])
)
}
}
export function applyNativeChatReportedSessionOptions(
record: NativeChatSessionOptionRecord,
values: Record<string, SessionOptionValue>,
/** Provider-confirmed ids; omitted means all values are read-back reports. */
confirmed?: readonly string[]
): boolean {
const sourceFor = (id: string): TrackedNativeChatSessionOption['source'] =>
confirmed === undefined || confirmed.includes(id) ? 'reported' : 'dispatched'
const modelId = typeof values.model === 'string' ? values.model : null
if (!modelId) {
return false
}
const modelChanged = record.model?.value !== modelId
let changed = modelChanged || record.model?.source !== sourceFor('model')
record.model = { value: modelId, source: sourceFor('model') }
const modelValues = modelChanged ? {} : { ...record.valuesByModel[modelId] }
for (const [id, value] of Object.entries(values)) {
if (id === 'model') {
continue
}
const current = modelValues[id]
if (current?.value !== value || current.source !== sourceFor(id)) {
changed = true
}
modelValues[id] = { value, source: sourceFor(id) }
}
record.valuesByModel[modelId] = modelValues
return changed
}
/** Match reported model by exact id, label, then longest contained id; retain raw reports for unseeded catalogs. Callers may keep unlisted selectors without fuzzy matching. */
export function matchNativeChatCatalogModelId(
catalog: AgentSessionOptionCatalog,
reported: string,
options?: { allowUnlisted?: boolean }
): string | null {
const normalized = reported.trim().toLowerCase()
if (!normalized) {
return null
}
// OMP seeds no models; its reported provider/id is already the exact reusable CLI selector.
if (catalog.models.length === 0) {
return reported.trim()
}
const exact = catalog.models.find((model) => model.id.toLowerCase() === normalized)
if (exact) {
return exact.id
}
const byLabel = catalog.models.find((model) => model.label.toLowerCase() === normalized)
if (byLabel) {
return byLabel.id
}
if (options?.allowUnlisted && !/\s/.test(normalized)) {
return reported.trim()
}
let containingId: string | null = null
for (const model of catalog.models) {
if (
(containingId === null || model.id.length > containingId.length) &&
normalized.includes(model.id.toLowerCase())
) {
containingId = model.id
}
}
return containingId
}