@qjysqjy/dsh-prompt-optimizer 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +83 -2
- package/cordis.patch.yml +6 -0
- package/icon.svg +11 -0
- package/lib/client.js +392 -0
- package/lib/index.js +349 -0
- package/lib/optimize.js +177 -0
- package/lib/prompt.js +233 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
- package/screenshots/composer-typed.jpg +0 -0
- package/screenshots/plugins-row.jpg +0 -0
- package/screenshots/toolrow-optimizing.jpg +0 -0
- package/screenshots/toolrow-undo.jpg +0 -0
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,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 提示词优化
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在 DSH Web UI 输入框的工具行里加一个**优化**控件:把当前草稿就地改写为结构化提示词,回填输入框,并且可以一键还原原文。
|
|
4
|
+
|
|
5
|
+
- 改写是**纯文本操作**:不执行工具、不发消息、不产生会话轮次。
|
|
6
|
+
- 复用**当前会话自己的模型路由**,不需要额外配置 API Key 或供应商账号。
|
|
7
|
+
- 只动**草稿**。附件与 `@` 引用芯片保持原样;草稿以斜杠命令开头时控件不生效。
|
|
8
|
+
|
|
9
|
+
## 样子
|
|
10
|
+
|
|
11
|
+
| 草稿已填 | 优化进行中(再点即取消) | 改写完成(点一下还原) |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
|  |  |  |
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
## 安装
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
plugin_manager install_bundle @qjysqjy/dsh-prompt-optimizer
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
也可以在 **侧边栏 → 插件 → 添加插件** 里输入同一个包名。想跟最新提交就改用 `github:adonis797/dsh-qjysqjy#path:/packages/prompt-optimizer`;改代码则从本地克隆装,先在仓库根 `pnpm install`。
|
|
24
|
+
|
|
25
|
+
装好刷新页面即可,控件出现在输入框工具行、模型选择器左侧。
|
|
26
|
+
|
|
27
|
+
## 配置
|
|
28
|
+
|
|
29
|
+
每个字段都可选,缺省走内置默认值。在 profile 的 `cordis.patch.yml` 里按行 id 覆盖:
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
- id: qjysqjy-prompt-optimizer
|
|
33
|
+
config:
|
|
34
|
+
useContext: false
|
|
35
|
+
maxOutputTokens: 2048
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| 字段 | 默认 | 含义 |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `systemTemplate` | 内置 | 系统提示词。空字符串表示用内置契约。 |
|
|
41
|
+
| `userTemplate` | 内置 | 首次改写的用户提示词,占位符 `{draft}`。 |
|
|
42
|
+
| `revisionTemplate` | 内置 | 增量修订的用户提示词,占位符 `{draft}`、`{previous}`。 |
|
|
43
|
+
| `contextTemplate` | 内置 | 携带上下文时追加的段落,占位符 `{context}`。 |
|
|
44
|
+
| `useContext` | `true` | 是否把会话近期消息作为背景发给模型。关掉可省 token,也让改写只依据草稿。 |
|
|
45
|
+
| `maxContextChars` | `2000` | 开启上下文时保留的字符数上限。 |
|
|
46
|
+
| `maxDraftChars` | `12000` | 接受的最大草稿长度,超过直接拒绝且不调模型。 |
|
|
47
|
+
| `maxOutputTokens` | `4096` | 单次改写的输出上限。 |
|
|
48
|
+
| `provider` / `model` | 未设 | 可选的固定路由,两者同时设置才生效,用于停止跟随会话。 |
|
|
49
|
+
|
|
50
|
+
## 行为
|
|
51
|
+
|
|
52
|
+
| 状态 | 触发 | 控件 | 点击后 |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| idle | 首次渲染、还原之后 | ✨ | 发起优化 |
|
|
55
|
+
| optimizing | ✨ 被按下 | 转圈 | **取消**——`fetch` 被 abort,Host 侧连带中断模型调用 |
|
|
56
|
+
| done | 改写已回填 | ↩️ | 还原为捕获的原文 |
|
|
57
|
+
| dirty | 回填后用户又编辑 | ↩️ 禁用,并多出一个 ✨ | 还原被拒绝;新 ✨ 基于当前编辑做增量修订 |
|
|
58
|
+
|
|
59
|
+
几条钉住的规则:空草稿不发请求;请求期间用户继续输入,结果**被丢弃**而不是覆盖;还原绝不覆盖改写之后用户自己写的字;语言锁定,中文进中文出、英文进英文出。
|
|
60
|
+
|
|
61
|
+
## HTTP 端点
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
POST /__qjysqjy-prompt-optimizer/optimize
|
|
65
|
+
请求:{ text: string, sessionId: string, previousResult?: string }
|
|
66
|
+
响应:{ enhanced: string, language: string, provider: string, model: string }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
空草稿、超长、缺 `sessionId` 一律 4xx 且不调模型;请求体上限 96 KiB;无可用路由返回 409,模型失败返回 502。
|
|
70
|
+
|
|
71
|
+
## 开发
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
lib/index.js Host 半:路由、路由解析、模型调用
|
|
75
|
+
lib/optimize.js 引擎:流收集、失败映射
|
|
76
|
+
lib/prompt.js 纯函数:模板、清洗、语言判定
|
|
77
|
+
lib/client.js Client 半:槽位注册、状态机、样式
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
node --test test/unit.test.js test/client.test.mjs
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Client 套件加载**真实的** `lib/client.js`,用最小 React 替身驱动,因此测的是发布出去的那份代码。
|
package/cordis.patch.yml
ADDED
|
@@ -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
|
+
});
|