快速体验:作为动态插件在会话内激活
August 28, 2026 · View on GitHub
这是仓库的"零构建"试用方式。每次 Host/应用重启后动态插件会丢失,需重新激活;正式使用请走 README 的标准安装方式。
步骤
-
打开一个 DSH 会话,让 agent 执行:
cordis_define:新插件,idPrefix: mswit,把下面的 code.host 与 code.client 原样粘贴;cordis_run激活(Client 半首次需在界面批准);- 激活后,输入框工具行(发送按钮左侧)出现胶囊按钮即成功。
-
想要更简单的说法,也可以直接对 agent 说:"重新激活模型切换器插件,代码在
docs/quick-start-dynamic.md"。
code.host
return {
apply(ctx) {
const defaultModel = ctx.get('agentDefaultModel')
const api = ctx.get('apiProxy')
const llm = ctx.get('llm')
const buildCatalog = async () => {
const current = defaultModel !== undefined ? defaultModel.currentSelection() : null
const groups = []
const failures = []
if (llm !== undefined) {
for (const provider of llm.listProviders()) {
try {
const models = await llm.listModels(provider.id)
const entries = []
for (const model of models) {
let reasoning
try {
const info = await llm.resolveModelInfo(provider.id, model.id)
if (info !== undefined && info.reasoning !== undefined) {
reasoning = {
efforts: info.reasoning.efforts.map((effort) => ({
id: effort.id,
name: effort.name,
...(effort.description !== undefined ? { description: effort.description } : {}),
})),
...(info.reasoning.defaultEffort !== undefined ? { defaultEffort: info.reasoning.defaultEffort } : {}),
}
}
} catch (e) { /* per-model metadata is advisory */ }
entries.push({
id: model.id,
name: model.name,
...(reasoning !== undefined ? { reasoning } : {}),
})
}
if (entries.length > 0) {
groups.push({ id: provider.id, name: provider.name, models: entries })
}
} catch (e) {
failures.push({
id: provider.id,
name: provider.name,
message: (e && e.message) || String(e),
})
}
}
}
const routable = current !== null && groups.some((group) => group.id === current.provider)
return { current, routable, groups, failures }
}
harness.handle('model-switch:list', async (args) => {
const sessionId = args && args.sessionId
if (sessionId && api !== undefined && api.sessions !== undefined) {
try {
const out = await api.sessions.models({ rpcId: 'model-switch', payload: { sessionId } })
if (out !== undefined && out.result !== undefined && out.result.ok && out.result.value !== undefined) {
return out.result.value
}
} catch (e) { /* fall through to the direct catalog */ }
}
return buildCatalog()
})
harness.handle('model-switch:set', async (args) => {
const sessionId = args && args.sessionId
const provider = args && args.provider
const model = args && args.model
const reasoningEffort = args && args.reasoningEffort
if (!provider || !model) {
return { ok: false, error: '缺少 provider 或 model' }
}
const payload = {
sessionId,
provider,
model,
...(reasoningEffort ? { reasoningEffort } : {}),
}
if (sessionId && api !== undefined && api.sessions !== undefined) {
try {
const out = await api.sessions.selectModel({ rpcId: 'model-switch', payload })
if (out !== undefined && out.result !== undefined && out.result.ok) {
return { ok: true, selected: out.result.value.selected }
}
const error = out && out.result && out.result.error
const code = error && error.code
if (code === 'session-not-found' || code === 'agent-busy') {
// No servable agent for this identity: persist the process-level default instead.
if (defaultModel !== undefined) {
await defaultModel.saveSelection({ provider, model, ...(reasoningEffort ? { reasoningEffort } : {}) })
}
return { ok: true, defaultOnly: true, selected: { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}) } }
}
return { ok: false, error: (error && error.message) || '模型切换失败' }
} catch (e) {
return { ok: false, error: (e && e.message) || String(e) }
}
}
try {
if (defaultModel !== undefined) {
await defaultModel.saveSelection({ provider, model, ...(reasoningEffort ? { reasoningEffort } : {}) })
}
return { ok: true, defaultOnly: true, selected: { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}) } }
} catch (e) {
return { ok: false, error: (e && e.message) || String(e) }
}
})
},
}
code.client
return {
apply(ctx) {
const css = [
'.dms-root { position: relative; display: inline-flex; align-items: center; }',
'.dms-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-family: inherit; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 999px; padding: 6px 10px; cursor: pointer; max-width: 300px; }',
'.dms-pill:hover:not(:disabled) { border-color: var(--dsw-alias-border-l2); }',
'.dms-pill-open { border-color: var(--dsw-alias-brand-primary); }',
'.dms-pill:disabled { opacity: .55; cursor: default; }',
'.dms-pill-label { display: inline-flex; align-items: baseline; min-width: 0; }',
'.dms-pill-model { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'.dms-pill-caret { flex: none; font-size: 10px; color: var(--dsw-alias-label-secondary); }',
'.dms-pill-bolt { flex: none; color: inherit; }',
'.dms-pill-effort { flex: none; font-weight: 600; }',
'.dms-pill-max { background: var(--dsw-alias-bg-base); border-color: #8B5CF6; color: var(--dsw-alias-label-primary); }',
'.dms-pill-max:hover:not(:disabled) { border-color: #A78BFA; }',
'.dms-pill-max .dms-pill-caret { color: var(--dsw-alias-label-secondary); }',
'.dms-pill-effort-max { color: #A78BFA; }',
'.dms-backdrop { position: fixed; inset: 0; z-index: 5000; }',
'.dms-menu { position: absolute; right: 0; z-index: 5001; min-width: 260px; max-width: 340px; max-height: 320px; overflow: auto; background: color-mix(in srgb, var(--dsw-alias-bg-overlay) 72%, transparent); backdrop-filter: blur(14px) saturate(1.15); -webkit-backdrop-filter: blur(14px) saturate(1.15); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; box-shadow: 0 10px 30px rgba(0, 0, 0, .2); padding: 6px; }',
'.dms-menu-up { bottom: calc(100% + 6px); }',
'.dms-menu-down { top: calc(100% + 6px); }',
'.dms-menu-max { background: color-mix(in srgb, var(--dsw-alias-bg-base) 72%, transparent); border-color: var(--dsw-alias-border-l1); }',
'.dms-menu-max .dms-option:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-2); }',
'.dms-menu-max .dms-option-active { color: #A78BFA; }',
'.dms-menu-max .dms-back:hover { background: var(--dsw-alias-bg-layer-2); }',
'.dms-slider { position: relative; height: 32px; margin: 8px 12px 5px; touch-action: none; user-select: none; -webkit-user-select: none; cursor: pointer; }',
'.dms-slider-tail { position: absolute; left: 0; right: 0; top: 50%; height: 28px; transform: translateY(-50%); border-radius: 9999px; background: #3D3D3D; overflow: hidden; }',
'.dms-slider-fill { position: absolute; left: 0; top: 0; bottom: 0; background: #339CFF; transition: width .3s cubic-bezier(.25, .8, .3, 1), background .3s ease; }',
'.dms-slider-fill-max { background: linear-gradient(90deg, #4A7CFF 0%, #7C5CF0 55%, #8B5CF6 100%); }',
'.dms-dot { position: absolute; top: 50%; width: 6px; height: 6px; transform: translate(-50%, -50%); border-radius: 50%; pointer-events: none; transition: transform .1s ease, opacity .1s ease; }',
'.dms-dot-blue { background: rgba(255, 255, 255, .5); }',
'.dms-dot-gray { background: rgba(255, 255, 255, .18); }',
'.dms-dot-max { background: rgba(255, 255, 255, .5); }',
'.dms-dot-hit { transform: translate(-50%, -50%) scale(1.7); opacity: .95; }',
'.dms-speck { position: absolute; transform: translate(-50%, -50%); border-radius: 50%; background: rgba(255, 255, 255, .8); pointer-events: none; transition: opacity .3s ease; }',
'.dms-thumb { position: absolute; top: 50%; width: 30px; height: 30px; transform: translate(-50%, -50%); border-radius: 50%; background: #ffffff; box-shadow: 0 1px 3px rgba(0, 0, 0, .25), 0 3px 7px rgba(0, 0, 0, .13); z-index: 2; pointer-events: none; transition: left .3s cubic-bezier(.25, .8, .3, 1); }',
'.dms-slide .dms-thumb { transition: left .11s cubic-bezier(.35, .5, .4, 1); }',
'.dms-slide .dms-slider-fill { transition: width .11s cubic-bezier(.35, .5, .4, 1); }',
'.dms-footer { display: flex; align-items: center; justify-content: space-between; padding: 7px 10px 5px; border-top: 1px solid var(--dsw-alias-border-l1); margin-top: 5px; }',
'.dms-foot-btn { font-size: 12.5px; font-family: inherit; color: var(--dsw-alias-label-primary); background: transparent; border: none; padding: 5px 6px; cursor: pointer; border-radius: 6px; display: inline-flex; align-items: center; gap: 4px; }',
'.dms-foot-btn:hover { background: var(--dsw-alias-bg-layer-2); }',
'.dms-foot-btn:disabled { opacity: .5; cursor: default; }',
'.dms-foot-snap { font-size: 14px; line-height: 1; color: var(--dsw-alias-label-secondary); background: transparent; border: none; padding: 5px 6px; cursor: pointer; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; }',
'.dms-foot-snap:hover:not(:disabled) { color: var(--dsw-alias-label-primary); }',
'.dms-foot-snap:disabled { opacity: .4; cursor: default; }',
'.dms-foot-snap-max { color: #A78BFA; }',
'.dms-foot-snap-max:disabled { color: #A78BFA; opacity: .45; }',
'.dms-back { font-size: 12px; font-family: inherit; color: var(--dsw-alias-label-secondary); background: transparent; border: none; padding: 6px 8px; cursor: pointer; border-radius: 6px; }',
'.dms-back:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }',
'.dms-group-name { font-size: 11px; font-weight: 600; color: var(--dsw-alias-label-secondary); padding: 8px 8px 4px; letter-spacing: .05em; }',
'.dms-option { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; font-size: 13px; font-family: inherit; color: var(--dsw-alias-label-primary); background: transparent; border: none; border-radius: 7px; padding: 7px 8px; cursor: pointer; text-align: left; }',
'.dms-option:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-2); }',
'.dms-option:disabled { opacity: .5; cursor: default; }',
'.dms-option-active { color: var(--dsw-alias-brand-primary); font-weight: 600; }',
'.dms-option-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'.dms-caret { flex: none; font-size: 10px; color: var(--dsw-alias-label-secondary); }',
'.dms-check { flex: none; font-size: 12px; }',
'.dms-error { font-size: 12px; color: var(--dsw-alias-state-error-primary); padding: 6px 8px; }',
'.dms-fail { font-size: 11.5px; color: var(--dsw-alias-label-secondary); padding: 5px 8px; }',
'.dms-empty { font-size: 12px; color: var(--dsw-alias-label-secondary); padding: 10px 8px; }',
].join('\n')
ctx.effect(() => styles.insert(css))
const slots = ctx.get('slots')
if (slots === undefined) return
function Bolt(props) {
return React.createElement('svg', {
width: props.size || 14,
height: props.size || 14,
viewBox: '0 0 24 24',
className: props.className || 'dms-bolt',
'aria-hidden': true,
}, React.createElement('path', { d: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z', fill: 'currentColor' }))
}
function ModelSwitcher(props) {
const sessionId = props.sessionId
const locked = props.locked === true
const up = props.up !== false
const [data, setData] = React.useState(null)
const [error, setError] = React.useState(null)
const [open, setOpen] = React.useState(false)
const [busy, setBusy] = React.useState(false)
const [pane, setPane] = React.useState('main')
const [dragPos, setDragPos] = React.useState(null)
const [pendingIdx, setPendingIdx] = React.useState(null)
const load = () => {
host.call('model-switch:list', sessionId ? { sessionId } : {})
.then((value) => {
if (value && value.groups) {
setData(value)
setError(null)
} else {
setError('无法加载模型列表')
}
})
.catch((e) => setError((e && e.message) || String(e)))
}
React.useEffect(() => { load() }, [sessionId])
const toggle = () => {
if (!open) load()
setOpen(!open)
setPane('main')
}
const commit = (groupId, model, effortId) => {
setBusy(true)
setError(null)
host.call('model-switch:set', {
sessionId,
provider: groupId,
model: model.id,
...(effortId ? { reasoningEffort: effortId } : {}),
})
.then((value) => {
setBusy(false)
setPendingIdx(null)
if (value && value.ok) {
setData((prev) => (prev ? Object.assign({}, prev, { current: value.selected, routable: true }) : prev))
setError(null)
setPane('main')
} else {
setError((value && value.error) || '切换失败')
}
})
.catch((e) => {
setBusy(false)
setPendingIdx(null)
setError((e && e.message) || String(e))
})
}
const current = data && data.current
const groups = data ? data.groups : []
const failures = data ? data.failures : []
let currentGroup = null
let currentModel = null
if (current) {
for (const group of groups) {
for (const model of group.models) {
if (group.id === current.provider && model.id === current.model) {
currentGroup = group
currentModel = model
}
}
}
}
const efforts = currentModel && currentModel.reasoning ? currentModel.reasoning.efforts : []
const defaultEffort = currentModel && currentModel.reasoning ? currentModel.reasoning.defaultEffort : undefined
const currentEffortId = current ? (current.reasoningEffort || defaultEffort) : undefined
let currentEffortName = null
if (currentEffortId) {
for (const effort of efforts) {
if (effort.id === currentEffortId) currentEffortName = effort.name
}
}
const n = efforts.length
const activeIndex = n > 1 && currentEffortId ? efforts.findIndex((effort) => effort.id === currentEffortId) : -1
const displayIdx = pendingIdx !== null ? pendingIdx : activeIndex
const qIdx = dragPos !== null ? Math.round(dragPos * (n - 1)) : -1
const frac = qIdx >= 0 ? qIdx / (n - 1) : (displayIdx >= 0 ? displayIdx / (n - 1) : 0)
const shownIdx = qIdx >= 0 ? qIdx : displayIdx
const isMax = n > 1 && shownIdx === n - 1
const shownEffortName = shownIdx >= 0 && shownIdx < n ? efforts[shownIdx].name : currentEffortName
const modelLabel = current ? current.model : (data ? '选择模型' : (error ? '模型不可用' : '加载中…'))
let title = '选择模型'
if (current) {
title = current.provider + ' / ' + current.model
if (shownEffortName) title += ' · ' + shownEffortName
}
const posOf = (f) => 'calc(15px + (100% - 30px) * ' + f + ')'
const SPECKS = [
{ f: 0.08, t: 22, s: 3, o: 0.5 },
{ f: 0.15, t: 64, s: 2, o: 0.35 },
{ f: 0.3, t: 40, s: 2.5, o: 0.45 },
{ f: 0.46, t: 70, s: 2, o: 0.3 },
{ f: 0.57, t: 26, s: 3, o: 0.5 },
{ f: 0.71, t: 58, s: 2, o: 0.35 },
{ f: 0.85, t: 32, s: 2.5, o: 0.4 },
{ f: 0.93, t: 66, s: 2, o: 0.3 },
]
const fracAt = (clientX, rect) => {
const span = rect.width - 30
if (span <= 0) return 0
const ratio = (clientX - rect.left - 15) / span
return Math.max(0, Math.min(1, ratio))
}
const onSliderDown = (e) => {
if (!currentGroup || !currentModel || n < 2 || busy || locked) return
if (e.currentTarget.setPointerCapture) {
try { e.currentTarget.setPointerCapture(e.pointerId) } catch (err) { /* pointer capture is advisory */ }
}
setDragPos(fracAt(e.clientX, e.currentTarget.getBoundingClientRect()))
}
const onSliderMove = (e) => {
if (dragPos === null) return
setDragPos(fracAt(e.clientX, e.currentTarget.getBoundingClientRect()))
}
const onSliderUp = () => {
if (dragPos === null) return
const idx = Math.round(dragPos * (n - 1))
setDragPos(null)
setPendingIdx(idx)
if (idx !== activeIndex) {
commit(currentGroup.id, currentModel, efforts[idx].id)
} else {
setPendingIdx(null)
}
}
const onSliderCancel = () => { setDragPos(null); setPendingIdx(null) }
let mainSlider
if (n > 1) {
const dots = efforts.map((effort, index) => {
const f = index / (n - 1)
const base = isMax ? 'dms-dot dms-dot-max' : ('dms-dot ' + (f <= frac ? 'dms-dot-blue' : 'dms-dot-gray'))
return React.createElement('span', {
className: base + (dragPos !== null && index === qIdx ? ' dms-dot-hit' : ''),
key: effort.id,
style: { left: posOf(f) },
})
})
const specks = isMax ? SPECKS.map((sp, i) => React.createElement('span', {
className: 'dms-speck',
key: 's' + i,
style: { left: posOf(sp.f), top: sp.t + '%', width: sp.s, height: sp.s, opacity: sp.o },
})) : null
mainSlider = React.createElement('div', {
className: 'dms-slider' + (dragPos !== null ? ' dms-slide' : ''),
role: 'slider',
'aria-label': '推理强度',
'aria-valuemin': 0,
'aria-valuemax': n - 1,
'aria-valuenow': shownIdx >= 0 ? shownIdx : 0,
onPointerDown: onSliderDown,
onPointerMove: onSliderMove,
onPointerUp: onSliderUp,
onPointerCancel: onSliderCancel,
},
React.createElement('div', { className: 'dms-slider-tail' },
React.createElement('span', { className: 'dms-slider-fill' + (isMax ? ' dms-slider-fill-max' : ''), style: { width: posOf(frac) } }),
dots,
specks),
React.createElement('span', { className: 'dms-thumb', style: { left: posOf(frac) } }))
} else {
mainSlider = React.createElement('div', { className: 'dms-empty' }, current ? '当前模型无推理强度选项' : '请先选择模型')
}
const snap = defaultEffort
? React.createElement('button', {
className: 'dms-foot-snap' + (isMax ? ' dms-foot-snap-max' : ''),
disabled: busy || currentEffortId === defaultEffort,
title: '恢复默认推理强度',
onClick: () => commit(currentGroup.id, currentModel, defaultEffort),
}, React.createElement(Bolt, { size: 14 }))
: null
const footer = React.createElement('div', { className: 'dms-footer' },
React.createElement('button', { className: 'dms-foot-btn', disabled: busy, onClick: () => setPane('models') },
React.createElement('span', null, '高级'),
React.createElement('span', null, '›')),
snap)
const mainPane = React.createElement('div', null, mainSlider, footer)
const modelRows = groups.map((group) => React.createElement('div', { className: 'dms-group', key: group.id },
React.createElement('div', { className: 'dms-group-name' }, group.name),
group.models.map((model) => {
const active = current !== null && current.provider === group.id && current.model === model.id
const hasEfforts = model.reasoning && model.reasoning.efforts && model.reasoning.efforts.length > 0
return React.createElement('button', {
className: 'dms-option' + (active ? ' dms-option-active' : ''),
key: model.id,
disabled: busy,
onClick: () => commit(group.id, model, model.reasoning ? model.reasoning.defaultEffort : undefined),
},
React.createElement('span', { className: 'dms-option-name' }, model.name),
hasEfforts ? React.createElement('span', { className: 'dms-caret' }, '▸') : (active ? React.createElement('span', { className: 'dms-check' }, '✓') : null))
})))
const failureRows = failures.map((failure) => React.createElement('div', { className: 'dms-fail', key: failure.id }, failure.name + ': ' + failure.message))
const modelsPane = React.createElement('div', null,
React.createElement('button', { className: 'dms-back', onClick: () => setPane('main') }, '← 返回'),
groups.length === 0 && failures.length === 0 ? React.createElement('div', { className: 'dms-empty' }, '没有可用模型') : null,
modelRows,
failureRows)
const menu = open
? React.createElement('div', { className: 'dms-menu ' + (up ? 'dms-menu-up' : 'dms-menu-down') + (isMax ? ' dms-menu-max' : '') },
error ? React.createElement('div', { className: 'dms-error' }, error) : null,
pane === 'main' ? mainPane : modelsPane)
: null
return React.createElement('div', { className: 'dms-root' },
React.createElement('button', {
className: 'dms-pill' + (open ? ' dms-pill-open' : '') + (isMax ? ' dms-pill-max' : ''),
disabled: locked || busy,
onClick: toggle,
title,
},
isMax ? React.createElement(Bolt, { className: 'dms-pill-bolt', size: 14 }) : null,
React.createElement('span', { className: 'dms-pill-label' },
React.createElement('span', { className: 'dms-pill-model' }, modelLabel),
shownEffortName ? React.createElement('span', { className: 'dms-pill-effort' + (isMax ? ' dms-pill-effort-max' : '') }, ' · ' + shownEffortName) : null),
React.createElement('span', { className: 'dms-pill-caret' }, open ? '⌃' : '⌄')),
open ? React.createElement('div', { className: 'dms-backdrop', onClick: () => setOpen(false) }) : null,
menu)
}
slots.inject('conversation.input.model', () => slots.register(
{ name: 'conversation.input.model' },
(props) => React.createElement(ModelSwitcher, props),
))
},
}
已知限制(动态方式)
- 重启后插件消失,需重新激活(见 README「安装(标准方式)」解决)
- 动态方式 Host 半通过
apiProxy通道工作,与标准方式的 wire 通道行为一致(会话模型 + 默认持久化)