SLOPSHOPPER

Qi Prompt Master

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

panebandcommandtoaststatusmodel
★ 1v0.1.0MITupdated 2026-10-16qizhuxu/qi-pm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · qi-pm
│ ┃ qi-pm 设置 ✕ › fix the failing auth test and add an audit log call │ ┃ qi-pm 改写设置 │ ┃ ⏺ Read(src/auth.ts) │ ┃ 当前走主模型(Claude Code 自己的) ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 协议: 主模型(Claude Code 自己的) ▾ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ 端点: https://api.example.com(可带 /v1,留空 ⎿ 3 pass, 1 fail │ ┃ │ ┃ 密钥: 未设置 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ 模型: gpt-6 或 claude-sonnet-4-5 之类,或点「 ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ 模板: 留空用内置模板;自定义需含 <enhanced-p › /qi-pm │ ┃ │ ┃ 拉取模型 关闭 │ p: 改写 s: 设置 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ qi-pm: 输入框是空的,也没有给参数

Draws

Band
p: 改写 s: 设置
Pane · qi-pm 设置
qi-pm 改写设置 当前走主模型(Claude Code 自己的) 协议: 主模型(Claude Code 自己的) ▾ 端点: https://api.example.com(可带 /v1,留空回退主模型) 密钥: 未设置 模型: gpt-6 或 claude-sonnet-4-5 之类,或点「拉取模型」 模板: 留空用内置模板;自定义需含 <enhanced-prompt> 标记 拉取模型 关闭
README

qi-pm

一个 Claude Code mod:用旁路模型改写输入框里的草稿,结果回填到输入框,由你决定改不改、发不发。

它做什么

  1. 你在输入框里敲一个草稿
  2. 按提示词上方 band 里的「改写」按钮(空输入框里也可以直接按 p)
  3. 它读草稿,调一次旁路模型改写
  4. 改写结果写回输入框,不发送
  5. 你改完按 Enter 才发出去

主对话看不到这次改写,除非你按了 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 的区别

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 计数。

用到的 API

调用作用
$.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

主模型路径为什么先走 fork

$.model.complete() 按设计发的是轻量请求:没有工具定义、没有历史。只放行 Claude Code 请求的中转站会拒绝它(实测 HTTP 503)。

$.model.fork() 把主线程最后一次请求原样重放:同一个模型、同一份 system prompt、同一套工具定义、同一段消息历史,只在末尾追加一条用户消息(改写指令 + 草稿)。请求特征和主对话一致,而且能命中提示词缓存。

只有一个情况退回 complete:nothing-to-fork,即主线程还没产生过回复(全新会话或刚 /clear),没有前缀可重放。

如果中转站连 fork 也拒绝,就用自定义端点绕开它。

提示词模板

整个改写请求是一条用户消息(wukong 的结构),没有 system prompt:代理后面的聊天模型会把 system 里的说明当成「要改写的东西」,实测踩过这个坑。消息分三段,草稿放最后:

  1. [Context] 块(自定义端点路径才有):项目目录 + 最近 10 条消息;主模型路径由 fork 的完整历史承担上下文
  2. 模板:wukong 原文的框架 + 三处增补
  3. 草稿,由模板末尾的「Here is my original instruction:」引出

模板正文是 wukong 自己的措辞,第一人称(「give you」「my original instruction」)。这个框架是实测选出来的:早先版本用 ### REWRITE REQUEST ### 机器头加「不得添加草稿没有的要求」这类规则,同一模型对照实验里产出退化成转述;换回 wukong 措辞后产出恢复为结构化增强。

在 wukong 原文基础上增补了三句,每句对应一次实测的失败:

  • 草稿用「之前聊的那些」这类模糊指代时,点名上下文里的具体事项(否则模型不解析)
  • 原样保留文件路径、命令、报错文本(Claude Code 的提示词常带裸路径,改错了很麻烦)
  • 不把上下文块和模板本身带进改写结果(代码里还有 stripContext 兜底)

模板可以在设置面板里整段替换(对应 wukong 的 setDefaultPrompt),留空就用上面这套内置的;自定义模板必须自己保留「结果包在 <enhanced-prompt> 标记里」的要求,否则回填拿不到内容。

上下文怎么取

  • 主模型路径:fork 自带完整会话历史,不需要另取
  • 自定义端点路径:参考 dsh 的 wukong 插件,取最近 10 条消息,每条压成一行、截断 1500 字符,加上当前工作目录;读不到就降级为不带上下文,而不是失败
  • 改写模型偶尔会把上下文块照抄进答案里,代码在回填前会把它剥掉(stripContext)

测试

npm test

node --test,覆盖三个协议的请求构造、端点拼接与响应解析。都是纯函数,不需要 Claude Code 运行。

已知边界

  • 只在能绘制的界面里工作(终端、桌面端、VS Code 扩展);手机端面板只显示摘要,不能编辑
  • 自定义端点直连,能不能用取决于服务商对三种协议的兼容程度
  • $.model.complete 的返回值在不同版本里形状不同(对象或字符串),代码两种都接受
  • 自定义端点的请求没有超时控制,服务端不响应时状态行会停在「改写中…」
  • 如果最近的对话本身在讨论「改写工具」或「测试改写」,聊天味重的代理模型可能以对话方式回复而不是执行改写(实测 gpt-6 如此);正常的开发对话没有这个问题

类型从哪来

首次用 --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

Source 1 files
hooks/register.js 835 lines
1// 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