快速体验:作为动态插件在会话内激活

August 28, 2026 · View on GitHub

这是仓库的"零构建"试用方式。每次 Host/应用重启后动态插件会丢失,需重新激活;正式使用请走 README 的标准安装方式。

步骤

  1. 打开一个 DSH 会话,让 agent 执行:

    • cordis_define:新插件,idPrefix: mswit,把下面的 code.hostcode.client 原样粘贴;
    • cordis_run 激活(Client 半首次需在界面批准);
    • 激活后,输入框工具行(发送按钮左侧)出现胶囊按钮即成功。
  2. 想要更简单的说法,也可以直接对 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 通道行为一致(会话模型 + 默认持久化)