Merge branch 'stack/agent-profiles-8-authoring' into stack/agent-profiles-9-rename
This commit is contained in:
@@ -51,7 +51,13 @@ export const inject = ['slots', 'locale', 'connection']
|
|||||||
export function apply(ctx: ClientContext): void {
|
export function apply(ctx: ClientContext): void {
|
||||||
const { api } = ctx.get('connection') as ConnectionHandle
|
const { api } = ctx.get('connection') as ConnectionHandle
|
||||||
const controller = new AgentPresetSettingsController(api)
|
const controller = new AgentPresetSettingsController(api)
|
||||||
const section = new AgentPresetSectionController(api)
|
// One roster, four surfaces. The chip is registered in a later scope, so it
|
||||||
|
// subscribes here rather than being reached from this one.
|
||||||
|
const rosterReaders = new Set<() => void>()
|
||||||
|
const section = new AgentPresetSectionController(api, () => {
|
||||||
|
void controller.load()
|
||||||
|
for (const read of rosterReaders) read()
|
||||||
|
})
|
||||||
|
|
||||||
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
|
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
|
||||||
|
|
||||||
@@ -119,6 +125,12 @@ export function apply(ctx: ClientContext): void {
|
|||||||
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
|
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||||
void seat.load()
|
void seat.load()
|
||||||
})
|
})
|
||||||
|
// Authoring writes a FILE, not a setting, so nothing on the wire
|
||||||
|
// announces it — without this the screen that starts the next session
|
||||||
|
// keeps offering the roster as it stood when the chip first loaded, and
|
||||||
|
// a preset authored to be used is missing from the one place it is used.
|
||||||
|
const readRoster = (): void => { void seat.load() }
|
||||||
|
rosterReaders.add(readRoster)
|
||||||
const chip = scope.slots.register({
|
const chip = scope.slots.register({
|
||||||
name: 'conversation.hero.agentPreset',
|
name: 'conversation.hero.agentPreset',
|
||||||
locale: 'settings.agentPreset',
|
locale: 'settings.agentPreset',
|
||||||
@@ -134,6 +146,7 @@ export function apply(ctx: ClientContext): void {
|
|||||||
return () => {
|
return () => {
|
||||||
stop()
|
stop()
|
||||||
settingsMoved()
|
settingsMoved()
|
||||||
|
rosterReaders.delete(readRoster)
|
||||||
chip()
|
chip()
|
||||||
label()
|
label()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,17 @@ export class AgentPresetSectionController {
|
|||||||
/** Page snapshot the renderer subscribes to. */
|
/** Page snapshot the renderer subscribes to. */
|
||||||
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
|
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
|
||||||
|
|
||||||
constructor(private readonly api: Pick<IApiClient, 'agentPresets' | 'settings'>) {}
|
constructor(
|
||||||
|
private readonly api: Pick<IApiClient, 'agentPresets' | 'settings'>,
|
||||||
|
/**
|
||||||
|
* Called after this page changes the roster DIRECTORY, so the other
|
||||||
|
* surfaces reading the same roster re-read it. A settings field moving is
|
||||||
|
* already announced by the host through `settings/changed`; a file written
|
||||||
|
* or deleted here is not, and the new-session chip has no other way to
|
||||||
|
* learn a preset it should offer now exists.
|
||||||
|
*/
|
||||||
|
private readonly rosterChanged: () => void = () => {},
|
||||||
|
) {}
|
||||||
|
|
||||||
private set(patch: Partial<AgentPresetSectionState>): void {
|
private set(patch: Partial<AgentPresetSectionState>): void {
|
||||||
this.store.set({ ...this.store.getSnapshot(), ...patch })
|
this.store.set({ ...this.store.getSnapshot(), ...patch })
|
||||||
@@ -265,6 +275,7 @@ export class AgentPresetSectionController {
|
|||||||
}
|
}
|
||||||
this.set({ draft: null })
|
this.set({ draft: null })
|
||||||
await this.load()
|
await this.load()
|
||||||
|
this.rosterChanged()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.patchDraft({ saving: false, error: messageOf(error) })
|
this.patchDraft({ saving: false, error: messageOf(error) })
|
||||||
}
|
}
|
||||||
@@ -303,6 +314,7 @@ export class AgentPresetSectionController {
|
|||||||
draft: draft?.id === pendingDelete && !draft.creating ? null : draft,
|
draft: draft?.id === pendingDelete && !draft.creating ? null : draft,
|
||||||
})
|
})
|
||||||
await this.load()
|
await this.load()
|
||||||
|
this.rosterChanged()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.set({ deleting: false, pendingDelete: null, error: messageOf(error) })
|
this.set({ deleting: false, pendingDelete: null, error: messageOf(error) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ const ROSTER_ONE = {
|
|||||||
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
|
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The roster after this browser authored one preset of its own. */
|
||||||
|
const ROSTER_AUTHORED = {
|
||||||
|
rpcId: 'r',
|
||||||
|
result: {
|
||||||
|
ok: true as const,
|
||||||
|
value: {
|
||||||
|
presets: [
|
||||||
|
{ id: 'standard', trust: 'system', isDefault: true },
|
||||||
|
{ id: 'mine', trust: 'user', isDefault: false },
|
||||||
|
],
|
||||||
|
authorable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/** The same roster with a second preset carrying the default. */
|
/** The same roster with a second preset carrying the default. */
|
||||||
const ROSTER_MOVED = {
|
const ROSTER_MOVED = {
|
||||||
rpcId: 'r',
|
rpcId: 'r',
|
||||||
@@ -49,7 +64,7 @@ async function bench() {
|
|||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
// The host's answer, mutable so a spec can move the default the way the
|
// The host's answer, mutable so a spec can move the default the way the
|
||||||
// settings surface does and watch who re-reads it.
|
// settings surface does and watch who re-reads it.
|
||||||
let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED = ROSTER_ONE
|
let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED | typeof ROSTER_AUTHORED = ROSTER_ONE
|
||||||
const moveDefault = (): void => { ROSTER = ROSTER_MOVED }
|
const moveDefault = (): void => { ROSTER = ROSTER_MOVED }
|
||||||
await ctx.plugin(SlotsService).await()
|
await ctx.plugin(SlotsService).await()
|
||||||
const locale = new LocaleService(ctx)
|
const locale = new LocaleService(ctx)
|
||||||
@@ -63,7 +78,13 @@ async function bench() {
|
|||||||
rpcId: 'r',
|
rpcId: 'r',
|
||||||
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '', writable: false } },
|
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '', writable: false } },
|
||||||
}),
|
}),
|
||||||
write: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }),
|
write: (payload: { agentPreset: string }) => {
|
||||||
|
calls.push(`write:${payload.agentPreset}`)
|
||||||
|
// The host's roster now contains it, which is the whole point of the
|
||||||
|
// write and what every surface must converge on.
|
||||||
|
ROSTER = ROSTER_AUTHORED
|
||||||
|
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
|
||||||
|
},
|
||||||
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
|
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
|
||||||
select: (payload: { agentPreset: string }) => {
|
select: (payload: { agentPreset: string }) => {
|
||||||
calls.push(`select:${payload.agentPreset}`)
|
calls.push(`select:${payload.agentPreset}`)
|
||||||
@@ -282,6 +303,35 @@ describe('ui-agent-preset apply', () => {
|
|||||||
conversation()
|
conversation()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('offers a just-authored preset on the new-session chip', async () => {
|
||||||
|
const { ctx, slots } = await bench()
|
||||||
|
declareRoot(slots)
|
||||||
|
const conversation = declareConversation(slots)
|
||||||
|
ctx.provide('conversation', {} as never)
|
||||||
|
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||||
|
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||||
|
|
||||||
|
const chip = slots.entries('conversation.hero.agentPreset')[0]!
|
||||||
|
const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
|
||||||
|
await seat.load()
|
||||||
|
expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard'])
|
||||||
|
|
||||||
|
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||||
|
await section.load()
|
||||||
|
await section.createFrom()
|
||||||
|
section.setId('mine')
|
||||||
|
section.setName('我的模式')
|
||||||
|
await section.save()
|
||||||
|
|
||||||
|
// Authoring writes a file rather than a setting, so nothing on the wire
|
||||||
|
// announces it: a preset authored to be used must appear on the one screen
|
||||||
|
// that starts sessions, without a reload.
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard', 'mine'])
|
||||||
|
})
|
||||||
|
conversation()
|
||||||
|
})
|
||||||
|
|
||||||
it('applies the staged choice to the blank session the flow lands on', async () => {
|
it('applies the staged choice to the blank session the flow lands on', async () => {
|
||||||
const { ctx, slots, calls } = await bench()
|
const { ctx, slots, calls } = await bench()
|
||||||
declareRoot(slots)
|
declareRoot(slots)
|
||||||
|
|||||||
Reference in New Issue
Block a user