返回 DeepSeek-Reasonix
model.go
根目录 / internal / cli / model.go
1 package cli
2
3 import (
4 "fmt"
5 "strings"
6
7 tea "charm.land/bubbletea/v2"
8
9 "reasonix/internal/config"
10 "reasonix/internal/i18n"
11 "reasonix/internal/provider"
12 )
13
14 // runModelSubcommand handles "/model": with no argument it opens the configured
15 // model picker; "/model <ref>" switches the
16 // session to that model in place, carrying the conversation across. The actual
17 // controller build runs asynchronously so it cannot block the TUI event loop.
18 func (m *chatTUI) runModelSubcommand(input string) {
19 args := tokenizeArgs(input) // args[0] == "/model"
20 if len(args) < 2 {
21 m.openModelPicker()
22 return
23 }
24 ref := args[1]
25 if m.buildController == nil {
26 m.notice(i18n.M.ModelSwitchUnavailable)
27 return
28 }
29 if m.runtimeSwitchBusy() {
30 m.notice(i18n.M.ModelSwitchBusy)
31 return
32 }
33 if m.modelSwitchPending {
34 m.notice(i18n.M.RuntimeSwitchPending)
35 return
36 }
37 if ref == m.modelRef {
38 m.notice(fmt.Sprintf(i18n.M.ModelAlreadyOnFmt, ref))
39 return
40 }
41 // Persist the user's choice to the user config.toml so the next
42 // session starts on the same model instead of falling back to the global
43 // default. Mirrors the pattern used by /theme (persistTheme), /effort, and
44 // /language.
45 m.persistModel(ref)
46 if err := m.ctrl.Snapshot(); err != nil {
47 m.notice("model: snapshot failed: " + err.Error())
48 }
49 // Capture the resume path and history only after Snapshot: a snapshot
50 // conflict can retarget the controller to a recovery branch (or adopt the
51 // newer disk transcript), and a pre-snapshot capture would bind the rebuilt
52 // controller back to the original file, re-conflicting on every later save.
53 carried := m.ctrl.History()
54 prevPath := m.ctrl.SessionPath()
55 // Move the lease before the rebuilt controller binds prevPath for writing
56 // (AdoptHistory resumes there): after a snapshot retarget the lease still
57 // guards the old path, and the async build must not open an unguarded
58 // writer on the recovery branch.
59 if err := m.rebindSessionLease(prevPath); err != nil {
60 m.notice("model: " + sessionLeaseHeldNotice(err))
61 return
62 }
63 m.notice(fmt.Sprintf(i18n.M.ModelSwitchingFmt, ref))
64
65 // Capture old controller for cleanup after the async build succeeds.
66 oldCtrl := m.ctrl
67 build := m.buildController
68
69 // Fire the build off the event loop; the result arrives as a tea.Cmd.
70 // Both the build AND the old-controller close run in the goroutine so
71 // neither blocks the bubbletea event loop. The old controller's Close
72 // kills plugin subprocesses (incl. CodeGraph), which can disrupt the
73 // terminal's cancelReader if called synchronously inside Update — so it
74 // must happen here, before we hand the new controller back.
75 m.modelSwitchPending = true
76 m.pendingModelSwitch = func() tea.Msg {
77 c, err := build(controllerBuildSpec{
78 ModelRef: ref,
79 RuntimeProfile: m.runtimeProfile,
80 ToolApprovalMode: oldCtrl.ToolApprovalMode(),
81 PlanMode: oldCtrl.PlanMode(),
82 }, carried, prevPath, oldCtrl)
83 if err != nil {
84 return modelSwitchMsg{ref: ref, err: err}
85 }
86 // Do NOT close the old controller here. Controller.Close() runs
87 // SessionEnd hooks (arbitrary shell commands) and kills plugin
88 // subprocesses — operations that corrupt bubbletea's terminal raw
89 // mode when executed from a goroutine. Instead, pass the old
90 // controller back in the message so the Update handler can defer
91 // its cleanup as a tea.Cmd that runs after the next render.
92 return modelSwitchMsg{
93 ref: ref,
94 ctrl: c,
95 oldCtrl: oldCtrl,
96 label: c.Label(),
97 commands: c.Commands(),
98 skills: c.SlashSkills(),
99 host: c.Host(),
100 }
101 }
102 }
103
104 func (m *chatTUI) openModelPicker() {
105 var catalog []provider.Descriptor
106 if m.ctrl != nil {
107 catalog = m.ctrl.ProviderCatalog()
108 }
109 refs := mergeExtensionModelRefs(modelRefs(), catalog)
110 if len(refs) == 0 {
111 m.notice("model: no configured chat models")
112 return
113 }
114 items := make([]quickPickerItem, 0, len(refs))
115 selected := 0
116 for _, ref := range refs {
117 parts := strings.SplitN(ref, "/", 2)
118 description := ""
119 if len(parts) == 2 {
120 description = "Provider: " + parts[0]
121 }
122 status := ""
123 if ref == m.modelRef {
124 status = "active"
125 selected = len(items)
126 }
127 items = append(items, quickPickerItem{ID: ref, Label: ref, Description: description, Status: status})
128 }
129 m.quickPick = &quickPicker{kind: quickPickerModel, title: "Select model", items: items, selected: selected}
130 }
131
132 // persistModel writes ref (a "provider/model" string) to default_model in the
133 // user config.toml so the next CLI launch starts on the same
134 // model. The in-memory switch is always allowed to proceed regardless of the
135 // outcome here, but every step (rejected by validation, save failed, or
136 // persisted successfully) reports back to the TUI notice channel so the user
137 // can see whether their /model choice will survive a restart. Run before
138 // Snapshot/ModelSwitchingFmt so the persistence outcome shows up first in
139 // the notice area.
140 func (m *chatTUI) persistModel(ref string) {
141 path := config.UserConfigPath()
142 if path == "" {
143 return
144 }
145 // Serialize the load-modify-save against other in-process user-config
146 // editors so concurrent writers don't drop each other's fields.
147 unlock := config.LockUserConfigEdits()
148 defer unlock()
149 edit := config.LoadForEdit(path)
150 if err := edit.SetDefaultModel(ref); err != nil {
151 m.notice(fmt.Sprintf("model: persist refused: %v (ref=%s)", err, ref))
152 return
153 }
154 if err := edit.SaveTo(path); err != nil {
155 m.notice(fmt.Sprintf("model: persist save failed: %v (ref=%s, path=%s)", err, ref, path))
156 return
157 }
158 m.notice(fmt.Sprintf("model: persisted (ref=%s, path=%s)", ref, path))
159 }
160
161 // modelRefs returns the configured provider/model refs for slash completion.
162 func modelRefs() []string {
163 cfg, err := config.Load()
164 if err != nil {
165 return nil
166 }
167 var out []string
168 for i := range cfg.Providers {
169 p := &cfg.Providers[i]
170 if !p.Configured() {
171 continue
172 }
173 for _, model := range p.ChatModelList() {
174 out = append(out, p.Name+"/"+model)
175 }
176 }
177 return out
178 }
179
180 // mergeExtensionModelRefs folds the session's extension provider catalog into
181 // the config-backed picker list. Extension refs arrive fully namespaced
182 // (plugin/<plugin>/<provider>/<model>); entries already listed (or blank) are
183 // dropped so a claim-replaced config ref never appears twice. A nil catalog
184 // returns base unchanged — the no-extension path is untouched.
185 func mergeExtensionModelRefs(base []string, catalog []provider.Descriptor) []string {
186 if len(catalog) == 0 {
187 return base
188 }
189 seen := make(map[string]bool, len(base)+len(catalog))
190 out := make([]string, 0, len(base)+len(catalog))
191 for _, ref := range base {
192 if seen[ref] {
193 continue
194 }
195 seen[ref] = true
196 out = append(out, ref)
197 }
198 for _, d := range catalog {
199 ref := strings.TrimSpace(d.Ref)
200 if ref == "" || seen[ref] {
201 continue
202 }
203 seen[ref] = true
204 out = append(out, ref)
205 }
206 return out
207 }
208
209 // providerNames returns the names of configured providers for slash completion.
210 func providerNames() []string {
211 cfg, err := config.Load()
212 if err != nil {
213 return nil
214 }
215 var out []string
216 for i := range cfg.Providers {
217 p := &cfg.Providers[i]
218 if !p.Configured() {
219 continue
220 }
221 out = append(out, p.Name)
222 }
223 return out
224 }
225
225 lines GO