Rewrite the draft in the prompt box with a side-channel model call, then put the result back for you to edit or send.

一个 Claude Code mod:用旁路模型改写输入框里的草稿,结果回填到输入框,由你决定改不改、发不发。
p)主对话看不到这次改写,除非你按了 Enter。
默认走 Claude Code 自己的主模型,也可以指向你自己的端点,见下面「配置」。
/qi-pm-config,或 band 里的「设置」按钮(空输入框里按 s),打开设置面板:
| 项 | 说明 |
|---|---|
| 协议 | 主模型(默认)/ Anthropic /v1/messages / OpenAI /v1/chat/completions / OpenAI /v1/responses |
| 端点 | base URL。可带 /v1,也可直接贴完整路径,两种都能拼对 |
| 密钥 | 只在面板里写入,从不回显;提交空值不会覆盖已存的密钥 |
| 模型 | 发给端点的模型名。点「拉取模型」会从端点的 /v1/models 拉一份列表,直接选 |
| 模板 | 留空用内置模板;填了则整段替换(对应 wukong 的 setDefaultPrompt),[Context] 块由代码拼接。自定义时需保留 <enhanced-prompt> 标记的要求,否则结果无法回填 |
四项齐全才走自定义端点;缺任何一项都回退主模型,不会失败。模型 id 区分大小写,拿不准就点「拉取模型」从列表里选。
关于密钥:存在 Claude Code 给本插件的本地存储里($.store),只在调用时发给你配置的那个端点。状态行、toast、日志里都不会出现它。
settings hook 只能往上下文里注入旁白,用户看不见,也改不了输入框。这个 mod 跑在 Claude Code 进程内,能读输入框、能写回、能画界面。
从 marketplace(两步):
/plugin marketplace add qizhuxu/qi-pm
/plugin install qi-pm@qi-pm
装完就常驻了:之后每次启动 Claude Code 都会自动加载,不再需要 --plugin-dir。
开发时加载本地目录(支持热重载):
claude --plugin-dir <本仓库路径>
装好后 /plugin 的 Installed 页会显示 mod 计数。
| 调用 | 作用 |
|---|---|
$.prompt.read() | 读输入框草稿 |
$.prompt.fill() | 写回输入框,不发送 |
$.model.fork() | 主模型路径:重放主线程最后一次请求 |
$.model.complete() | 主模型的兜底:会话还没有历史时用 |
$.http.fetch() | 自定义端点路径:发 HTTP 请求 |
$.session.messages() / $.session.cwd() | 会话上下文(自定义端点路径用) |
$.store | 设置的持久化 |
$.ui.open() / $.ui.close() / $.ui.panes() | 设置面板 |
$.ui.status() / $.ui.toast() / $.ui.invalidate() | 状态行、提示、面板重绘 |
$.command.register() | 注册 /qi-pm 和 /qi-pm-config |
$.model.complete() 按设计发的是轻量请求:没有工具定义、没有历史。只放行 Claude Code 请求的中转站会拒绝它(实测 HTTP 503)。
$.model.fork() 把主线程最后一次请求原样重放:同一个模型、同一份 system prompt、同一套工具定义、同一段消息历史,只在末尾追加一条用户消息(改写指令 + 草稿)。请求特征和主对话一致,而且能命中提示词缓存。
只有一个情况退回 complete:nothing-to-fork,即主线程还没产生过回复(全新会话或刚 /clear),没有前缀可重放。
如果中转站连 fork 也拒绝,就用自定义端点绕开它。
整个改写请求是一条用户消息(wukong 的结构),没有 system prompt:代理后面的聊天模型会把 system 里的说明当成「要改写的东西」,实测踩过这个坑。消息分三段,草稿放最后:
[Context] 块(自定义端点路径才有):项目目录 + 最近 10 条消息;主模型路径由 fork 的完整历史承担上下文模板正文是 wukong 自己的措辞,第一人称(「give you」「my original instruction」)。这个框架是实测选出来的:早先版本用 ### REWRITE REQUEST ### 机器头加「不得添加草稿没有的要求」这类规则,同一模型对照实验里产出退化成转述;换回 wukong 措辞后产出恢复为结构化增强。
在 wukong 原文基础上增补了三句,每句对应一次实测的失败:
stripContext 兜底)模板可以在设置面板里整段替换(对应 wukong 的 setDefaultPrompt),留空就用上面这套内置的;自定义模板必须自己保留「结果包在 <enhanced-prompt> 标记里」的要求,否则回填拿不到内容。
stripContext)npm test
node --test,覆盖三个协议的请求构造、端点拼接与响应解析。都是纯函数,不需要 Claude Code 运行。
$.model.complete 的返回值在不同版本里形状不同(对象或字符串),代码两种都接受首次用 --plugin-dir 加载时,Claude Code 会把权威类型写到 .claude-plugin/types/claude-code/index.d.ts。
这是你这台机器的版本的真实签名。官方明说 GitHub 上那份可能更旧,两者冲突时以本机生成的为准。
它已经解决了本项目的几个不确定点:
$.model.complete 返回 ModelCompleteResult,一个联合类型:成功分支带 isAnswered: true 和 text,拒绝分支带 isAnswered: false 和 reason。GitHub 上的 d.ts 说 Promise<string>,那是旧版。$.session.model() 返回主循环的模型,/model 显示的那个。$.model.fork 的请求体是「主线程最后一次请求 + 一条用户消息」,工具全部 deny。$.http.fetch 走宿主网络,返回 {status, ok, headers, text},插件自己不碰网络。$.ui.open 开面板,ui.render 用 {component: "Pane", requestId: <id>} 匹配到它。这些生成文件不进 git:types/ 目录自带一个内容为 * 的 .gitignore,根 tsconfig.json 也已加入忽略。
MIT
hooks/register.js 835 lines1// qi-pm — rewrite the draft in the prompt box with a side model call, then
2// put the result back for the person to edit or send.
3//
4// Two ways in: the /qi-pm command and the 改写 Button on the band above the
5// prompt. /qi-pm-config, or the 设置 Button, opens a pane that points the
6// rewrite at a custom endpoint: Anthropic, OpenAI chat completions, or
7// OpenAI responses. With none set the rewrite goes through Claude Code's
8// own model instead.
9//
10// The calls that carry the interaction:
11// $.prompt.read() read the draft
12// $.model.fork() the main-model call, replaying the main thread's request
13// $.model.complete() its fallback, for a session with no history yet
14// $.http.fetch() the call to a custom endpoint, when one is set
15// $.prompt.fill() write back into the box, without sending
16// $.store, $.ui.open settings that persist, and the pane that edits them
17
18const NL = String.fromCharCode(10)
19
20const BEGIN = "<enhanced-prompt>"
21const END = "</enhanced-prompt>"
22
23// The rewrite template, in wukong's own wording. It rides in the message
24// itself on every path — the system slot proved fragile: a proxy-backed chat
25// model read the system prompt as the thing to improve. The frame is
26// deliberately first-person ("give you", "my original instruction"): a
27// machine-request header and a "do not invent requirements" rule, both tried
28// earlier, flattened the output into a paraphrase in a same-model A/B test.
29// The sentences added to wukong's text each answer a failure seen live:
30// vague references left unresolved, paths and commands mangled, the context
31// echoed back into the rewrite.
32const RULES_LEAD = [
33 "Here is an instruction that I'd like to give you, but it needs to be improved.",
34 "Rewrite and enhance this instruction to make it clearer, more specific, less ambiguous, and correct any mistakes.",
35 "Reply immediately with your answer, even if you are not sure.",
36 "Consider the context of our conversation history when enhancing the prompt: when the instruction refers to earlier discussion vaguely, name the concrete items found in the context instead of repeating the vague reference.",
37 "Do not use any tools and do not ask questions.",
38 "Do not include the context above or this template in the enhanced instruction.",
39 "If there is code in triple backticks (```) consider whether it is a code sample and should remain unchanged.",
40 "Keep file paths, commands and error strings exactly as written.",
41 "Keep the original language of the instruction.",
42 "Reply with the following format:",
43].join(" ")
44
45const RULES = [
46 RULES_LEAD,
47 "",
48 "### BEGIN RESPONSE ###",
49 "Here is an enhanced version of the original instruction that is more specific and clear:",
50 BEGIN + "enhanced prompt goes here" + END,
51 "",
52 "### END RESPONSE ###",
53 "",
54 "Here is my original instruction:",
55].join(NL)
56
57const MAX_TOKENS = 2048
58
59const COMMAND_NAME = "qi-pm"
60const CONFIG_COMMAND = "qi-pm-config"
61const CONFIG_KEY = "config"
62const MODELS_KEY = "models"
63const PANE_ID = "qi-pm-config"
64
65const PROTOCOLS = ["main", "anthropic", "openai-chat", "openai-responses"]
66
67const PROTOCOL_PATHS = {
68 anthropic: "/v1/messages",
69 "openai-chat": "/v1/chat/completions",
70 "openai-responses": "/v1/responses",
71}
72
73const PROTOCOL_LABELS = [
74 { value: "main", label: "主模型(Claude Code 自己的)" },
75 { value: "anthropic", label: "Anthropic · /v1/messages" },
76 { value: "openai-chat", label: "OpenAI · /v1/chat/completions" },
77 { value: "openai-responses", label: "OpenAI · /v1/responses" },
78]
79
80// One user message, wukong's shape: context first, then the template, then
81// the draft last — the model rewrites what it read most recently. The
82// template ends with "Here is my original instruction:", so the draft needs
83// no marker of its own.
84function buildPrompt(draft, context, template = RULES) {
85 const parts = []
86 if (context) parts.push("[Context]" + NL + context)
87 parts.push(template)
88 parts.push(draft)
89 return parts.join(NL + NL)
90}
91
92// A custom template replaces the built-in one whole, wukong's setDefaultPrompt;
93// an empty field keeps the default. The context block is code-side and stays
94// either way; a custom template brings its own lead-in for the draft.
95function templateFor(config) {
96 return config.prompt !== "" ? config.prompt : RULES
97}
98
99// The rewriter sometimes echoes the context block back — it reads the whole
100// message as the draft. The block is input, not part of the rewrite, so a
101// leading one is dropped before the text goes anywhere near the box.
102function stripContext(text, context) {
103 if (!context) return text
104 if (!text.startsWith("[Context]")) return text
105 const cut = text.indexOf(NL + NL)
106 if (cut < 0) return text
107 return text.slice(cut + 2).trim()
108}
109
110// Collapse runs of whitespace without a regex literal, so this file stays
111// free of backslash escapes.
112function squash(value) {
113 const out = []
114 let pendingSpace = false
115 for (const ch of value) {
116 const code = ch.charCodeAt(0)
117 const isSpace = code === 32 || code === 9 || code === 10 || code === 13
118 if (isSpace) {
119 if (!pendingSpace) out.push(" ")
120 pendingSpace = true
121 } else {
122 out.push(ch)
123 pendingSpace = false
124 }
125 }
126 return out.join("").trim()
127}
128
129const HISTORY_COUNT = 10
130const HISTORY_TEXT_LEN = 1500
131
132function buildContext(cwd, messages) {
133 const parts = []
134 if (cwd) parts.push("项目目录:" + cwd)
135
136 const lines = []
137 const list = Array.isArray(messages) ? messages.slice(-HISTORY_COUNT) : []
138 for (const message of list) {
139 if (!message || typeof message.text !== "string") continue
140 const text = squash(message.text)
141 if (!text) continue
142 const who = message.role === "user" ? "User" : "Assistant"
143 const shown = text.length > HISTORY_TEXT_LEN
144 ? text.slice(0, HISTORY_TEXT_LEN) + "..."
145 : text
146 lines.push("- " + who + ": " + shown)
147 }
148 if (lines.length > 0) {
149 parts.push("最近对话:")
150 for (const line of lines) parts.push(line)
151 }
152 return parts.join(NL)
153}
154
155// $.model.complete resolves to the reply's text on some builds and to a
156// record carrying it on others: the docs describe the record, the published
157// d.ts promises a string. Accept both rather than guess, which is what the
158// first run got wrong.
159function textOf(result) {
160 if (typeof result === "string") return result
161 if (result && typeof result === "object") {
162 if (typeof result.text === "string") return result.text
163 if (typeof result.content === "string") return result.content
164 }
165 return ""
166}
167
168// A record with isAnswered false carries why the model declined. An
169// api-error adds a status and a classified error kind, and both are worth
170// showing: a rate limit, an overload and a bad key read differently.
171function refusalOf(result) {
172 if (!result || typeof result !== "object") return undefined
173 if (result.isAnswered !== false) return undefined
174
175 const bits = []
176 bits.push(typeof result.reason === "string" && result.reason !== ""
177 ? result.reason
178 : "模型没有给出改写")
179 if (typeof result.error === "string" && result.error !== "") bits.push(result.error)
180 if (typeof result.status === "number") {
181 bits.push("HTTP " + result.status)
182 } else if (result.reason === "api-error") {
183 bits.push("没有收到响应")
184 }
185 return bits.join(" · ")
186}
187
188function extract(text) {
189 if (typeof text !== "string") return undefined
190 const start = text.indexOf(BEGIN)
191 if (start < 0) return undefined
192 const from = start + BEGIN.length
193 const end = text.indexOf(END, from)
194 const body = end > start ? text.slice(from, end) : text.slice(from)
195 const out = body.trim()
196 return out === "" ? undefined : out
197}
198
199// A command's output already carries the plugin's name in the transcript, so
200// these messages do not repeat it.
201function status($, text) {
202 try {
203 $.ui.status(text)
204 } catch (err) {
205 // A surface that cannot draw a status line is not an error.
206 }
207}
208
209async function readContext($) {
210 try {
211 const cwd = await $.session.cwd()
212 const messages = await $.session.messages()
213 return buildContext(cwd, messages)
214 } catch (err) {
215 // No transcript, no context. Rewrite without it rather than fail.
216 return ""
217 }
218}
219
220async function resolveModel($) {
221 try {
222 const model = await $.session.model()
223 if (typeof model === "string" && model !== "") return model
224 } catch (err) {
225 // fall through
226 }
227 return undefined
228}
229
230function why(err) {
231 if (err && typeof err.message === "string" && err.message !== "") return err.message
232 return String(err)
233}
234
235// ---------------------------------------------------------------- settings
236
237async function loadConfig($) {
238 let raw
239 try {
240 raw = await $.store.get(CONFIG_KEY)
241 } catch (err) {
242 raw = undefined
243 }
244 const held = raw && typeof raw === "object" ? raw : {}
245 return {
246 protocol: PROTOCOLS.indexOf(held.protocol) >= 0 ? held.protocol : "main",
247 baseUrl: typeof held.baseUrl === "string" ? held.baseUrl : "",
248 apiKey: typeof held.apiKey === "string" ? held.apiKey : "",
249 model: typeof held.model === "string" ? held.model : "",
250 prompt: typeof held.prompt === "string" ? held.prompt : "",
251 }
252}
253
254// A custom endpoint needs all three fields; anything less falls back to the
255// main model rather than failing the rewrite.
256function isReady(config) {
257 return config.protocol !== "main" && config.baseUrl !== "" && config.apiKey !== "" && config.model !== ""
258}
259
260async function saveConfig($, patch) {
261 try {
262 const current = await loadConfig($)
263 await $.store.set(CONFIG_KEY, Object.assign({}, current, patch))
264 $.ui.toast("qi-pm:设置已保存")
265 if (typeof patch.prompt === "string" && patch.prompt !== "" && !patch.prompt.includes(BEGIN)) {
266 $.ui.toast("模板里没有 " + BEGIN + " 标记,改写结果可能无法回填")
267 }
268 $.ui.invalidate("ui.render")
269 } catch (err) {
270 status($, "保存设置失败 — " + why(err))
271 }
272}
273
274// ------------------------------------------------------------- the requests
275
276// Join the base with a path: a base that already names the full path is kept
277// as it is, a base ending in /v1 gets the tail alone, so both https://host
278// and https://host/v1 land on the same URL.
279function joinPath(baseUrl, path) {
280 let base = baseUrl.trim()
281 while (base.endsWith("/")) base = base.slice(0, -1)
282 if (path === "") return base
283 if (base.endsWith(path)) return base
284 const tail = path.slice(3)
285 if (base.endsWith("/v1")) return base + tail
286 return base + path
287}
288
289function endpointFor(protocol, baseUrl) {
290 return joinPath(baseUrl, PROTOCOL_PATHS[protocol] || "")
291}
292
293// The auth each protocol wants on every call, the models list included.
294function authHeaders(protocol, apiKey) {
295 if (protocol === "anthropic") {
296 return { "content-type": "application/json", "x-api-key": apiKey, "anthropic-version": "2023-06-01" }
297 }
298 return { "content-type": "application/json", authorization: "Bearer " + apiKey }
299}
300
301// One user message carries everything, template included; there is no
302// system slot, because a proxy-backed model read it as content to improve.
303function requestFor(protocol, config, message) {
304 const headers = authHeaders(protocol, config.apiKey)
305 let body
306 if (protocol === "anthropic") {
307 body = {
308 model: config.model,
309 max_tokens: MAX_TOKENS,
310 messages: [{ role: "user", content: message }],
311 }
312 } else if (protocol === "openai-responses") {
313 body = {
314 model: config.model,
315 input: message,
316 max_output_tokens: MAX_TOKENS,
317 }
318 } else {
319 body = {
320 model: config.model,
321 max_tokens: MAX_TOKENS,
322 messages: [{ role: "user", content: message }],
323 }
324 }
325 return { url: endpointFor(protocol, config.baseUrl), headers, body: JSON.stringify(body) }
326}
327
328function textFrom(protocol, json) {
329 if (!json || typeof json !== "object") return undefined
330 if (protocol === "anthropic") {
331 const parts = Array.isArray(json.content) ? json.content : []
332 const texts = []
333 for (const part of parts) {
334 if (part && part.type === "text" && typeof part.text === "string") texts.push(part.text)
335 }
336 return texts.length > 0 ? texts.join("") : undefined
337 }
338 if (protocol === "openai-chat") {
339 const choices = Array.isArray(json.choices) ? json.choices : []
340 const message = choices[0] && choices[0].message
341 return message && typeof message.content === "string" ? message.content : undefined
342 }
343 if (protocol === "openai-responses") {
344 if (typeof json.output_text === "string" && json.output_text !== "") return json.output_text
345 const output = Array.isArray(json.output) ? json.output : []
346 const texts = []
347 for (const item of output) {
348 if (!item || item.type !== "message" || !Array.isArray(item.content)) continue
349 for (const part of item.content) {
350 if (part && part.type === "output_text" && typeof part.text === "string") texts.push(part.text)
351 }
352 }
353 return texts.length > 0 ? texts.join("") : undefined
354 }
355 return undefined
356}
357
358function errorFrom(json) {
359 if (!json || typeof json !== "object") return undefined
360 const error = json.error
361 if (error && typeof error === "object" && typeof error.message === "string") return error.message
362 if (typeof error === "string" && error !== "") return error
363 if (typeof json.message === "string" && json.message !== "") return json.message
364 return undefined
365}
366
367// --------------------------------------------------------------- the calls
368
369// $.model.fork replays the main thread's last request — its model, system
370// prompt, tools and messages — with our instruction after it, so the request
371// looks exactly like the main conversation's. A relay that serves only
372// Claude Code traffic passes this one and turns away the light request
373// complete sends, which is what the HTTP 503 on the first run was.
374async function forkRewrite($, draft, template) {
375 try {
376 return await $.model.fork({ prompt: buildPrompt(draft, "", template) })
377 } catch (err) {
378 return { isAnswered: false, reason: "fork 调用失败", error: why(err) }
379 }
380}
381
382// The fallback for a session whose main thread has not answered yet: fork
383// has no prefix to replay then, so complete carries the request instead. It
384// has no transcript of its own, so it reads one and sends it along.
385async function completeRewrite($, draft, template) {
386 const model = await resolveModel($)
387 if (!model) return { isAnswered: false, reason: "拿不到当前模型" }
388
389 const context = await readContext($)
390 let result
391 try {
392 result = await $.model.complete({
393 model,
394 prompt: buildPrompt(draft, context, template),
395 maxTokens: MAX_TOKENS,
396 })
397 } catch (err) {
398 return { isAnswered: false, reason: "complete 调用失败", error: why(err) }
399 }
400 if (typeof result === "string") return stripContext(result, context)
401 if (result && result.isAnswered && typeof result.text === "string") {
402 return Object.assign({}, result, { text: stripContext(result.text, context) })
403 }
404 return result
405}
406
407// The custom-endpoint path: one plain HTTP call, the three protocols folded
408// into one shape by requestFor and textFrom.
409async function customRewrite($, config, draft, template) {
410 const context = await readContext($)
411 const request = requestFor(config.protocol, config, buildPrompt(draft, context, template))
412
413 let response
414 try {
415 response = await $.http.fetch(request.url, {
416 method: "POST",
417 headers: request.headers,
418 body: request.body,
419 })
420 } catch (err) {
421 return { isAnswered: false, reason: "请求没有发出去", error: why(err) }
422 }
423
424 let json
425 try {
426 json = JSON.parse(response.text)
427 } catch (err) {
428 json = undefined
429 }
430
431 if (!response.ok) {
432 const detail = errorFrom(json) || response.text.slice(0, 200)
433 return { isAnswered: false, reason: "HTTP " + response.status, error: detail }
434 }
435 if (!json) {
436 return { isAnswered: false, reason: "响应不是 JSON", error: response.text.slice(0, 200) }
437 }
438
439 const text = textFrom(config.protocol, json)
440 if (typeof text !== "string" || text.trim() === "") {
441 return { isAnswered: false, reason: "响应里没有文本", error: errorFrom(json) }
442 }
443 return { isAnswered: true, text: stripContext(text, context) }
444}
445
446async function readDraft($, explicitDraft) {
447 if (typeof explicitDraft === "string" && explicitDraft.trim() !== "") return explicitDraft.trim()
448 try {
449 const box = await $.prompt.read()
450 return box && typeof box.text === "string" ? box.text.trim() : ""
451 } catch (err) {
452 return ""
453 }
454}
455
456function viaNote(via) {
457 if (via === "fork") return "(主模型,带完整会话上下文)"
458 if (via === "complete") return "(主模型,只带最近对话)"
459 return "(自定义端点)"
460}
461
462// `explicitDraft` comes from /qi-pm <text>; without it the box is the source.
463async function rewrite($, explicitDraft) {
464 const draft = await readDraft($, explicitDraft)
465 if (!draft) {
466 status($, "输入框是空的,也没有给参数")
467 return
468 }
469
470 status($, "改写中…")
471
472 const config = await loadConfig($)
473 const template = templateFor(config)
474 let via
475 let raw
476 if (isReady(config)) {
477 via = config.protocol
478 raw = await customRewrite($, config, draft, template)
479 } else {
480 via = "fork"
481 raw = await forkRewrite($, draft, template)
482 // Only an absent prefix falls back. Every other failure would fail the
483 // same way through complete, and a relay that rejects light requests
484 // rejects that path outright.
485 if (!raw.isAnswered && raw.reason === "nothing-to-fork") {
486 via = "complete"
487 raw = await completeRewrite($, draft, template)
488 }
489 }
490
491 const refusal = refusalOf(raw)
492 if (refusal) {
493 status($, "改写被拒绝 — " + via + " · " + refusal)
494 return
495 }
496
497 const out = extract(textOf(raw))
498 if (!out) {
499 const shape = typeof raw === "string" ? "字符串" : (raw === null ? "null" : typeof raw)
500 status($, "输出里没有 " + BEGIN + " 标记(返回类型 " + shape + ")")
501 return
502 }
503
504 let filled
505 try {
506 filled = await $.prompt.fill({ text: out, mode: "replace" })
507 } catch (err) {
508 status($, "写回输入框失败 — " + why(err))
509 return
510 }
511 if (filled && filled.isFilled === false) {
512 status($, "输入框没有接受这次写入")
513 return
514 }
515
516 status($, "改写完成" + viaNote(via) + ",改完按 Enter 发送")
517}
518
519// ------------------------------------------------------------- the settings pane
520
521function summaryLine(config) {
522 if (config.protocol === "main") return "当前走主模型(Claude Code 自己的)"
523 if (!isReady(config)) return "自定义端点还缺字段,暂时回退主模型"
524 return "当前走 " + endpointFor(config.protocol, config.baseUrl) + ",模型 " + config.model
525}
526
527function keyHint(config) {
528 return config.apiKey !== "" ? "已设置,输入新值覆盖" : "未设置"
529}
530
531async function loadModels($) {
532 try {
533 const raw = await $.store.get(MODELS_KEY)
534 if (Array.isArray(raw)) {
535 const ids = []
536 for (const item of raw) {
537 if (typeof item === "string" && item !== "") ids.push(item)
538 }
539 return ids
540 }
541 } catch (err) {
542 // fall through
543 }
544 return []
545}
546
547// Reads the endpoint's own model list so nobody has to guess an id from
548// memory: gpt-6 and "GPT 6" are not the same string to a service.
549async function fetchModels($) {
550 const config = await loadConfig($)
551 if (config.protocol === "main") {
552 status($, "先在协议里选一个自定义接口,再拉模型列表")
553 return
554 }
555 if (config.baseUrl === "") {
556 status($, "先填端点,再拉模型列表")
557 return
558 }
559
560 let response
561 try {
562 response = await $.http.fetch(joinPath(config.baseUrl, "/v1/models"), {
563 method: "GET",
564 headers: authHeaders(config.protocol, config.apiKey),
565 })
566 } catch (err) {
567 status($, "拉取模型列表失败 — " + why(err))
568 return
569 }
570
571 let json
572 try {
573 json = JSON.parse(response.text)
574 } catch (err) {
575 json = undefined
576 }
577 if (!response.ok) {
578 const detail = errorFrom(json) || response.text.slice(0, 200)
579 status($, "拉取模型列表失败 — HTTP " + response.status + " · " + detail)
580 return
581 }
582
583 const data = json && Array.isArray(json.data) ? json.data : []
584 const ids = []
585 for (const item of data) {
586 if (item && typeof item.id === "string" && item.id !== "") ids.push(item.id)
587 }
588 if (ids.length === 0) {
589 status($, "模型列表是空的")
590 return
591 }
592
593 try {
594 await $.store.set(MODELS_KEY, ids)
595 } catch (err) {
596 status($, "模型列表没能保存 — " + why(err))
597 return
598 }
599 $.ui.toast("qi-pm:拉到 " + ids.length + " 个模型,在面板里选")
600 $.ui.invalidate("ui.render")
601}
602
603async function togglePane($) {
604 try {
605 const panes = await $.ui.panes()
606 let isOpen = false
607 for (const pane of panes) {
608 if (pane.id === PANE_ID) isOpen = true
609 }
610 if (isOpen) {
611 await $.ui.close({ id: PANE_ID })
612 return
613 }
614 const opened = await $.ui.open({
615 id: PANE_ID,
616 title: "qi-pm 设置",
617 focus: true,
618 closeOnEscape: true,
619 rows: 16,
620 })
621 if (opened && opened.isPlaced === false) {
622 status($, "终端太窄,设置面板暂时显示不了 — " + opened.reason)
623 }
624 } catch (err) {
625 status($, "打开设置面板失败 — " + why(err))
626 }
627}
628
629function protocolSelect(ui, $, config) {
630 return ui.Select({
631 key: "protocol",
632 label: "协议",
633 options: PROTOCOL_LABELS,
634 value: config.protocol,
635 onSelect: (value) => {
636 void saveConfig($, { protocol: value })
637 },
638 })
639}
640
641// Shown instead of the free-text field once a list was fetched; the stored
642// model stays first if the endpoint no longer lists it.
643function modelSelect(ui, $, config, choices) {
644 const options = []
645 for (const id of choices) options.push({ value: id, label: id })
646 if (config.model !== "" && choices.indexOf(config.model) < 0) {
647 options.unshift({ value: config.model, label: config.model + "(当前,不在列表里)" })
648 }
649 return ui.Select({
650 key: "model",
651 label: "模型",
652 options,
653 value: config.model,
654 onSelect: (value) => {
655 void saveConfig($, { model: value })
656 },
657 })
658}
659
660// The key field never draws the stored key: it starts empty and a blank
661// submit is ignored, so Enter on an untouched field cannot blank the key.
662function settingInput(ui, $, config, key, label, placeholder, isSecret) {
663 return ui.Input({
664 key,
665 label,
666 placeholder,
667 value: isSecret ? "" : config[key],
668 onSubmit: (value) => {
669 const text = value.trim()
670 if (isSecret && text === "") return
671 void saveConfig($, { [key]: text })
672 },
673 })
674}
675
676function buttonRow(ui, $, config) {
677 const buttons = [
678 ui.Button({
679 key: "fetch-models",
680 label: "拉取模型",
681 plain: true,
682 dimColor: true,
683 onPress: () => {
684 void fetchModels($)
685 },
686 }),
687 ui.Button({
688 key: "close",
689 label: "关闭",
690 plain: true,
691 onPress: () => {
692 void $.ui.close({ id: PANE_ID })
693 },
694 }),
695 ]
696 if (config.apiKey !== "") {
697 buttons.push(ui.Button({
698 key: "clear-key",
699 label: "清除密钥",
700 plain: true,
701 dimColor: true,
702 onPress: () => {
703 void saveConfig($, { apiKey: "" })
704 },
705 }))
706 }
707 if (config.prompt !== "") {
708 buttons.push(ui.Button({
709 key: "reset-template",
710 label: "恢复默认模板",
711 plain: true,
712 dimColor: true,
713 onPress: () => {
714 void saveConfig($, { prompt: "" })
715 },
716 }))
717 }
718 return ui.Box({ flexDirection: "row", gap: 2, children: buttons })
719}
720
721async function paneBody($, e) {
722 const ui = $.ui.resolve(e)
723 const config = await loadConfig($)
724
725 // The mobile table draws no Input or Select yet, so it gets the summary
726 // alone rather than a tree that cannot draw.
727 if (e.surface === "mobile") {
728 return ui.Box({
729 flexDirection: "column",
730 children: [ui.Text({ children: summaryLine(config) })],
731 })
732 }
733
734 const choices = await loadModels($)
735 const modelRow = choices.length > 0
736 ? modelSelect(ui, $, config, choices)
737 : settingInput(ui, $, config, "model", "模型", "gpt-6 或 claude-sonnet-4-5 之类,或点「拉取模型」", false)
738
739 const rows = [
740 ui.Text({ bold: true, children: "qi-pm 改写设置" }),
741 ui.Text({ dimColor: true, children: summaryLine(config) }),
742 protocolSelect(ui, $, config),
743 settingInput(ui, $, config, "baseUrl", "端点", "https://api.example.com(可带 /v1,留空回退主模型)", false),
744 settingInput(ui, $, config, "apiKey", "密钥", keyHint(config), true),
745 modelRow,
746 settingInput(ui, $, config, "prompt", "模板", "留空用内置模板;自定义需含 " + BEGIN + " 标记", false),
747 buttonRow(ui, $, config),
748 ]
749 return ui.Box({ flexDirection: "column", gap: 1, children: rows })
750}
751
752// ------------------------------------------------------------------ wiring
753
754export function register(on, options) {
755 // Registering in session.start makes the commands available from the first
756 // turn. A taken name throws, and a throw here would skip the rest of this
757 // hook, so each is wrapped.
758 on("session.start", async ($, e, next) => {
759 try {
760 await $.command.register({
761 name: COMMAND_NAME,
762 description: "改写输入框里的草稿,结果回填等你确认",
763 argumentHint: "[草稿]",
764 })
765 await $.command.register({
766 name: CONFIG_COMMAND,
767 description: "打开 qi-pm 设置面板:自定义端点与密钥",
768 })
769 } catch (err) {
770 status($, "注册命令失败 — " + why(err))
771 }
772 return next(e)
773 })
774
775 // /qi-pm [草稿] — with no argument it rewrites what is in the box.
776 on("command.run", { command: COMMAND_NAME }, async ($, e) => {
777 await rewrite($, e.args)
778 return {}
779 }).catch(async ($, e, next) => {
780 // command.run gates the command, so it carries a catch handler: a throw
781 // here would otherwise leave the command without an answer.
782 const failure = next.error
783 const detail = failure && failure.message ? failure.message : (failure ? failure.kind : "unknown")
784 status($, "命令失败 — " + detail)
785 return {}
786 })
787
788 // /qi-pm-config — opens the settings pane, or closes it when it is up.
789 on("command.run", { command: CONFIG_COMMAND }, async ($, e) => {
790 await togglePane($)
791 return {}
792 }).catch(async ($, e, next) => {
793 const failure = next.error
794 const detail = failure && failure.message ? failure.message : (failure ? failure.kind : "unknown")
795 status($, "命令失败 — " + detail)
796 return {}
797 })
798
799 // The band above the prompt. hotkey "p" works while the box is empty, so a
800 // bare p then Enter is the fastest path; "s" opens the settings pane.
801 on("ui.render", { component: "AbovePrompt" }, async ($, e) => {
802 const { Box, Button } = $.ui.resolve(e)
803 return Box({
804 flexDirection: "row",
805 gap: 1,
806 children: [
807 Button({
808 key: COMMAND_NAME,
809 label: "改写",
810 hotkey: "p",
811 plain: true,
812 onPress: () => {
813 void rewrite($)
814 },
815 }),
816 Button({
817 key: CONFIG_COMMAND,
818 label: "设置",
819 hotkey: "s",
820 plain: true,
821 onPress: () => {
822 void togglePane($)
823 },
824 }),
825 ],
826 })
827 })
828
829 // The settings pane's body, redrawn whenever saveConfig invalidates.
830 on("ui.render", { component: "Pane", requestId: PANE_ID }, async ($, e) => paneBody($, e))
831}
832
833// Exported for the tests alone; the engine only reads register.
834export { endpointFor, requestFor, textFrom, errorFrom, joinPath, authHeaders, buildPrompt, stripContext, templateFor, RULES }
835