mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
Merge pull request #1369 from MrWangBigData/main
feat(agent-teams): 显示成员模型信息 + 修复 CORS 预检 + 补 Fable 槽位
This commit is contained in:
@@ -3010,6 +3010,7 @@ describe('Settings > Providers tab', () => {
|
||||
expect(providerStoreState.createProvider).toHaveBeenCalledWith(expect.objectContaining({
|
||||
model1mSupport: {
|
||||
main: true,
|
||||
fable: false,
|
||||
haiku: false,
|
||||
sonnet: true,
|
||||
opus: false,
|
||||
@@ -3030,6 +3031,82 @@ describe('Settings > Providers tab', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('persists the Fable slot to its own env var with its own 1M marker', async () => {
|
||||
providerStoreState.createProvider = vi.fn().mockResolvedValue({
|
||||
id: 'provider-fable',
|
||||
presetId: 'custom',
|
||||
name: 'Custom',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.example.com/anthropic',
|
||||
apiFormat: 'anthropic',
|
||||
models: {
|
||||
main: 'claude-sonnet-4-6',
|
||||
fable: 'claude-fable-5',
|
||||
haiku: 'claude-sonnet-4-6',
|
||||
sonnet: 'claude-sonnet-4-6',
|
||||
opus: 'claude-sonnet-4-6',
|
||||
},
|
||||
model1mSupport: {
|
||||
main: false,
|
||||
fable: true,
|
||||
haiku: false,
|
||||
sonnet: false,
|
||||
opus: false,
|
||||
},
|
||||
})
|
||||
providerStoreState.presets = [
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom',
|
||||
baseUrl: 'https://api.example.com/anthropic',
|
||||
apiFormat: 'anthropic',
|
||||
// Fable must be settable from the form, not only by hand-editing
|
||||
// settings.json or importing a cc-switch profile.
|
||||
defaultModels: {
|
||||
main: 'claude-sonnet-4-6',
|
||||
fable: 'claude-fable-5',
|
||||
haiku: '',
|
||||
sonnet: '',
|
||||
opus: '',
|
||||
},
|
||||
needsApiKey: true,
|
||||
websiteUrl: '',
|
||||
},
|
||||
]
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add Model|添加模型/i }))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
await waitFor(() => {
|
||||
const settingsTextarea = dialog.querySelector('textarea')
|
||||
expect(settingsTextarea?.value).toContain('"ANTHROPIC_MODEL"')
|
||||
})
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('sk-...'), { target: { value: 'sk-test' } })
|
||||
|
||||
// The slot is editable from the form rather than being carried invisibly.
|
||||
// Queried by label because the input only takes the `combobox` role once a
|
||||
// model list has been fetched.
|
||||
const fableInput = within(dialog).getByLabelText(/Fable Model/i)
|
||||
expect(fableInput).toHaveValue('claude-fable-5')
|
||||
// Fable is the one slot that does not fall back to the main model, so it
|
||||
// must not advertise "Same as main" the way the tier slots do.
|
||||
expect(fableInput).toHaveAttribute('placeholder', 'Leave blank to let Claude Code choose')
|
||||
expect(within(dialog).getByLabelText(/Haiku Model/i))
|
||||
.toHaveAttribute('placeholder', 'Same as main')
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('checkbox', { name: /1M support: fable/i }))
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /Save|Add|保存|添加/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MOCK_UPDATE_SETTINGS).toHaveBeenCalledWith(expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5[1m]',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('hides the API key by default and reveals it from the eye button', () => {
|
||||
providerStoreState.presets = [
|
||||
{
|
||||
@@ -3157,7 +3234,7 @@ describe('Settings > Providers tab', () => {
|
||||
})
|
||||
})
|
||||
expect(await within(dialog).findByText(/Model list loaded \(2\)/i)).toBeInTheDocument()
|
||||
expect(within(dialog).getAllByRole('button', { name: /from the fetched list/i })).toHaveLength(4)
|
||||
expect(within(dialog).getAllByRole('button', { name: /from the fetched list/i })).toHaveLength(5)
|
||||
|
||||
// The picker supplements the field; a model id that is not on the list must
|
||||
// still be typeable. Queried by role because the picker's own accessible
|
||||
|
||||
@@ -354,4 +354,51 @@ describe('AgentTeamsCanvas', () => {
|
||||
/@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.agent-teams-task-running-fill\s*\{[^}]*opacity:\s*0;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('chips each member card with its model family and keeps the full id on hover', () => {
|
||||
const current = snapshot('current')
|
||||
const withModels: TeamWorkbenchSnapshot = {
|
||||
...current,
|
||||
team: {
|
||||
...current.team,
|
||||
members: current.team.members.map(member => (
|
||||
member.agentId === 'team-lead@canvas-team'
|
||||
? { ...member, model: 'claude-opus-4-8' }
|
||||
: member.agentId === 'builder@canvas-team'
|
||||
? { ...member, model: 'claude-sonnet-5' }
|
||||
: member
|
||||
)),
|
||||
},
|
||||
}
|
||||
|
||||
render(<AgentTeamsCanvas {...props({
|
||||
snapshots: [withModels],
|
||||
selectedIndex: 0,
|
||||
snapshot: withModels,
|
||||
previousSnapshot: undefined,
|
||||
activeMessageId: null,
|
||||
})} />)
|
||||
|
||||
// A member with its own model shows the short family, full id on hover.
|
||||
const builder = screen.getByTestId('agent-teams-canvas-member-model-builder@canvas-team')
|
||||
expect(builder.textContent).toBe('sonnet')
|
||||
expect(builder.getAttribute('title')).toBe('claude-sonnet-5')
|
||||
expect(builder.getAttribute('data-model-inherited')).toBe('false')
|
||||
|
||||
// The lead shows its own model, and a member with none inherits it.
|
||||
const lead = screen.getByTestId('agent-teams-canvas-member-model-team-lead@canvas-team')
|
||||
expect(lead.textContent).toBe('opus')
|
||||
const reviewer = screen.getByTestId('agent-teams-canvas-member-model-reviewer@canvas-team')
|
||||
expect(reviewer.textContent).toBe('opus')
|
||||
expect(reviewer.getAttribute('data-model-inherited')).toBe('true')
|
||||
})
|
||||
|
||||
it('renders no model chip at all when neither the member nor the lead has one', () => {
|
||||
render(<AgentTeamsCanvas {...props()} />)
|
||||
|
||||
const builder = screen.queryByTestId('agent-teams-canvas-member-model-builder@canvas-team')
|
||||
expect(builder).toBeNull()
|
||||
const lead = screen.queryByTestId('agent-teams-canvas-member-model-team-lead@canvas-team')
|
||||
expect(lead).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
inferTaskOwner,
|
||||
layoutWorkbenchTasks,
|
||||
parseWorkbenchMessageBody,
|
||||
resolveMemberModel,
|
||||
resolveTeamMemberIdentity,
|
||||
taskOwnedByMember,
|
||||
type MemberWorkState,
|
||||
@@ -437,6 +438,7 @@ function MemberNode({
|
||||
hasActiveMessage,
|
||||
waitingDependency,
|
||||
leadStatus,
|
||||
leadModel,
|
||||
onSelect,
|
||||
t,
|
||||
}: {
|
||||
@@ -445,10 +447,12 @@ function MemberNode({
|
||||
hasActiveMessage: boolean
|
||||
waitingDependency?: string
|
||||
leadStatus?: string
|
||||
leadModel?: string
|
||||
onSelect: () => void
|
||||
t: TranslationFn
|
||||
}) {
|
||||
const { member, isLead, centerX, accent, workState: state } = position
|
||||
const model = resolveMemberModel(member, isLead ? undefined : leadModel)
|
||||
const top = isLead ? LEAD_TOP : MEMBER_TOP
|
||||
const avatarSize = isLead ? 96 : 84
|
||||
const identityWidth = isLead ? 28 : 24
|
||||
@@ -519,6 +523,16 @@ function MemberNode({
|
||||
<span className="truncate font-mono text-[10.5px] font-extrabold text-[var(--color-text-primary)]">
|
||||
{memberName(member)}
|
||||
</span>
|
||||
{model ? (
|
||||
<span
|
||||
className="max-w-[54px] shrink-0 truncate rounded-full border border-[var(--color-border)] px-1.5 py-px text-[9px] font-extrabold text-[var(--color-text-tertiary)]"
|
||||
title={model.full}
|
||||
data-testid={`agent-teams-canvas-member-model-${member.agentId}`}
|
||||
data-model-inherited={model.inherited ? 'true' : 'false'}
|
||||
>
|
||||
{model.label}
|
||||
</span>
|
||||
) : null}
|
||||
{isLead ? (
|
||||
<span className="shrink-0 rounded-full border border-[var(--color-brand)] bg-[var(--color-brand-soft)] px-1.5 py-px text-[9px] font-extrabold text-[var(--color-brand)]">
|
||||
{t('agentTeams.leader')}
|
||||
@@ -714,6 +728,7 @@ export function AgentTeamsCanvas({
|
||||
const leadMember = uniqueMembers.find(member => member.agentId === snapshot.team.leadAgentId)
|
||||
?? uniqueMembers.find(member => aliases(member.name).includes('team-lead'))
|
||||
?? uniqueMembers[0]
|
||||
const leadModel = leadMember?.model
|
||||
const workers = uniqueMembers.filter(member => member !== leadMember)
|
||||
const naturalWidth = Math.max(CANVAS_MIN_WIDTH, 460 + Math.max(0, workers.length - 1) * MEMBER_PITCH)
|
||||
const layout = useMemo(
|
||||
@@ -1033,6 +1048,7 @@ export function AgentTeamsCanvas({
|
||||
hasActiveMessage={activeParticipants.has(position.member.agentId)}
|
||||
waitingDependency={waitingDependency}
|
||||
leadStatus={position.isLead ? leadStatusLabel(snapshot, t) : undefined}
|
||||
leadModel={leadModel}
|
||||
onSelect={() => onSelectMember(position.member, position.isLead)}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -170,4 +170,53 @@ describe('AgentTeamsMemberInspector', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenExecution).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports the model a teammate runs on, and flags one that inherits the lead', () => {
|
||||
const lead: TeamMember = {
|
||||
agentId: 'lead@team-a',
|
||||
name: 'lead',
|
||||
role: 'Lead',
|
||||
status: 'running',
|
||||
model: 'claude-opus-4-8',
|
||||
}
|
||||
const base = snapshot('2026-08-08T07:00:00.000Z', 'pending')
|
||||
const withLead: TeamWorkbenchSnapshot = {
|
||||
...base,
|
||||
team: { ...base.team, members: [lead, builder, reviewer] },
|
||||
}
|
||||
const renderMember = (member: TeamMember) => render(
|
||||
<AgentTeamsMemberInspector
|
||||
snapshots={[withLead]}
|
||||
selectedIndex={0}
|
||||
snapshot={withLead}
|
||||
member={member}
|
||||
isLead={false}
|
||||
leadIsStreaming={false}
|
||||
onBack={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onOpenExecution={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// A teammate with its own model shows the full id, not the short family.
|
||||
const own = renderMember({ ...builder, model: 'claude-haiku-4-5' })
|
||||
const ownCell = screen.getByTestId('agent-teams-member-model')
|
||||
expect(ownCell.textContent).toBe('claude-haiku-4-5')
|
||||
expect(ownCell.getAttribute('data-model-inherited')).toBe('false')
|
||||
own.unmount()
|
||||
|
||||
// One with no model of its own names where the model comes from.
|
||||
renderMember({ ...builder })
|
||||
const inheritedCell = screen.getByTestId('agent-teams-member-model')
|
||||
expect(inheritedCell.textContent).toBe('Inherit from lead · claude-opus-4-8')
|
||||
expect(inheritedCell.getAttribute('data-model-inherited')).toBe('true')
|
||||
})
|
||||
|
||||
it('never leaves the model cell blank when nothing is known', () => {
|
||||
renderInspector()
|
||||
|
||||
const cell = screen.getByTestId('agent-teams-member-model')
|
||||
expect(cell.textContent).toBe('Unknown')
|
||||
expect(cell.textContent).not.toContain('undefined')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getWorkbenchTaskState,
|
||||
inferTaskOwner,
|
||||
parseWorkbenchMessageBody,
|
||||
resolveMemberModel,
|
||||
resolveTeamMemberIdentity,
|
||||
taskOwnedByMember,
|
||||
type MemberWorkState,
|
||||
@@ -297,6 +298,8 @@ export function AgentTeamsMemberInspector({
|
||||
const workState = snapshot.deletedAt
|
||||
? 'exited'
|
||||
: getMemberWorkState(member, { isLead, leadIsStreaming })
|
||||
const leadMember = snapshot.team.members.find(candidate => candidate.agentId === snapshot.team.leadAgentId)
|
||||
const model = resolveMemberModel(member, isLead ? undefined : leadMember?.model)
|
||||
const taskHistory = useMemo(
|
||||
() => deriveTaskHistory(snapshots, selectedIndex, snapshot, member),
|
||||
[member, selectedIndex, snapshot, snapshots],
|
||||
@@ -396,6 +399,26 @@ export function AgentTeamsMemberInspector({
|
||||
</dt>
|
||||
<dd className="mt-0.5 font-extrabold tabular-nums">{messages.length}</dd>
|
||||
</div>
|
||||
{/* Model spans the full width: a model id plus an "inherited from" prefix
|
||||
does not fit a third of the drawer, and truncating it would cut the
|
||||
model name itself — the one part that matters. */}
|
||||
<div className="col-span-3 min-w-0">
|
||||
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
{t('agentTeams.model.label')}
|
||||
</dt>
|
||||
<dd
|
||||
className="mt-0.5 truncate font-extrabold"
|
||||
title={model?.full}
|
||||
data-testid="agent-teams-member-model"
|
||||
data-model-inherited={model?.inherited ? 'true' : 'false'}
|
||||
>
|
||||
{model === undefined
|
||||
? t('agentTeams.model.unknown')
|
||||
: model.inherited
|
||||
? t('agentTeams.model.inheritFromLead', { model: model.full })
|
||||
: model.full}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
inferTaskOwner,
|
||||
layoutWorkbenchTasks,
|
||||
parseWorkbenchMessageBody,
|
||||
resolveMemberModel,
|
||||
resolveTeamMemberIdentity,
|
||||
runningTaskForMember,
|
||||
shortModelLabel,
|
||||
snapshotWithHistoricalMembers,
|
||||
taskOwnedByMember,
|
||||
WORKBENCH_TASK_WIDTH,
|
||||
@@ -442,3 +444,103 @@ describe('Agent Teams workbench model', () => {
|
||||
expect(formatWorkbenchMessageTime('not-a-date')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent Teams member model display', () => {
|
||||
const worker: TeamMember = {
|
||||
agentId: 'builder@team-a',
|
||||
name: 'builder',
|
||||
role: 'Frontend',
|
||||
status: 'running',
|
||||
}
|
||||
|
||||
function memberSnapshot(generatedAt: string, members: TeamMember[]): TeamWorkbenchSnapshot {
|
||||
return {
|
||||
version: generatedAt,
|
||||
generatedAt,
|
||||
team: {
|
||||
name: 'team-a',
|
||||
leadAgentId: 'lead@team-a',
|
||||
leadSessionId: 'session-a',
|
||||
members,
|
||||
},
|
||||
tasks: [],
|
||||
messages: [],
|
||||
}
|
||||
}
|
||||
|
||||
it('shortens a full model id to its family and passes unknown ids through', () => {
|
||||
expect(shortModelLabel('claude-opus-4-8')).toBe('opus')
|
||||
expect(shortModelLabel('eu.anthropic.claude-sonnet-5')).toBe('sonnet')
|
||||
expect(shortModelLabel('claude-haiku-4-5')).toBe('haiku')
|
||||
expect(shortModelLabel('claude-fable-5')).toBe('fable')
|
||||
expect(shortModelLabel('sonnet')).toBe('sonnet')
|
||||
// An unknown family is preserved rather than forced into a known one.
|
||||
expect(shortModelLabel('gpt-5')).toBe('gpt-5')
|
||||
})
|
||||
|
||||
it('keeps the full id for the tooltip while the chip shows the short family', () => {
|
||||
expect(resolveMemberModel({ ...worker, model: 'claude-opus-4-8' }))
|
||||
.toEqual({ label: 'opus', full: 'claude-opus-4-8', inherited: false })
|
||||
})
|
||||
|
||||
it('trims the stored id so no padding leaks into the label or the tooltip', () => {
|
||||
expect(resolveMemberModel({ ...worker, model: ' claude-opus-4-8 ' }))
|
||||
.toEqual({ label: 'opus', full: 'claude-opus-4-8', inherited: false })
|
||||
})
|
||||
|
||||
it('drops the context-window tag from the chip while the tooltip keeps it', () => {
|
||||
// `[1m]` is a routing detail, and leaving it on made the chip wide enough to
|
||||
// squeeze the member name out of the card.
|
||||
expect(shortModelLabel('qwen3.7-plus[1m]')).toBe('qwen3.7-plus')
|
||||
expect(shortModelLabel('claude-opus-4-8[1m]')).toBe('opus')
|
||||
expect(resolveMemberModel({ ...worker, model: 'qwen3.7-plus[1m]' }))
|
||||
.toEqual({ label: 'qwen3.7-plus', full: 'qwen3.7-plus[1m]', inherited: false })
|
||||
// A value that is nothing but a tag must not collapse to an empty label.
|
||||
expect(shortModelLabel('[1m]')).toBe('[1m]')
|
||||
})
|
||||
|
||||
it('marks a member with no model of its own as inheriting the lead model', () => {
|
||||
expect(resolveMemberModel({ ...worker }, 'claude-sonnet-5'))
|
||||
.toEqual({ label: 'sonnet', full: 'claude-sonnet-5', inherited: true })
|
||||
})
|
||||
|
||||
it('treats an explicit inherit alias as inheritance, not as a model name', () => {
|
||||
expect(resolveMemberModel({ ...worker, model: 'inherit' }, 'claude-opus-4-8'))
|
||||
.toEqual({ label: 'opus', full: 'claude-opus-4-8', inherited: true })
|
||||
expect(resolveMemberModel({ ...worker, model: 'INHERIT' }, 'claude-opus-4-8'))
|
||||
.toEqual({ label: 'opus', full: 'claude-opus-4-8', inherited: true })
|
||||
})
|
||||
|
||||
it('returns undefined when nothing is known, so callers render no empty label', () => {
|
||||
expect(resolveMemberModel({ ...worker })).toBeUndefined()
|
||||
expect(resolveMemberModel({ ...worker, model: '' })).toBeUndefined()
|
||||
expect(resolveMemberModel({ ...worker, model: ' ' })).toBeUndefined()
|
||||
expect(resolveMemberModel({ ...worker, model: 'inherit' })).toBeUndefined()
|
||||
// A blank lead model must not be promoted into an inherited label either.
|
||||
expect(resolveMemberModel({ ...worker }, ' ')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a model learned from an earlier frame when a later frame omits it', () => {
|
||||
const earlier = memberSnapshot('2026-08-08T07:00:00.000Z', [
|
||||
{ ...worker, model: 'claude-opus-4-8' },
|
||||
])
|
||||
const later = memberSnapshot('2026-08-08T07:05:00.000Z', [{ ...worker }])
|
||||
|
||||
const merged = snapshotWithHistoricalMembers([earlier, later], 1)
|
||||
|
||||
expect(merged?.team.members[0]?.model).toBe('claude-opus-4-8')
|
||||
})
|
||||
|
||||
it('lets a later frame update the model when it actually carries one', () => {
|
||||
const earlier = memberSnapshot('2026-08-08T07:00:00.000Z', [
|
||||
{ ...worker, model: 'claude-opus-4-8' },
|
||||
])
|
||||
const later = memberSnapshot('2026-08-08T07:05:00.000Z', [
|
||||
{ ...worker, model: 'claude-haiku-4-5' },
|
||||
])
|
||||
|
||||
const merged = snapshotWithHistoricalMembers([earlier, later], 1)
|
||||
|
||||
expect(merged?.team.members[0]?.model).toBe('claude-haiku-4-5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,6 +111,69 @@ export function getMemberAvatarKey(member: TeamMember, isLead = false): MemberAv
|
||||
return WORKER_AVATARS[stableHash(member.agentId) % WORKER_AVATARS.length]!.key
|
||||
}
|
||||
|
||||
/**
|
||||
* Short family label for a model id or alias, used by compact UI chips.
|
||||
* 'claude-opus-4-8' -> 'opus', 'eu.anthropic.claude-sonnet-5' -> 'sonnet',
|
||||
* 'sonnet' -> 'sonnet'. Unrecognized values are returned trimmed and unchanged
|
||||
* rather than being forced into a family.
|
||||
*
|
||||
* A trailing context-window tag (`[1m]`) is a routing detail, not part of the
|
||||
* name a reader recognises, so it is dropped before matching.
|
||||
*/
|
||||
export function shortModelLabel(model: string): string {
|
||||
const untagged = model.trim().replace(/\[[^\]]*\]$/, '').trim()
|
||||
// A value that was nothing but a tag keeps its original form rather than
|
||||
// collapsing to an empty label.
|
||||
const trimmed = untagged || model.trim()
|
||||
if (!trimmed) return trimmed
|
||||
const lowered = trimmed.toLowerCase()
|
||||
if (lowered.includes('opus')) return 'opus'
|
||||
if (lowered.includes('sonnet')) return 'sonnet'
|
||||
if (lowered.includes('haiku')) return 'haiku'
|
||||
if (lowered.includes('fable')) return 'fable'
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export type MemberModelDisplay = {
|
||||
/** Short chip label, e.g. 'opus'. */
|
||||
label: string
|
||||
/** Full model id or alias, used for the hover tooltip. */
|
||||
full: string
|
||||
/** True when the member runs on the lead's model rather than one of its own. */
|
||||
inherited: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Model display for one member. Returns undefined when nothing is known, so
|
||||
* callers render no chip instead of an empty or 'undefined' label.
|
||||
*
|
||||
* `leadModel` is the lead member's model. Callers pass undefined for the lead
|
||||
* itself; a lead without an explicit model therefore renders no chip.
|
||||
*/
|
||||
export function resolveMemberModel(
|
||||
member: TeamMember,
|
||||
leadModel?: string,
|
||||
): MemberModelDisplay | undefined {
|
||||
const memberModelRaw = member.model
|
||||
const memberModel = typeof memberModelRaw === 'string' ? memberModelRaw.trim() : ''
|
||||
if (memberModel && memberModel.toLowerCase() !== 'inherit') {
|
||||
return {
|
||||
label: shortModelLabel(memberModel),
|
||||
full: memberModel,
|
||||
inherited: false,
|
||||
}
|
||||
}
|
||||
const resolvedLead = typeof leadModel === 'string' ? leadModel.trim() : ''
|
||||
if (resolvedLead) {
|
||||
return {
|
||||
label: shortModelLabel(resolvedLead),
|
||||
full: resolvedLead,
|
||||
inherited: true,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function identityAliases(value: string): string[] {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
const short = normalized.split('@')[0] ?? normalized
|
||||
@@ -153,7 +216,13 @@ export function snapshotWithHistoricalMembers(
|
||||
}
|
||||
}
|
||||
const [currentMember] = remaining.splice(currentIndex, 1)
|
||||
return { ...historicalMember, ...currentMember! }
|
||||
const merged = { ...historicalMember, ...currentMember! }
|
||||
// A later frame can land with `model: undefined`, which would clobber a
|
||||
// model already learned from an earlier snapshot. Restore the historical
|
||||
// model when the incoming member has no model of its own.
|
||||
const currentModel = currentMember?.model
|
||||
const hasCurrentModel = typeof currentModel === 'string' && currentModel.trim() !== ''
|
||||
return hasCurrentModel ? merged : { ...merged, model: historicalMember.model }
|
||||
})
|
||||
members.push(...remaining)
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('ModelSelector', () => {
|
||||
id: 'provider-1m', presetId: 'custom', name: 'Provider 1M',
|
||||
apiFormat: 'anthropic', apiKey: 'fixture', baseUrl: 'http://127.0.0.1:9999',
|
||||
models: { main: 'main-model', haiku: 'haiku-model', sonnet: 'sonnet-model', opus: 'opus-model' },
|
||||
model1mSupport: { main: enabled, haiku: enabled, sonnet: enabled, opus: enabled },
|
||||
model1mSupport: { main: enabled, fable: enabled, haiku: enabled, sonnet: enabled, opus: enabled },
|
||||
}],
|
||||
})
|
||||
const runtimeChange = vi.fn()
|
||||
|
||||
@@ -30,7 +30,7 @@ function makeCandidate(overrides: Partial<CcSwitchCandidate> = {}): CcSwitchCand
|
||||
sonnet: 'relay-sonnet',
|
||||
opus: 'relay-opus',
|
||||
},
|
||||
model1mSupport: { main: false, haiku: false, sonnet: false, opus: false },
|
||||
model1mSupport: { main: false, fable: false, haiku: false, sonnet: false, opus: false },
|
||||
apiFormat: 'anthropic',
|
||||
authStrategy: 'auth_token',
|
||||
presetId: 'custom',
|
||||
|
||||
@@ -99,6 +99,7 @@ function galleryTeam(
|
||||
agentId: string,
|
||||
role: string,
|
||||
status: TeamDetail['members'][number]['status'],
|
||||
model?: string,
|
||||
): TeamDetail['members'][number] => ({
|
||||
agentId,
|
||||
name: agentId,
|
||||
@@ -108,6 +109,7 @@ function galleryTeam(
|
||||
currentTask: activeTaskByMember[agentId],
|
||||
color: AGENT_TEAMS_GALLERY_COLORS[agentId],
|
||||
sessionId: `gallery-${agentId}`,
|
||||
...(model ? { model } : {}),
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -117,11 +119,11 @@ function galleryTeam(
|
||||
leadSessionId: AGENT_TEAMS_GALLERY_SESSION_ID,
|
||||
createdAt,
|
||||
members: [
|
||||
member('team-lead', '队长', 'running'),
|
||||
member('backend-dev', '后端实现', 'running'),
|
||||
member('frontend-dev', '前端实现', 'running'),
|
||||
member('ui-reviewer', 'UI 走查', 'running'),
|
||||
member('code-reviewer', '代码审查', 'running'),
|
||||
member('team-lead', '队长', 'running', 'claude-opus-4-8'),
|
||||
member('backend-dev', '后端实现', 'running', 'claude-sonnet-5'),
|
||||
member('frontend-dev', '前端实现', 'running', 'claude-sonnet-5'),
|
||||
member('ui-reviewer', 'UI 走查', 'running', 'claude-haiku-4-5'),
|
||||
member('code-reviewer', '代码审查', 'running', 'qwen3.7-plus[1m]'),
|
||||
member('test-runner', '测试验证', 'running'),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1300,6 +1300,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'settings.providers.getApiKey': 'Get API Key',
|
||||
'settings.providers.modelMapping': 'Model Mapping',
|
||||
'settings.providers.mainModel': 'Main Model',
|
||||
'settings.providers.fableModel': 'Fable Model',
|
||||
'settings.providers.haikuModel': 'Haiku Model',
|
||||
'settings.providers.sonnetModel': 'Sonnet Model',
|
||||
'settings.providers.opusModel': 'Opus Model',
|
||||
@@ -1308,6 +1309,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'settings.providers.model1mSupportTooltip': 'Declares that this model endpoint supports a 1M-token context: the model name gets a [1m] suffix and the context window is set to 1,000,000. If the endpoint is actually smaller, auto-compaction never fires and requests hit the provider’s hard context limit. Clearing the box restores the preset window.',
|
||||
'settings.providers.modelIdPlaceholder': 'e.g. deepseek-v4-flash',
|
||||
'settings.providers.sameAsMain': 'Same as main',
|
||||
'settings.providers.fableModelPlaceholder': 'Leave blank to let Claude Code choose',
|
||||
'settings.providers.contextSettingsTitle': 'Context and auto-compact',
|
||||
'settings.providers.contextSettingsDesc': 'Preset limits are applied automatically. Edit only when the provider changes limits or you use a custom model ID.',
|
||||
'settings.providers.contextSettingsEdit': 'Advanced',
|
||||
@@ -3086,6 +3088,9 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'agentTeams.inspector.taskHistoryHint': 'One teammate executes multiple tasks in sequence',
|
||||
'agentTeams.inspector.sent': 'Sent',
|
||||
'agentTeams.inspector.received': 'Received',
|
||||
'agentTeams.model.label': 'Model',
|
||||
'agentTeams.model.inheritFromLead': 'Inherit from lead · {model}',
|
||||
'agentTeams.model.unknown': 'Unknown',
|
||||
'agentTeams.hideReport': 'Hide Agent Teams run report',
|
||||
'agentTeams.closeReport': 'Close Agent Teams run report',
|
||||
'agentTeams.loading': 'Waiting for Agent Teams activity…',
|
||||
|
||||
@@ -1302,6 +1302,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.providers.getApiKey': 'API キーを取得',
|
||||
'settings.providers.modelMapping': 'モデルマッピング',
|
||||
'settings.providers.mainModel': 'メインモデル',
|
||||
'settings.providers.fableModel': 'Fable モデル',
|
||||
'settings.providers.haikuModel': 'Haiku モデル',
|
||||
'settings.providers.sonnetModel': 'Sonnet モデル',
|
||||
'settings.providers.opusModel': 'Opus モデル',
|
||||
@@ -1310,6 +1311,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.providers.model1mSupportTooltip': 'このモデルのエンドポイントが 100 万トークンのコンテキストに対応していることを宣言します。モデル名に [1m] が付き、コンテキストウィンドウは 1,000,000 に設定されます。実際のウィンドウが 1M より小さい場合、自動圧縮は実行されず、リクエストはプロバイダーのコンテキスト上限に達します。チェックを外すとプリセットのウィンドウに戻ります。',
|
||||
'settings.providers.modelIdPlaceholder': '例: deepseek-v4-flash',
|
||||
'settings.providers.sameAsMain': 'メインと同じ',
|
||||
'settings.providers.fableModelPlaceholder': '空欄の場合は Claude Code が決定',
|
||||
'settings.providers.contextSettingsTitle': 'コンテキストと自動コンパクト',
|
||||
'settings.providers.contextSettingsDesc': 'プリセットの上限は自動的に適用されます。プロバイダーが上限を変更した場合やカスタムモデル ID を使用する場合のみ編集してください。',
|
||||
'settings.providers.contextSettingsEdit': '詳細設定',
|
||||
@@ -3088,6 +3090,9 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'agentTeams.inspector.taskHistoryHint': '1 人のメンバーが複数タスクを順番に実行',
|
||||
'agentTeams.inspector.sent': '送信',
|
||||
'agentTeams.inspector.received': '受信',
|
||||
'agentTeams.model.label': 'モデル',
|
||||
'agentTeams.model.inheritFromLead': 'リードから継承 · {model}',
|
||||
'agentTeams.model.unknown': '不明',
|
||||
'agentTeams.hideReport': 'Agent Teams 実行レポートを隠す',
|
||||
'agentTeams.closeReport': 'Agent Teams 実行レポートを閉じる',
|
||||
'agentTeams.loading': 'Agent Teams のアクティビティを待機中…',
|
||||
|
||||
@@ -1304,6 +1304,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.providers.getApiKey': 'API 키 가져오기',
|
||||
'settings.providers.modelMapping': '모델 매핑',
|
||||
'settings.providers.mainModel': '메인 모델',
|
||||
'settings.providers.fableModel': 'Fable 모델',
|
||||
'settings.providers.haikuModel': 'Haiku 모델',
|
||||
'settings.providers.sonnetModel': 'Sonnet 모델',
|
||||
'settings.providers.opusModel': 'Opus 모델',
|
||||
@@ -1312,6 +1313,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.providers.model1mSupportTooltip': '이 모델 엔드포인트가 100만 토큰 컨텍스트를 지원한다고 선언합니다. 모델 이름에 [1m]이 추가되고 컨텍스트 창이 1,000,000으로 설정됩니다. 엔드포인트의 실제 창이 1M보다 작으면 자동 압축이 실행되지 않고 요청이 공급자의 컨텍스트 한도에 도달합니다. 선택을 해제하면 사전 설정 창으로 되돌아갑니다.',
|
||||
'settings.providers.modelIdPlaceholder': '예: deepseek-v4-flash',
|
||||
'settings.providers.sameAsMain': '메인과 동일',
|
||||
'settings.providers.fableModelPlaceholder': '비워 두면 Claude Code가 결정',
|
||||
'settings.providers.contextSettingsTitle': '컨텍스트 및 자동 압축',
|
||||
'settings.providers.contextSettingsDesc': '사전 설정 한도가 자동으로 적용됩니다. 공급자가 한도를 변경하거나 사용자 지정 모델 ID를 사용할 때만 편집하세요.',
|
||||
'settings.providers.contextSettingsEdit': '고급',
|
||||
@@ -3090,6 +3092,9 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'agentTeams.inspector.taskHistoryHint': '한 멤버가 여러 작업을 순차 실행',
|
||||
'agentTeams.inspector.sent': '보냄',
|
||||
'agentTeams.inspector.received': '받음',
|
||||
'agentTeams.model.label': '모델',
|
||||
'agentTeams.model.inheritFromLead': '리드로부터 상속 · {model}',
|
||||
'agentTeams.model.unknown': '알 수 없음',
|
||||
'agentTeams.hideReport': 'Agent Teams 실행 보고서 숨기기',
|
||||
'agentTeams.closeReport': 'Agent Teams 실행 보고서 닫기',
|
||||
'agentTeams.loading': 'Agent Teams 활동을 기다리는 중…',
|
||||
|
||||
@@ -1301,6 +1301,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.getApiKey': '獲取 API Key',
|
||||
'settings.providers.modelMapping': '模型對映',
|
||||
'settings.providers.mainModel': '主模型',
|
||||
'settings.providers.fableModel': 'Fable 模型',
|
||||
'settings.providers.haikuModel': 'Haiku 模型',
|
||||
'settings.providers.sonnetModel': 'Sonnet 模型',
|
||||
'settings.providers.opusModel': 'Opus 模型',
|
||||
@@ -1309,6 +1310,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.model1mSupportTooltip': '宣告該模型端點支援 100 萬 token 上下文:模型名稱會附加 [1m],上下文視窗自動填為 1,000,000。若端點實際視窗小於 1M,自動壓縮不會觸發,請求會撞上服務商的上下文硬限制。取消勾選會還原為預設視窗。',
|
||||
'settings.providers.modelIdPlaceholder': '例如: deepseek-v4-flash',
|
||||
'settings.providers.sameAsMain': '與主模型相同',
|
||||
'settings.providers.fableModelPlaceholder': '留空則由 Claude Code 決定',
|
||||
'settings.providers.contextSettingsTitle': '上下文與自動壓縮',
|
||||
'settings.providers.contextSettingsDesc': '預設會自動應用模型視窗。只有服務商調整限制,或使用自定義模型 ID 時才需要修改。',
|
||||
'settings.providers.contextSettingsEdit': '高階',
|
||||
@@ -3087,6 +3089,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'agentTeams.inspector.taskHistoryHint': '一位成員依序執行多個任務',
|
||||
'agentTeams.inspector.sent': '送出',
|
||||
'agentTeams.inspector.received': '收到',
|
||||
'agentTeams.model.label': '模型',
|
||||
'agentTeams.model.inheritFromLead': '繼承主代理 · {model}',
|
||||
'agentTeams.model.unknown': '未知',
|
||||
'agentTeams.hideReport': '隱藏 Agent Teams 執行報告',
|
||||
'agentTeams.closeReport': '關閉 Agent Teams 執行報告',
|
||||
'agentTeams.loading': '正在等待 Agent Teams 活動…',
|
||||
|
||||
@@ -1300,6 +1300,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.getApiKey': '获取 API Key',
|
||||
'settings.providers.modelMapping': '模型映射',
|
||||
'settings.providers.mainModel': '主模型',
|
||||
'settings.providers.fableModel': 'Fable 模型',
|
||||
'settings.providers.haikuModel': 'Haiku 模型',
|
||||
'settings.providers.sonnetModel': 'Sonnet 模型',
|
||||
'settings.providers.opusModel': 'Opus 模型',
|
||||
@@ -1308,6 +1309,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.model1mSupportTooltip': '声明该模型端点支持 100 万 token 上下文:模型名会追加 [1m],上下文窗口自动填为 1,000,000。若端点实际窗口小于 1M,自动压缩不会触发,请求会撞上服务商的上下文硬限制。取消勾选会恢复预设窗口。',
|
||||
'settings.providers.modelIdPlaceholder': '例如: deepseek-v4-flash',
|
||||
'settings.providers.sameAsMain': '与主模型相同',
|
||||
'settings.providers.fableModelPlaceholder': '留空则由 Claude Code 决定',
|
||||
'settings.providers.contextSettingsTitle': '上下文与自动压缩',
|
||||
'settings.providers.contextSettingsDesc': '预设会自动应用模型窗口。只有服务商调整限制,或使用自定义模型 ID 时才需要修改。',
|
||||
'settings.providers.contextSettingsEdit': '高级',
|
||||
@@ -3086,6 +3088,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'agentTeams.inspector.taskHistoryHint': '一个成员串行执行多个任务',
|
||||
'agentTeams.inspector.sent': '发出',
|
||||
'agentTeams.inspector.received': '收到',
|
||||
'agentTeams.model.label': '模型',
|
||||
'agentTeams.model.inheritFromLead': '继承主代理 · {model}',
|
||||
'agentTeams.model.unknown': '未知',
|
||||
'agentTeams.hideReport': '隐藏 Agent Teams 运行报告',
|
||||
'agentTeams.closeReport': '关闭 Agent Teams 运行报告',
|
||||
'agentTeams.loading': '正在等待 Agent Teams 活动…',
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '../providerModelContext'
|
||||
|
||||
function inputs(overrides: Partial<ModelContextInputs> = {}): ModelContextInputs {
|
||||
return { main: '', haiku: '', sonnet: '', opus: '', ...overrides }
|
||||
return { main: '', fable: '', haiku: '', sonnet: '', opus: '', ...overrides }
|
||||
}
|
||||
|
||||
describe('1M support and the context window field', () => {
|
||||
@@ -87,6 +87,7 @@ describe('1M support and the context window field', () => {
|
||||
|
||||
const next = apply1mSupportToContextInputs(before, {
|
||||
main: true,
|
||||
fable: false,
|
||||
haiku: false,
|
||||
sonnet: false,
|
||||
opus: false,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Model1mSupport } from '../types/provider'
|
||||
|
||||
export const MODEL_SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const
|
||||
export const MODEL_SLOTS = ['main', 'fable', 'haiku', 'sonnet', 'opus'] as const
|
||||
export type ModelSlot = typeof MODEL_SLOTS[number]
|
||||
export type ModelContextInputs = Record<ModelSlot, string>
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('provider 1M runtime selection', () => {
|
||||
id: 'provider', name: 'Provider', presetId: 'custom', apiKey: 'fixture',
|
||||
baseUrl: 'http://127.0.0.1:9999', apiFormat: 'anthropic',
|
||||
models: { main: ' main-model ', haiku: 'fast-model', sonnet: 'balanced-model', opus: 'large-model' },
|
||||
model1mSupport: { main: true, haiku: false, sonnet: true, opus: false },
|
||||
model1mSupport: { main: true, fable: false, haiku: false, sonnet: true, opus: false },
|
||||
}
|
||||
|
||||
it('materializes the active provider main slot by id and by legacy name', () => {
|
||||
|
||||
@@ -838,7 +838,7 @@ describe('EmptySession', () => {
|
||||
sonnet: 'MiniMax-M3',
|
||||
opus: 'MiniMax-M3',
|
||||
},
|
||||
model1mSupport: { main: enabled, haiku: enabled, sonnet: enabled, opus: enabled },
|
||||
model1mSupport: { main: enabled, fable: enabled, haiku: enabled, sonnet: enabled, opus: enabled },
|
||||
toolSearchEnabled: true,
|
||||
}],
|
||||
activeId: 'provider-minimax',
|
||||
|
||||
@@ -7,7 +7,7 @@ import aruhubLogo from '../../../../docs/images/sponsors/aruhub-logo.png'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useProviderStore } from '../../stores/providerStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
@@ -59,6 +59,15 @@ type ProviderListItem =
|
||||
| { id: typeof GROK_OFFICIAL_PROVIDER_ID; kind: 'grok-official' }
|
||||
| { id: string; kind: 'saved'; provider: SavedProvider }
|
||||
|
||||
/** Row labels for the model-mapping form, in `MODEL_SLOTS` order. */
|
||||
const MODEL_SLOT_LABEL_KEYS: Record<ModelSlot, TranslationKey> = {
|
||||
main: 'settings.providers.mainModel',
|
||||
fable: 'settings.providers.fableModel',
|
||||
haiku: 'settings.providers.haikuModel',
|
||||
sonnet: 'settings.providers.sonnetModel',
|
||||
opus: 'settings.providers.opusModel',
|
||||
}
|
||||
|
||||
function defaultProviderOrder(providers: SavedProvider[]): string[] {
|
||||
return [
|
||||
...providers.map((provider) => provider.id),
|
||||
@@ -562,6 +571,7 @@ const MODEL_CONTEXT_WINDOWS_ENV_KEY = 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS'
|
||||
const DISABLE_EXPERIMENTAL_BETAS_ENV_KEY = 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS'
|
||||
const DEFAULT_MODEL_1M_SUPPORT: Model1mSupport = {
|
||||
main: false,
|
||||
fable: false,
|
||||
haiku: false,
|
||||
sonnet: false,
|
||||
opus: false,
|
||||
@@ -699,6 +709,7 @@ function getInitialModel1mSupport(
|
||||
): Model1mSupport {
|
||||
return {
|
||||
main: provider?.model1mSupport?.main === true || hasModel1mMarker(models.main),
|
||||
fable: provider?.model1mSupport?.fable === true || (models.fable ? hasModel1mMarker(models.fable) : false),
|
||||
haiku: provider?.model1mSupport?.haiku === true || hasModel1mMarker(models.haiku),
|
||||
sonnet: provider?.model1mSupport?.sonnet === true || hasModel1mMarker(models.sonnet),
|
||||
opus: provider?.model1mSupport?.opus === true || hasModel1mMarker(models.opus),
|
||||
@@ -716,7 +727,7 @@ function applyModel1mSupportMapping(
|
||||
): ModelMapping {
|
||||
return {
|
||||
main: applyModel1mSupport(models.main, model1mSupport.main),
|
||||
...(models.fable ? { fable: stripModel1mMarker(models.fable) } : {}),
|
||||
...(models.fable ? { fable: applyModel1mSupport(models.fable, model1mSupport.fable) } : {}),
|
||||
haiku: applyModel1mSupport(models.haiku, model1mSupport.haiku),
|
||||
sonnet: applyModel1mSupport(models.sonnet, model1mSupport.sonnet),
|
||||
opus: applyModel1mSupport(models.opus, model1mSupport.opus),
|
||||
@@ -1856,23 +1867,26 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
|
||||
)}
|
||||
<div className={browserMode ? "grid grid-cols-1 sm:grid-cols-2 gap-2" : "grid grid-cols-2 gap-2"}>
|
||||
{MODEL_SLOTS.map((slot) => {
|
||||
const labelKey = slot === 'main'
|
||||
? 'settings.providers.mainModel'
|
||||
: slot === 'haiku'
|
||||
? 'settings.providers.haikuModel'
|
||||
: slot === 'sonnet'
|
||||
? 'settings.providers.sonnetModel'
|
||||
: 'settings.providers.opusModel'
|
||||
const label = t(labelKey)
|
||||
const label = t(MODEL_SLOT_LABEL_KEYS[slot])
|
||||
const pickLabel = t('settings.providers.fetchModelsPick', { label })
|
||||
return (
|
||||
<div key={slot} className="min-w-0">
|
||||
<ModelIdCombobox
|
||||
label={label}
|
||||
required={slot === 'main'}
|
||||
value={models[slot]}
|
||||
value={models[slot] ?? ''}
|
||||
onChange={(value) => handleModelChange(slot, value)}
|
||||
placeholder={slot === 'main' ? t('settings.providers.modelIdPlaceholder') : t('settings.providers.sameAsMain')}
|
||||
placeholder={
|
||||
// Fable is the one slot that does not fall back to the
|
||||
// main model: an empty value leaves the choice to the
|
||||
// runtime, which resolves it to the provider's Opus-tier
|
||||
// model (third-party) or a real Fable model (official).
|
||||
slot === 'main'
|
||||
? t('settings.providers.modelIdPlaceholder')
|
||||
: slot === 'fable'
|
||||
? t('settings.providers.fableModelPlaceholder')
|
||||
: t('settings.providers.sameAsMain')
|
||||
}
|
||||
groups={modelPickerGroups}
|
||||
pickerLabel={pickLabel}
|
||||
noMatchesLabel={t('model.noMatches')}
|
||||
@@ -2167,6 +2181,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
|
||||
const mergedModels = { ...prev, ...newModels }
|
||||
const nextModel1mSupport = {
|
||||
main: hasModel1mMarker(mergedModels.main),
|
||||
fable: mergedModels.fable ? hasModel1mMarker(mergedModels.fable) : false,
|
||||
haiku: hasModel1mMarker(mergedModels.haiku),
|
||||
sonnet: hasModel1mMarker(mergedModels.sonnet),
|
||||
opus: hasModel1mMarker(mergedModels.opus),
|
||||
|
||||
@@ -5265,7 +5265,7 @@ describe('chatStore history mapping', () => {
|
||||
id: 'provider-1', presetId: 'custom', name: 'DeepSeek', apiKey: 'fixture',
|
||||
baseUrl: 'http://127.0.0.1:1', apiFormat: 'anthropic',
|
||||
models: { main: model, haiku: '', sonnet: '', opus: '' },
|
||||
model1mSupport: { main: enabled, haiku: false, sonnet: false, opus: false },
|
||||
model1mSupport: { main: enabled, fable: false, haiku: false, sonnet: false, opus: false },
|
||||
}]
|
||||
const staleSelection = { providerId: 'provider-1', modelId: `${model}${enabled ? '' : '[1m]'}`, effortLevel: 'high' as const }
|
||||
const expectedSelection = { ...staleSelection, modelId: `${model}${enabled ? '[1m]' : ''}` }
|
||||
@@ -5290,7 +5290,7 @@ describe('chatStore history mapping', () => {
|
||||
id: 'provider-1', presetId: 'custom', name: 'DeepSeek', apiKey: 'fixture',
|
||||
baseUrl: 'http://127.0.0.1:1', apiFormat: 'anthropic',
|
||||
models: { main: 'deepseek-v4.1', haiku: '', sonnet: '', opus: '' },
|
||||
model1mSupport: { main: true, haiku: false, sonnet: false, opus: false },
|
||||
model1mSupport: { main: true, fable: false, haiku: false, sonnet: false, opus: false },
|
||||
}]
|
||||
useChatStore.getState().sendMessage(TEST_SESSION_ID, 'continue')
|
||||
expect(sendMock.mock.calls.slice(0, 2)).toEqual([
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('providerStore runtime refresh', () => {
|
||||
})
|
||||
|
||||
it.each([true, false])('refreshes saved model capabilities for idle, disconnected and draft selections (1m=%s)', async (enabled) => {
|
||||
const provider = makeProvider({ model1mSupport: { main: enabled, haiku: false, sonnet: false, opus: enabled } })
|
||||
const provider = makeProvider({ model1mSupport: { main: enabled, fable: false, haiku: false, sonnet: false, opus: enabled } })
|
||||
providersApiMock.update.mockResolvedValue({ provider })
|
||||
providersApiMock.list.mockResolvedValue({ providers: [provider], activeId: provider.id })
|
||||
chatStoreState.sessions = {
|
||||
@@ -197,7 +197,7 @@ describe('providerStore runtime refresh', () => {
|
||||
})
|
||||
|
||||
it('reconciles restored selections when provider capabilities arrive after connection', async () => {
|
||||
const provider = makeProvider({ model1mSupport: { main: true, haiku: false, sonnet: false, opus: false } })
|
||||
const provider = makeProvider({ model1mSupport: { main: true, fable: false, haiku: false, sonnet: false, opus: false } })
|
||||
providersApiMock.list.mockResolvedValue({ providers: [provider], activeId: provider.id })
|
||||
chatStoreState.sessions = { restored: { connectionState: 'connected', chatState: 'idle' } }
|
||||
runtimeStoreState.selections = { restored: { providerId: provider.id, modelId: 'model-main' } }
|
||||
|
||||
@@ -1387,6 +1387,49 @@ describe('teamStore workbench timeline', () => {
|
||||
useTeamStore.getState().clearTeam()
|
||||
})
|
||||
|
||||
it('keeps each member model through the roster read instead of dropping it', async () => {
|
||||
const snapshot = workbench('v1', 'in_progress')
|
||||
getWorkbenchForSessionMock.mockResolvedValue({
|
||||
sessionId: 'lead-session',
|
||||
teamName: 'team-workbench',
|
||||
snapshots: [{
|
||||
...snapshot,
|
||||
team: {
|
||||
...snapshot.team,
|
||||
members: [
|
||||
{ ...snapshot.team.members[0]!, model: 'claude-opus-4-8' },
|
||||
// A mapped provider id is not a known family — it must still survive
|
||||
// verbatim rather than being normalised away.
|
||||
{ ...snapshot.team.members[1]!, model: 'qwen3.7-plus[1m]' },
|
||||
],
|
||||
},
|
||||
}],
|
||||
source: 'live',
|
||||
})
|
||||
|
||||
await useTeamStore.getState().fetchTeamForSession('lead-session', { force: true })
|
||||
|
||||
const members = useTeamStore.getState()
|
||||
.workbenchesBySession['lead-session']?.snapshots.at(-1)?.team.members ?? []
|
||||
expect(members.map(member => member.model)).toEqual(['claude-opus-4-8', 'qwen3.7-plus[1m]'])
|
||||
})
|
||||
|
||||
it('reports no model rather than inventing one when the roster omits it', async () => {
|
||||
const snapshot = workbench('v1', 'in_progress')
|
||||
getWorkbenchForSessionMock.mockResolvedValue({
|
||||
sessionId: 'lead-session',
|
||||
teamName: 'team-workbench',
|
||||
snapshots: [snapshot],
|
||||
source: 'live',
|
||||
})
|
||||
|
||||
await useTeamStore.getState().fetchTeamForSession('lead-session', { force: true })
|
||||
|
||||
const members = useTeamStore.getState()
|
||||
.workbenchesBySession['lead-session']?.snapshots.at(-1)?.team.members ?? []
|
||||
expect(members.map(member => member.model)).toEqual([undefined, undefined])
|
||||
})
|
||||
|
||||
it('keeps unknown watcher identities out of each roster and refreshes authoritative data', () => {
|
||||
getTeamMock.mockReturnValue(new Promise(() => {}))
|
||||
getWorkbenchMock.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
@@ -144,6 +144,7 @@ function toTeamMember(raw: Record<string, unknown>): TeamMember {
|
||||
currentTask: raw.currentTask as string | undefined,
|
||||
color: raw.color as AgentColor | undefined,
|
||||
sessionId: raw.sessionId as string | undefined,
|
||||
model: typeof raw.model === 'string' ? raw.model : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ModelMapping = {
|
||||
|
||||
export type Model1mSupport = {
|
||||
main: boolean
|
||||
fable: boolean
|
||||
haiku: boolean
|
||||
sonnet: boolean
|
||||
opus: boolean
|
||||
|
||||
@@ -25,6 +25,7 @@ export type TeamMember = {
|
||||
currentTask?: string
|
||||
color?: AgentColor
|
||||
sessionId?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export type TeamDetail = {
|
||||
|
||||
+18
-1
@@ -10,7 +10,7 @@ import { handleSessionCollaborationApi } from './api/sessionCollaboration.js'
|
||||
|
||||
import { handleApiRequest } from './router.js'
|
||||
import { handleWebSocket, type WebSocketData } from './ws/handler.js'
|
||||
import { resolveCors, type CorsResolution } from './middleware/cors.js'
|
||||
import { isLoopbackBrowserOrigin, resolveCors, type CorsResolution } from './middleware/cors.js'
|
||||
import { requireAuth, requireH5Token } from './middleware/auth.js'
|
||||
import { teamWatcher } from './services/teamWatcher.js'
|
||||
import { cronScheduler } from './services/cronScheduler.js'
|
||||
@@ -377,6 +377,23 @@ export function startServer(port = PORT, host = HOST) {
|
||||
})
|
||||
const h5AccessControlBlocked = isH5AccessControlRequest(req, url, h5RequestContext)
|
||||
|
||||
// A *loopback* renderer's CORS preflight is answered before the
|
||||
// capability gates. A preflight never carries credentials, so a
|
||||
// browser-hosted renderer (the Vite dev server on
|
||||
// http://localhost:<port>) could never reach the API once H5 access was
|
||||
// disabled: the preflight was rejected with a CORS-less 403, the
|
||||
// browser refused to send the real request, and the app could only
|
||||
// report an opaque `TypeError: Failed to fetch`. This grants nothing —
|
||||
// the actual request is still gated below and still has to present the
|
||||
// process token. Remote origins deliberately skip this block and keep
|
||||
// hitting the strict rejection path beneath it.
|
||||
if (req.method === 'OPTIONS' && origin && isLoopbackBrowserOrigin(origin)) {
|
||||
if (cors.rejected) {
|
||||
return corsRejectedResponse(cors)
|
||||
}
|
||||
return new Response(null, { status: 204, headers: cors.headers })
|
||||
}
|
||||
|
||||
if (h5AccessControlBlocked) {
|
||||
return isLocalCredentialOnlyPath(url.pathname)
|
||||
? localCredentialRejectedResponse()
|
||||
|
||||
@@ -45,7 +45,7 @@ function isLocalOrigin(origin?: string | null): boolean {
|
||||
return LOCAL_DESKTOP_ORIGINS.has(origin) || isLoopbackBrowserOrigin(origin)
|
||||
}
|
||||
|
||||
function isLoopbackBrowserOrigin(origin: string): boolean {
|
||||
export function isLoopbackBrowserOrigin(origin: string): boolean {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(origin)
|
||||
|
||||
@@ -1062,6 +1062,7 @@ describe('cc-switch candidate mapping', () => {
|
||||
})
|
||||
expect(candidate.model1mSupport).toEqual({
|
||||
main: false,
|
||||
fable: false,
|
||||
haiku: false,
|
||||
sonnet: false,
|
||||
opus: false,
|
||||
@@ -1093,6 +1094,7 @@ describe('cc-switch candidate mapping', () => {
|
||||
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||
ANTHROPIC_AUTH_TOKEN: FULL_KEY,
|
||||
ANTHROPIC_MODEL: 'gemini-3.6-flash-high[1M]',
|
||||
ANTHROPIC_DEFAULT_FABLE_MODEL: 'gemini-3.6-pro:1m',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3.6-flash-lite',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3.6-flash-high:1m',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3.6-pro[1m]',
|
||||
@@ -1103,12 +1105,14 @@ describe('cc-switch candidate mapping', () => {
|
||||
|
||||
expect(candidate.models).toEqual({
|
||||
main: 'gemini-3.6-flash-high',
|
||||
fable: 'gemini-3.6-pro',
|
||||
haiku: 'gemini-3.6-flash-lite',
|
||||
sonnet: 'gemini-3.6-flash-high',
|
||||
opus: 'gemini-3.6-pro',
|
||||
})
|
||||
expect(candidate.model1mSupport).toEqual({
|
||||
main: true,
|
||||
fable: true,
|
||||
haiku: false,
|
||||
sonnet: true,
|
||||
opus: true,
|
||||
|
||||
@@ -624,6 +624,7 @@ function stripModel1mMarkers(models: ModelMapping): ModelMapping {
|
||||
function readModel1mSupport(models: ModelMapping): Model1mSupport {
|
||||
return {
|
||||
main: hasModel1mMarker(models.main),
|
||||
fable: models.fable ? hasModel1mMarker(models.fable) : false,
|
||||
haiku: hasModel1mMarker(models.haiku),
|
||||
sonnet: hasModel1mMarker(models.sonnet),
|
||||
opus: hasModel1mMarker(models.opus),
|
||||
|
||||
@@ -176,6 +176,7 @@ function normalizeModel1mSupport(
|
||||
if (!model1mSupport) return undefined
|
||||
const normalized = {
|
||||
main: model1mSupport.main === true,
|
||||
fable: model1mSupport.fable === true,
|
||||
haiku: model1mSupport.haiku === true,
|
||||
sonnet: model1mSupport.sonnet === true,
|
||||
opus: model1mSupport.opus === true,
|
||||
@@ -209,7 +210,9 @@ function applyModel1mSupportMapping(
|
||||
): SavedProvider['models'] {
|
||||
return {
|
||||
main: applyModel1mSupport(models.main, model1mSupport?.main),
|
||||
...(models.fable ? { fable: models.fable.trim() } : {}),
|
||||
...(models.fable !== undefined
|
||||
? { fable: applyModel1mSupport(models.fable, model1mSupport?.fable) }
|
||||
: {}),
|
||||
haiku: applyModel1mSupport(models.haiku, model1mSupport?.haiku),
|
||||
sonnet: applyModel1mSupport(models.sonnet, model1mSupport?.sonnet),
|
||||
opus: applyModel1mSupport(models.opus, model1mSupport?.opus),
|
||||
|
||||
@@ -269,7 +269,15 @@ function carryForwardArchivedMembers(
|
||||
const identity = memberArchiveIdentity(historicalMember)
|
||||
historicalIdentities.add(identity)
|
||||
const currentMember = currentByIdentity.get(identity)
|
||||
if (currentMember) return { ...historicalMember, ...currentMember }
|
||||
if (currentMember) {
|
||||
return {
|
||||
...historicalMember,
|
||||
...currentMember,
|
||||
...(currentMember.model === undefined && historicalMember.model !== undefined
|
||||
? { model: historicalMember.model }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...historicalMember,
|
||||
status: historicalMember.status === 'failed'
|
||||
@@ -1515,7 +1523,7 @@ export class TeamService {
|
||||
agentId: m.agentId,
|
||||
name: m.name,
|
||||
agentType: m.agentType,
|
||||
model: m.model,
|
||||
...(m.model ? { model: m.model } : {}),
|
||||
color: m.color,
|
||||
backendType: m.backendType,
|
||||
status: this.deriveStatus(m.isActive),
|
||||
|
||||
@@ -55,6 +55,9 @@ export const ModelMappingSchema = z.object({
|
||||
|
||||
export const Model1mSupportSchema = z.object({
|
||||
main: z.boolean(),
|
||||
// Defaulted rather than required: providers saved before the Fable slot
|
||||
// existed carry no `fable` key, and a required field would reject them.
|
||||
fable: z.boolean().default(false),
|
||||
haiku: z.boolean(),
|
||||
sonnet: z.boolean(),
|
||||
opus: z.boolean(),
|
||||
|
||||
@@ -59,7 +59,7 @@ export function getTeammateStatuses(teamName: string): TeammateStatus[] {
|
||||
name: member.name,
|
||||
agentId: member.agentId,
|
||||
agentType: member.agentType,
|
||||
model: member.model,
|
||||
...(member.model ? { model: member.model } : {}),
|
||||
prompt: member.prompt,
|
||||
status,
|
||||
color: member.color,
|
||||
|
||||
Reference in New Issue
Block a user