@qjysqjy/dsh-prompt-optimizer 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 QJYSQJY
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,125 @@
1
- # Temporary Holding Version
1
+ # 提示词优化
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 在 DSH Web UI 输入框的工具行里加一个**优化**控件:把当前草稿就地改写为结构化提示词,回填输入框,并且可以一键还原原文。
4
+
5
+ ## 做什么,不做什么
6
+
7
+ - 改写是**纯文本操作**:不执行工具、不发消息、不产生会话轮次。
8
+ - 复用**当前会话自己的模型路由**(`agent.model.current()` → Agent options → 部署默认值),不需要额外配置 API Key 或供应商账号。
9
+ - 只动**草稿**。附件与 `@` 引用芯片保持原样;草稿以一个已声明的斜杠命令开头时控件不生效,因为覆盖首芯片会丢掉命令声明。
10
+
11
+ ## 样子
12
+
13
+ 输入框工具行里,控件排在模型选择器左侧:
14
+
15
+ | 草稿已填、待优化 | 优化进行中(再点即取消) | 改写完成(点一下还原) |
16
+ |---|---|---|
17
+ | ![typed](./screenshots/composer-typed.jpg) | ![optimizing](./screenshots/toolrow-optimizing.jpg) | ![undo](./screenshots/toolrow-undo.jpg) |
18
+
19
+ 插件页里的条目(标题、描述与图标都来自包内资源,未激活时也能读到):
20
+
21
+ ![plugin row](./screenshots/plugins-row.jpg)
22
+
23
+ ## 安装
24
+
25
+ 先在仓库根 `pnpm install`,再:
26
+
27
+ ```text
28
+ plugin_manager install_bundle <仓库根>/packages/prompt-optimizer
29
+ ```
30
+
31
+ 安装后刷新页面即可看到控件。改动 `lib/client.js` 后刷新页面生效;改动 `lib/index.js` 需要重新应用插件行(Host 模块已在进程里)。
32
+
33
+ ## 配置
34
+
35
+ 每个字段都可选,缺省走内置默认值。在 profile 的 `cordis.patch.yml` 里按行 id 覆盖:
36
+
37
+ ```yaml
38
+ - id: qjysqjy-prompt-optimizer
39
+ config:
40
+ useContext: true
41
+ systemTemplate: |
42
+ You rewrite drafts into terse, concrete task prompts.
43
+ Reply in the draft's own language and return only the prompt.
44
+ ```
45
+
46
+ | 字段 | 默认 | 含义 |
47
+ |---|---|---|
48
+ | `systemTemplate` | 内置 | 系统提示词。空字符串表示用内置契约。 |
49
+ | `userTemplate` | 内置 | 首次改写的用户提示词,占位符 `{draft}`。 |
50
+ | `revisionTemplate` | 内置 | 增量修订的用户提示词,占位符 `{draft}`、`{previous}`。 |
51
+ | `contextTemplate` | 内置 | 携带上下文时追加的段落,占位符 `{context}`。 |
52
+ | `useContext` | `false` | 是否把会话近期消息作为背景发给模型。 |
53
+ | `maxContextChars` | `2000` | 开启上下文时保留的字符数上限。 |
54
+ | `maxDraftChars` | `12000` | 接受的最大草稿长度,超过直接拒绝且不调模型。 |
55
+ | `maxOutputTokens` | `4096` | 单次改写的输出上限。 |
56
+ | `provider` / `model` | 未设 | 可选的固定路由,两者同时设置才生效,用于停止跟随会话。 |
57
+
58
+ ## HTTP 端点
59
+
60
+ ```text
61
+ POST /__qjysqjy-prompt-optimizer/optimize
62
+ 请求:{ text: string, sessionId: string, previousResult?: string }
63
+ 响应:{ enhanced: string, language: string, provider: string, model: string }
64
+ ```
65
+
66
+ - 空草稿、纯空白、超长、缺 `sessionId` 一律 4xx,**不调用模型**。
67
+ - `content-type` 必须是 JSON;带 `Origin` 时校验同源;请求体上限 96 KiB。
68
+ - 没有可用路由时返回 409,模型调用失败返回 502。
69
+
70
+ ## 行为契约
71
+
72
+ | 状态 | 触发 | 控件 | 点击后 |
73
+ |---|---|---|---|
74
+ | idle | 首次渲染、还原之后 | ✨ | 发起优化 |
75
+ | optimizing | ✨ 被按下 | 转圈 | **取消**——`fetch` 被 abort,Host 侧连带中断模型调用 |
76
+ | done | 改写已回填 | ↩️ | 还原为捕获的原文 |
77
+ | dirty | 回填后用户又编辑 | ↩️ 禁用,并出现第二个 ✨ | 还原被拒绝;第二个 ✨ 基于当前编辑做增量修订 |
78
+
79
+ 测试钉住的规则:
80
+
81
+ - 空草稿或纯空白禁用控件,不发请求。
82
+ - 用户在请求期间继续输入,回填前比对快照,结果**被丢弃**而不是覆盖。
83
+ - 还原绝不覆盖改写之后用户自己写的字。
84
+ - 缺少 `inputActions.setDraft` 时显式报错,不降级到 DOM 操作。
85
+ - 模型输出在 Host 侧确定性清洗(代码围栏、`Enhanced prompt:` / `优化后的提示词:` 标签、外层引号)之后才可能进入草稿。
86
+ - 语言锁定:中文进中文出,英文进英文出。
87
+
88
+ ## 布局
89
+
90
+ ```text
91
+ lib/index.js Host 半:路由、路由解析、模型调用
92
+ lib/optimize.js 引擎:流收集、失败映射
93
+ lib/prompt.js 纯函数:模板、清洗、语言判定
94
+ lib/client.js Client 半:槽位注册、状态机、样式
95
+ test/ unit.test.js(纯函数与引擎)、client.test.mjs(状态机)
96
+ ```
97
+
98
+ ## 测试
99
+
100
+ ```text
101
+ node --test test/unit.test.js test/client.test.mjs
102
+ ```
103
+
104
+ Client 套件加载**真实的** `lib/client.js`,用最小 React 替身驱动,因此测的是发布出去的那份代码。
105
+
106
+ ## 已实测(DSH 0.2.2-rc.1,Web UI)
107
+
108
+ 用 CDP 驱动真实页面逐条走过:
109
+
110
+ | 场景 | 结果 |
111
+ |---|---|
112
+ | 空草稿 | 按钮 `disabled`,`title` 为“草稿为空,无需优化”,无 network 请求 |
113
+ | 正常改写 | 200 回填,按钮变↩️,`data-done` 置位 |
114
+ | 请求中取消 | `fetch` 收到 `AbortError`,`signal.aborted === true`,草稿不变,提示“已取消优化” |
115
+ | 请求中继续输入 | 请求照常返回 200,结果被丢弃,页面提示“草稿在优化期间被修改,结果已丢弃”,用户新写的字保留 |
116
+ | 改写后再编辑 | ↩️ 置 `disabled` 且 `data-stale`,强行触发点击也不覆盖;旁边出现增量优化 ✨ |
117
+ | 还原 | 草稿精确回到原文,按钮回 idle |
118
+ | 中文进 / 英文进 | `language` 分别为 `zh` / `en`,输出语言一致 |
119
+
120
+ ## 已知限制
121
+
122
+ - 只在 **DSH Web UI** 形态可用(`dsh.client.platform: "web"`)。CLI/TUI 与 ACP 形态不注入任何槽位,未做任何验证。
123
+ - `conversation.composer` 是可被接管的 chain;若被其他插件临时接管,本控件随整条工具行一起不渲染。该形态未构造验证。
124
+ - 移动端窄视口未在真机验证:控件尺寸按桌面工具行设计(28×28),触摸命中区域未测。
125
+ - 深浅色主题只在实际使用的那套皮肤下截图确认过;控件只用 `--dsw-alias-*` 令牌,换肤应当跟随,但未逐皮肤验证。
@@ -0,0 +1,6 @@
1
+ # Host half of the prompt optimizer. Service access is declared by the module's
2
+ # own `export const inject`; this row must NOT carry an `inject` field, because
3
+ # a row-level `inject` would also lock the Client half out of its services.
4
+ - insert:
5
+ - id: qjysqjy-prompt-optimizer
6
+ name: '@qjysqjy/dsh-prompt-optimizer'
package/icon.svg ADDED
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Prompt optimizer">
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#5686fe"/>
5
+ <stop offset="1" stop-color="#8a5cf6"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="2" y="2" width="60" height="60" rx="14" fill="#101725"/>
9
+ <path d="M26 16l4.4 11.6L42 32l-11.6 4.4L26 48l-4.4-11.6L10 32l11.6-4.4L26 16z" fill="url(#g)"/>
10
+ <path d="M47 30l2.2 5.8 5.8 2.2-5.8 2.2L47 46l-2.2-5.8L39 38l5.8-2.2L47 30z" fill="#dbe4ff"/>
11
+ </svg>
package/lib/client.js ADDED
@@ -0,0 +1,392 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@qjysqjy/dsh-prompt-optimizer",
3
+ factory: (require) => {
4
+ const React = require("react");
5
+ const h = React.createElement;
6
+ /** Same-origin Host endpoint owned by this package's Host half. */
7
+ const OPTIMIZE_PATH = "/__qjysqjy-prompt-optimizer/optimize";
8
+ /** Locale namespace for this package's visible text. */
9
+ const NS = "qjysqjy-prompt-optimizer";
10
+ /** Stand-in InputState for a composition that supplies no `useInput`. */
11
+ const EMPTY_INPUT = { draft: "", phase: "plain", attachmentIds: [] };
12
+ /** Style element id, so two plugin generations cannot double-inject. */
13
+ const STYLE_ID = "@qjysqjy/dsh-prompt-optimizer/prompt-optimizer.css";
14
+
15
+ const zh = {
16
+ "action.optimize": "优化提示词",
17
+ "action.cancel": "取消优化",
18
+ "action.undo": "还原为改写前",
19
+ "action.refine": "基于我的修改继续优化",
20
+ "state.optimizing": "正在优化…",
21
+ "state.done": "已优化,可一键还原",
22
+ "state.dirty": "草稿已修改,原文还原已失效",
23
+ "hint.empty": "草稿为空,无需优化",
24
+ "hint.missing": "当前输入框未提供写入接口,无法回填优化结果",
25
+ "notice.discarded": "草稿在优化期间被修改,结果已丢弃",
26
+ "notice.unchanged": "优化结果与原文一致,无需还原",
27
+ "notice.failed": "优化失败",
28
+ "notice.cancelled": "已取消优化",
29
+ };
30
+ const en = {
31
+ "action.optimize": "Optimize prompt",
32
+ "action.cancel": "Cancel optimization",
33
+ "action.undo": "Restore original draft",
34
+ "action.refine": "Optimize again from your edit",
35
+ "state.optimizing": "Optimizing…",
36
+ "state.done": "Optimized; one click restores the original",
37
+ "state.dirty": "Draft edited; the original can no longer be restored",
38
+ "hint.empty": "The draft is empty; nothing to optimize",
39
+ "hint.missing": "This composer exposes no draft writer, so the result cannot be filled in",
40
+ "notice.discarded": "The draft changed while optimizing; the result was discarded",
41
+ "notice.unchanged": "The rewrite matched the draft, so there is nothing to restore",
42
+ "notice.failed": "Optimization failed",
43
+ "notice.cancelled": "Optimization cancelled",
44
+ };
45
+
46
+ const CSS = `
47
+ .dspo-root{display:inline-flex;align-items:center;gap:2px;min-width:0}
48
+ .dspo-button{
49
+ appearance:none;display:grid;place-items:center;width:28px;height:28px;flex:none;
50
+ padding:0;border:0;border-radius:999px;background:transparent;
51
+ color:var(--dsw-alias-label-secondary);cursor:pointer;
52
+ transition:background-color .1s,color .1s;
53
+ }
54
+ .dspo-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
55
+ .dspo-button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
56
+ .dspo-button:disabled{opacity:.4;cursor:default}
57
+ .dspo-button[data-busy]{color:var(--dsw-alias-brand-primary);cursor:progress}
58
+ .dspo-button[data-done]{color:var(--dsw-alias-brand-primary)}
59
+ .dspo-button[data-stale]{opacity:.4;cursor:default}
60
+ .dspo-spinner{animation:dspo-spin .8s linear infinite}
61
+ @keyframes dspo-spin{to{transform:rotate(360deg)}}
62
+ @media (prefers-reduced-motion:reduce){.dspo-spinner{animation-duration:2.4s}}
63
+ .dspo-alert{
64
+ max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
65
+ color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;
66
+ }
67
+ .dspo-notice{
68
+ max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
69
+ color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;
70
+ }
71
+ `;
72
+
73
+ /** Sparkle glyph: the "enhance" affordance. */
74
+ function IconSparkle(props) {
75
+ return h("svg", {
76
+ viewBox: "0 0 16 16", width: 16, height: 16, fill: "none",
77
+ "aria-hidden": true, focusable: "false", ...props,
78
+ }, h("path", {
79
+ d: "M6.5 1.8l1.05 2.9 2.9 1.05-2.9 1.05L6.5 9.7 5.45 6.8 2.55 5.75 5.45 4.7 6.5 1.8z",
80
+ fill: "currentColor",
81
+ }), h("path", {
82
+ d: "M12 9l.6 1.65 1.65.6-1.65.6L12 13.5l-.6-1.65-1.65-.6 1.65-.6L12 9z",
83
+ fill: "currentColor",
84
+ }));
85
+ }
86
+
87
+ /** Undo glyph: restore the original draft. */
88
+ function IconUndo(props) {
89
+ return h("svg", {
90
+ viewBox: "0 0 16 16", width: 16, height: 16, fill: "none",
91
+ "aria-hidden": true, focusable: "false", ...props,
92
+ }, h("path", {
93
+ d: "M6.2 4.2L2.6 7.8l3.6 3.6",
94
+ stroke: "currentColor", strokeWidth: "1.5",
95
+ strokeLinecap: "round", strokeLinejoin: "round",
96
+ }), h("path", {
97
+ d: "M2.8 7.8h6.1a3.4 3.4 0 0 1 0 6.8H6.6",
98
+ stroke: "currentColor", strokeWidth: "1.5",
99
+ strokeLinecap: "round", strokeLinejoin: "round",
100
+ }));
101
+ }
102
+
103
+ /** Spinner glyph while a request is in flight. */
104
+ function IconSpinner(props) {
105
+ return h("svg", {
106
+ className: "dspo-spinner",
107
+ viewBox: "0 0 16 16", width: 16, height: 16, fill: "none",
108
+ "aria-hidden": true, focusable: "false", ...props,
109
+ }, h("circle", {
110
+ cx: 8, cy: 8, r: 5.6,
111
+ stroke: "currentColor", strokeWidth: "1.8", strokeOpacity: "0.25",
112
+ }), h("path", {
113
+ d: "M13.6 8A5.6 5.6 0 0 0 8 2.4",
114
+ stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round",
115
+ }));
116
+ }
117
+
118
+ /**
119
+ * The optimize control.
120
+ *
121
+ * Four states, one primary button: idle (sparkle), optimizing (spinner,
122
+ * press cancels), done (undo), and the implicit dirty state where an undo
123
+ * would overwrite text the user wrote afterwards, so it is refused. A
124
+ * second sparkle appears only while dirty, so an edited rewrite can still be
125
+ * refined against the previous result.
126
+ */
127
+ function PromptOptimizer(props) {
128
+ const { sessionId, inputActions, useInput } = props;
129
+ // The slot's declared `locale: NS` supplies `t`; `tFallback` keeps the
130
+ // control readable in a composition whose locale dictionary has not
131
+ // registered yet.
132
+ const t = typeof props.t === "function"
133
+ ? props.t
134
+ : (typeof props.tFallback === "function" ? props.tFallback : (key) => en[key] ?? key);
135
+ // Hook order must not depend on which props arrived: `useInput` is a
136
+ // standard prop, but a silent undefined must still cost the same two
137
+ // hooks on every render.
138
+ const readInput = typeof useInput === "function" ? useInput : (selector) => selector(EMPTY_INPUT);
139
+ const draft = readInput((state) => state.draft);
140
+ const phase = readInput((state) => state.phase);
141
+ const [status, setStatus] = React.useState("idle");
142
+ const [rollback, setRollback] = React.useState(null);
143
+ const [error, setError] = React.useState("");
144
+ const [notice, setNotice] = React.useState("");
145
+
146
+ /** Latest draft, readable from an async continuation without stale props. */
147
+ const draftRef = React.useRef(draft);
148
+ draftRef.current = typeof draft === "string" ? draft : "";
149
+ /** The in-flight request, so a second press really aborts it. */
150
+ const requestRef = React.useRef(null);
151
+ const mountedRef = React.useRef(true);
152
+ React.useEffect(() => {
153
+ mountedRef.current = true;
154
+ return () => {
155
+ mountedRef.current = false;
156
+ requestRef.current?.abort();
157
+ requestRef.current = null;
158
+ };
159
+ }, []);
160
+
161
+ const canWrite = inputActions !== undefined && typeof inputActions.setDraft === "function";
162
+ // Only a plain draft is optimizable: a claimed slash command owns the
163
+ // leading chip, and a sending/submitting draft is read-only.
164
+ const writable = phase === undefined ? true : phase === "plain";
165
+ const current = draftRef.current;
166
+ const trimmed = current.trim();
167
+ const enhanced = rollback === null ? undefined : rollback.enhanced;
168
+ // Compare in the editor's own normalization so the draft the editor
169
+ // publishes right after a fill is not mistaken for a user edit: newline
170
+ // style and trailing whitespace per line are ignored, everything a user
171
+ // actually types is not.
172
+ const dirty = enhanced != null && normalizeDraft(current) !== normalizeDraft(enhanced);
173
+ const canUndo = enhanced != null && !dirty;
174
+ const optimizing = status === "optimizing";
175
+
176
+ /**
177
+ * One optimization round trip.
178
+ * @param {string} text - draft captured at request time.
179
+ * @param {string | undefined} previous - earlier rewrite, for a revision.
180
+ */
181
+ const optimize = async (text, previous) => {
182
+ if (canWrite === false) {
183
+ setError(t("hint.missing"));
184
+ return;
185
+ }
186
+ const snapshot = text;
187
+ const controller = new AbortController();
188
+ requestRef.current = controller;
189
+ setStatus("optimizing");
190
+ setError("");
191
+ setNotice("");
192
+ try {
193
+ const response = await fetch(OPTIMIZE_PATH, {
194
+ method: "POST",
195
+ headers: { "content-type": "application/json" },
196
+ body: JSON.stringify({
197
+ text: snapshot,
198
+ sessionId,
199
+ ...(previous === undefined ? {} : { previousResult: previous }),
200
+ }),
201
+ signal: controller.signal,
202
+ });
203
+ const body = await response.json().catch(() => ({}));
204
+ if (requestRef.current !== controller) return;
205
+ requestRef.current = null;
206
+ if (mountedRef.current === false) return;
207
+ if (response.ok === false) {
208
+ setStatus("idle");
209
+ setError(typeof body.message === "string" && body.message !== ""
210
+ ? body.message
211
+ : `${t("notice.failed")} (${String(body.error ?? response.status)})`);
212
+ return;
213
+ }
214
+ const result = typeof body.enhanced === "string" ? body.enhanced : "";
215
+ if (result === "") {
216
+ setStatus("idle");
217
+ setError(t("notice.failed"));
218
+ return;
219
+ }
220
+ // Race guard: any edit during the request discards the result rather
221
+ // than overwriting newer text.
222
+ if (normalizeDraft(draftRef.current) !== normalizeDraft(snapshot)) {
223
+ setStatus("idle");
224
+ setNotice(t("notice.discarded"));
225
+ return;
226
+ }
227
+ // A rewrite identical to the draft leaves nothing to undo.
228
+ if (normalizeDraft(result) === normalizeDraft(snapshot)) {
229
+ setStatus("idle");
230
+ setNotice(t("notice.unchanged"));
231
+ return;
232
+ }
233
+ inputActions.setDraft(result);
234
+ setRollback({ original: snapshot, enhanced: result });
235
+ setStatus("done");
236
+ } catch (failure) {
237
+ if (requestRef.current !== controller) return;
238
+ requestRef.current = null;
239
+ if (mountedRef.current === false) return;
240
+ setStatus("idle");
241
+ if (controller.signal.aborted) setNotice(t("notice.cancelled"));
242
+ else setError(failure instanceof Error ? failure.message : String(failure));
243
+ }
244
+ };
245
+
246
+ const onPrimary = () => {
247
+ if (status === "optimizing") {
248
+ // Real cancellation: aborting the fetch closes the response, which
249
+ // aborts the Host-side model call.
250
+ requestRef.current?.abort();
251
+ requestRef.current = null;
252
+ setStatus("idle");
253
+ setNotice(t("notice.cancelled"));
254
+ return;
255
+ }
256
+ if (rollback !== null) {
257
+ // A disabled button does not receive clicks in a browser, but a
258
+ // keyboard activation, a synthetic event, or a stale render can still
259
+ // reach this handler: never restore over text the user has since
260
+ // written.
261
+ if (dirty || canWrite === false || writable === false) return;
262
+ inputActions.setDraft(rollback.original);
263
+ setRollback(null);
264
+ setStatus("idle");
265
+ setNotice("");
266
+ return;
267
+ }
268
+ if (trimmed === "" || writable === false) return;
269
+ optimize(current, undefined);
270
+ };
271
+
272
+ const onRefine = () => {
273
+ if (trimmed === "" || writable === false || enhanced === undefined) return;
274
+ optimize(current, enhanced);
275
+ };
276
+
277
+ // With a rollback point in memory the primary control keeps its undo
278
+ // identity even after the draft was edited further: it stays visible so the
279
+ // user can see what restoring would mean, and is simply disabled while the
280
+ // draft no longer matches the rewrite it would replace.
281
+ const label = optimizing
282
+ ? t("action.cancel")
283
+ : rollback !== null
284
+ ? t("action.undo")
285
+ : t("action.optimize");
286
+ const tooltip = optimizing
287
+ ? t("state.optimizing")
288
+ : canUndo
289
+ ? t("state.done")
290
+ : dirty
291
+ ? t("state.dirty")
292
+ : trimmed === ""
293
+ ? t("hint.empty")
294
+ : t("action.optimize");
295
+
296
+ const primaryDisabled = optimizing === false
297
+ && (rollback !== null && dirty
298
+ ? true
299
+ : (canUndo === false && (trimmed === "" || writable === false)));
300
+
301
+ return h("span", { className: "dspo-root" },
302
+ error === ""
303
+ ? null
304
+ : h("span", { className: "dspo-alert", role: "alert", title: error }, error),
305
+ notice === ""
306
+ ? null
307
+ : h("span", { className: "dspo-notice", role: "status" }, notice),
308
+ optimizing === false && dirty
309
+ ? h("button", {
310
+ type: "button",
311
+ className: "dspo-button",
312
+ title: t("action.refine"),
313
+ "aria-label": t("action.refine"),
314
+ disabled: writable === false || trimmed === "",
315
+ onMouseDown: (event) => event.preventDefault(),
316
+ onClick: onRefine,
317
+ }, h(IconSparkle))
318
+ : null,
319
+ h("button", {
320
+ type: "button",
321
+ className: "dspo-button",
322
+ title: notice === "" ? tooltip : `${tooltip} · ${notice}`,
323
+ "aria-label": label,
324
+ "aria-busy": optimizing ? "true" : undefined,
325
+ disabled: primaryDisabled,
326
+ "data-busy": optimizing ? "" : undefined,
327
+ "data-done": canUndo ? "" : undefined,
328
+ "data-stale": rollback !== null && dirty ? "" : undefined,
329
+ onMouseDown: (event) => event.preventDefault(),
330
+ onClick: onPrimary,
331
+ }, optimizing
332
+ ? h(IconSpinner)
333
+ : rollback !== null
334
+ ? h(IconUndo)
335
+ : h(IconSparkle)),
336
+ );
337
+ }
338
+
339
+ /**
340
+ * Normalize a draft for comparison only.
341
+ * @param {string} text - draft text.
342
+ * @returns the comparable form.
343
+ */
344
+ function normalizeDraft(text) {
345
+ return String(text).replace(/\r\n?/gu, "\n").replace(/[ \t]+$/gmu, "").trimEnd();
346
+ }
347
+
348
+ /** Insert this package's stylesheet once, and remove it with the plugin. */
349
+ function insertStyles() {
350
+ if (document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`) !== null) return () => {};
351
+ const style = document.createElement("style");
352
+ style.dataset.pluginCss = STYLE_ID;
353
+ style.textContent = CSS;
354
+ document.head.appendChild(style);
355
+ return () => style.remove();
356
+ }
357
+
358
+ return {
359
+ name: "qjysqjy-prompt-optimizer",
360
+ // `slots` is the only hard requirement: the control renders nowhere else.
361
+ inject: ["slots"],
362
+ apply(ctx) {
363
+ ctx.effect(insertStyles, "qjysqjy-prompt-optimizer: styles");
364
+
365
+ const locale = (() => {
366
+ try {
367
+ return ctx.get("locale");
368
+ } catch {
369
+ return undefined;
370
+ }
371
+ })();
372
+ let t;
373
+ if (locale !== undefined && typeof locale.register === "function") {
374
+ ctx.effect(() => locale.register(NS, { zh, en }), "qjysqjy-prompt-optimizer: copy");
375
+ t = locale.bind(NS);
376
+ } else {
377
+ const table = { zh, en };
378
+ t = (key) => table.en[key] ?? key;
379
+ }
380
+
381
+ ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
382
+ name: "conversation.input.right",
383
+ id: "qjysqjy-prompt-optimizer",
384
+ // Sits before the model seat, beside the other trailing controls.
385
+ order: 2,
386
+ locale: NS,
387
+ inject: () => ({ t }),
388
+ }, PromptOptimizer));
389
+ },
390
+ };
391
+ },
392
+ });