dsh-prompt-enhance 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -55,6 +55,12 @@ The plugin is one npm package with two halves, following the dsh plugin conventi
55
55
  - `dsh >= 0.1.1-rc.1`
56
56
  - Node `^22.19.0 || >=24.0.0` (for building from source)
57
57
 
58
+ | | |
59
+ |---|---|
60
+ | dsh | `>= 0.1.1-rc.1` |
61
+ | Node | `^22.19.0 \|\| >=24.0.0` |
62
+ | Plugin | `0.1.x` |
63
+
58
64
  ## Install
59
65
 
60
66
  From npm (recommended):
@@ -95,13 +101,13 @@ dsh plugin --profile web remove dsh-prompt-enhance
95
101
  3. The result phase shows both texts side by side. **Apply** fills the enhanced text back and raises the undo bar; **Copy** puts it on the clipboard; **Cancel** (or `Esc`, or clicking the overlay) discards everything.
96
102
  4. The undo bar sits above the composer: one click restores the original. If you keep typing after applying, the bar quietly retires itself so stale text can never overwrite newer edits.
97
103
 
98
- **`/enhance <text>`** — rewrite any text from the slash menu. The result renders in the command plane (copyable) and never enters the conversation history or the model's context. To enhance the *composer draft itself*, use the button or shortcut — the draft lives in the browser.
104
+ **`/enhance <text>`** — rewrite any text from the slash menu. The result renders in the command plane (copyable) and never enters the conversation history or the model's context. To enhance the *composer draft itself*, use the button or shortcut — the draft lives in the browser. Cancelling a running `/enhance` follows the harness command plane; if the client offers no cancel affordance, the call simply runs to completion or times out.
99
105
 
100
- **Language consistency** is guaranteed by the rewrite strategy: Chinese in, Chinese out; English in, English out.
106
+ **Language consistency**: the strategy instructs the model to mirror the input language (Chinese in → Chinese out). This is a best-effort instruction, not a hard guarantee.
101
107
 
102
108
  ## Configuration
103
109
 
104
- Everything lives in the `prompt-enhance` settings namespace, edited from the web GUI's **Settings → 插件配置** page. Changes apply to the very next call — no restart.
110
+ Everything lives in the `prompt-enhance` settings namespace, edited from the web GUI's **Settings → 插件配置** page. Changes apply to the very next call — no restart. Every enhancement is one billable LLM call: `maxOutputTokens` bounds its cost, and the host-side concurrency/rate caps bound how often calls can be made.
105
111
 
106
112
  | Field | Default | Description |
107
113
  |---|---|---|
@@ -109,10 +115,12 @@ Everything lives in the `prompt-enhance` settings namespace, edited from the web
109
115
  | `provider` + `model` | empty | Explicit route override; must be filled as a **pair** (or both empty to follow the current session model) |
110
116
  | `temperature` | `0.3` | Low temperature keeps the rewrite faithful to the original |
111
117
  | `maxOutputTokens` | `2048` | Output token budget of one enhancement call |
112
- | `maxInputChars` | `12000` | Input character cap; over-limit drafts are **rejected, never truncated** |
118
+ | `maxInputChars` | `12000` | Input character cap (counted in Unicode code points — an emoji is one character); over-limit drafts are **rejected, never truncated** |
113
119
  | `timeoutMs` | `60000` | End-to-end deadline of one call |
114
120
  | `systemPrompt` | built-in strategy | Replace the whole enhancement strategy if you prefer your own |
115
121
  | `shortcut` | `ctrl+alt+e` | Global shortcut spec (at least one modifier + one alphanumeric/function key — bare keys are ignored so normal typing can never be swallowed); empty disables it |
122
+ | `maxConcurrent` | `2` | Host-side concurrency cap; extra requests answer `429` |
123
+ | `rateLimitPerMinute` | `10` | Sliding-window rate cap per minute; extra requests answer `429` |
116
124
  | `provider` + `model` values | — | Match the harness settings: each key under `llm-pi-ai.providers` (e.g. `zhipu`, `muyuu`) is a provider and each `models[].id` under it (e.g. `glm-5.3-flash`) is a model. Example pair: `provider: zhipu` + `model: glm-5.3-flash` |
117
125
 
118
126
  **Model routing precedence:** explicit settings pair → the route recorded in the current session's request header → the harness-wide default model (`agent-default-model`). If none of them names a route (e.g. a fresh session with no default model), the plugin fails with an actionable message instead of guessing.
@@ -156,9 +164,10 @@ The enhance route is served by your own dsh host and reachable **only from this
156
164
 
157
165
  - **Socket fence** — requests from non-loopback addresses are refused (`127.0.0.1` / `::1` only). Note this means *any local process* can call the route; it carries no user authentication.
158
166
  - **Host allowlist** — the route also validates the `Host` header against `localhost` / `127.0.0.1` / `[::1]`, which defeats DNS-rebinding (a rebound attacker domain keeps the loopback socket address but carries the attacker's hostname and is refused). Responses are `cache-control: no-store`.
167
+ - **Abuse caps** — an `Origin` gate refuses browser calls from non-local pages, and the route enforces a concurrency cap (`maxConcurrent`, default 2) and a per-minute rate limit (`rateLimitPerMinute`, default 10), answering `429` beyond either.
159
168
  - **Proxy rejection** — requests carrying `X-Forwarded-For` / `Forwarded` headers are refused outright: those headers only exist when a proxy is in the path, which the trust model does not cover.
160
169
  - **Not for reverse-proxy exposure** — if you put dsh web behind a proxy that listens on the LAN, external callers appear as loopback to the route and the fence is moot. Do not expose a proxied host without adding your own authentication at the proxy.
161
- - **Prompt-injection boundary** — the draft is framed between `<raw_prompt>` tags, literal closing tags inside the draft are neutralized, and the strategy prompt treats the framed text as pure data. Enhancements run with your own credentials and the result is only ever shown back to you.
170
+ - **Prompt-injection boundary** — the draft is framed between `<raw_prompt>` tags, literal closing tags inside the draft are neutralized, and the strategy prompt treats the framed text as pure data. This lowers the risk of simple tag-escape; prompt-based boundaries are best-effort, not a guarantee. Enhancements run with your own credentials and the result is only ever shown back to you.
162
171
 
163
172
  ## Architecture
164
173
 
@@ -233,6 +242,16 @@ Browser → your own dsh host over a loopback-only route → the harness LLM ser
233
242
  **Does it work with the official DeepSeek route?**
234
243
  Yes — it rides `ctx.llm`, so any provider the harness serves (DeepSeek official, OpenAI-compatible gateways) works.
235
244
 
245
+ ## Manual smoke checklist
246
+
247
+ After installing and restarting `dsh web`:
248
+
249
+ 1. Settings → 插件配置 shows the `prompt-enhance` section.
250
+ 2. The ✨ button sits next to the send button; `Ctrl+Alt+E` triggers the same flow.
251
+ 3. Empty input → refusal panel; a valid draft → preview with model info; Apply fills back; Undo restores; Copy works.
252
+ 4. `/enhance <text>` renders a copyable result without touching model history.
253
+ 5. All three model routes work: pinned settings pair, a session's model, and the harness default.
254
+
236
255
  ## Acknowledgments
237
256
 
238
257
  Plugin structure, the loopback fence, and the settings-section pattern follow the conventions established by the dsh plugin family — in particular [`@linxin666/dsh-tool-describe-image`](https://www.npmjs.com/package/@linxin666/dsh-tool-describe-image).
package/README.zh-CN.md CHANGED
@@ -55,6 +55,12 @@ flowchart LR
55
55
  - `dsh >= 0.1.1-rc.1`
56
56
  - Node `^22.19.0 || >=24.0.0`(仅从源码构建时需要)
57
57
 
58
+ | | |
59
+ |---|---|
60
+ | dsh | `>= 0.1.1-rc.1` |
61
+ | Node | `^22.19.0 \|\| >=24.0.0` |
62
+ | 插件 | `0.1.x` |
63
+
58
64
  ## 安装
59
65
 
60
66
  从 npm(推荐):
@@ -95,13 +101,13 @@ dsh plugin --profile web remove dsh-prompt-enhance
95
101
  3. 结果阶段左右并排展示两份文本。**回填**把增强文本写回输入框并升起撤销条;**复制**进剪贴板;**取消**(`Esc` 或点击遮罩)丢弃一切。
96
102
  4. 撤销条停在输入框上方:一键恢复原文。回填后你继续输入,撤销条会安静地自我退位——过期的原文永远不会覆盖你更新的编辑。
97
103
 
98
- **`/enhance <文本>`** —— 从斜杠菜单改写任意文本。结果在命令面板渲染(可复制),不进入会话历史与模型上下文。要增强**输入框里的草稿**请用按钮或快捷键——草稿在浏览器里。
104
+ **`/enhance <文本>`** —— 从斜杠菜单改写任意文本。结果在命令面板渲染(可复制),不进入会话历史与模型上下文。要增强**输入框里的草稿**请用按钮或快捷键——草稿在浏览器里。正在运行的 `/enhance` 取消跟随 harness 命令面板能力;客户端未提供取消入口时,调用会执行到完成或超时。
99
105
 
100
- **语言一致性**由重写策略保证:中文进中文出,英文进英文出。
106
+ **语言一致性**:重写策略默认要求模型保持输入语言(中文进中文出)——尽力而为的约束,无法严格保证。
101
107
 
102
108
  ## 配置
103
109
 
104
- 全部配置位于 `prompt-enhance` 设置命名空间,在 Web GUI 的 **设置 → 插件配置** 页编辑。改动作用于下一次调用——无需重启。
110
+ 全部配置位于 `prompt-enhance` 设置命名空间,在 Web GUI 的 **设置 → 插件配置** 页编辑。改动作用于下一次调用——无需重启。每次增强都是一次计费的 LLM 调用——`maxOutputTokens` 约束单次成本,并发/限流字段约束调用频率。
105
111
 
106
112
  | 字段 | 默认 | 说明 |
107
113
  |---|---|---|
@@ -109,10 +115,12 @@ dsh plugin --profile web remove dsh-prompt-enhance
109
115
  | `provider` + `model` | 空 | 显式路由覆盖;必须**成对**填写(或都留空以跟随当前会话模型) |
110
116
  | `temperature` | `0.3` | 低温使改写更忠实于原意 |
111
117
  | `maxOutputTokens` | `2048` | 单次增强调用的输出 token 预算 |
112
- | `maxInputChars` | `12000` | 输入字数上限;超限**拒绝而不截断** |
118
+ | `maxInputChars` | `12000` | 输入字数上限(按 Unicode 码点统计,一个 emoji 算一个字);超限**拒绝而不截断** |
113
119
  | `timeoutMs` | `60000` | 单次调用的端到端超时 |
114
120
  | `systemPrompt` | 内置策略 | 想用自己的增强策略时整体替换 |
115
121
  | `shortcut` | `ctrl+alt+e` | 快捷键规格(至少一个修饰键 + 单个字母/数字/功能键——纯裸键会被忽略,绝不会吞掉正常打字);留空禁用 |
122
+ | `maxConcurrent` | `2` | 宿主侧并发上限;超出的请求返回 `429` |
123
+ | `rateLimitPerMinute` | `10` | 每分钟滑动窗口限流;超出的请求返回 `429` |
116
124
  | `provider` + `model` 取值 | — | 与 harness 设置一致:设置文件 `llm-pi-ai.providers` 下的键就是 provider(如 `zhipu`、`muyuu`),其 `models[].id` 就是 model(如 `glm-5.3-flash`)。示例:`provider: zhipu` + `model: glm-5.3-flash` |
117
125
 
118
126
  **模型路由优先级:** 设置成对覆盖 → 当前会话请求头中记录的路由 → 全局默认模型(`agent-default-model`)。三者都指不出路由时(例如全新会话且无默认模型),插件给出可操作的报错而不是乱猜。
@@ -156,9 +164,10 @@ dsh plugin --profile web remove dsh-prompt-enhance
156
164
 
157
165
  - **套接字栅栏** —— 非回环地址(`127.0.0.1` / `::1` 之外)的请求一律拒绝。注意这意味着*本机任意进程*都可以调用该路由;路由本身不带用户鉴权。
158
166
  - **Host 白名单** —— 路由同时校验 `Host` 头是否为 `localhost` / `127.0.0.1` / `[::1]`,可防御 DNS 重绑定(重绑定的攻击者域名虽然落在回环套接字上,但携带的是攻击者主机名,会被拒绝)。响应均为 `cache-control: no-store`。
167
+ - **滥用上限** —— `Origin` 门拒绝来自非本地页面的浏览器调用;路由还执行并发上限(`maxConcurrent`,默认 2)与每分钟限流(`rateLimitPerMinute`,默认 10),超限返回 `429`。
159
168
  - **代理请求拒绝** —— 携带 `X-Forwarded-For` / `Forwarded` 头的请求直接拒绝:这些头只会在链路上存在代理时出现,而代理场景不在本路由的信任模型内。
160
169
  - **不要经反向代理暴露** —— 若把 dsh web 放在监听局域网的代理后面,外部调用者在路由看来就是回环地址,栅栏形同虚设。除非在代理层自行加鉴权,否则不要暴露。
161
- - **提示注入边界** —— 草稿被框在 `<raw_prompt>` 标签之间,草稿内的字面闭合标签会被中和,策略提示词把框内文本视为纯数据。增强只使用你自己的凭据,结果也只回显给你本人。
170
+ - **提示注入边界** —— 草稿被框在 `<raw_prompt>` 标签之间,草稿内的字面闭合标签会被中和,策略提示词把框内文本视为纯数据——降低简单标签逃逸的风险;基于提示词的边界是尽力而为,并非完整防护。增强只使用你自己的凭据,结果也只回显给你本人。
162
171
 
163
172
  ## 架构
164
173
 
@@ -232,6 +241,16 @@ CI 在每次 push/PR 上运行类型检查 + 测试 + 构建,并在 `lib/` 与
232
241
  **官方 DeepSeek 路由能用吗?**
233
242
  能——调用走 `ctx.llm`,harness 支持的所有 provider(DeepSeek 官方、OpenAI 兼容网关)都可用。
234
243
 
244
+ ## 安装后手工冒烟清单
245
+
246
+ 安装并重启 `dsh web` 后:
247
+
248
+ 1. 设置 → 插件配置 出现 `prompt-enhance` 配置节。
249
+ 2. 发送键旁出现 ✨ 按钮;`Ctrl+Alt+E` 触发相同流程。
250
+ 3. 空输入 → 拒绝面板;有效草稿 → 预览含模型信息;回填生效;撤销恢复;复制可用。
251
+ 4. `/enhance <文本>` 输出可复制结果,且不进入模型历史。
252
+ 5. 三种模型路由都通:设置成对覆盖、会话模型、全局默认模型。
253
+
235
254
  ## 致谢
236
255
 
237
256
  插件结构、回环栅栏与设置节模式遵循 dsh 插件家族的既有惯例——特别是 [`@linxin666/dsh-tool-describe-image`](https://www.npmjs.com/package/@linxin666/dsh-tool-describe-image)。
package/lib/client.js CHANGED
@@ -44,8 +44,9 @@ function checkInputText(text, maxChars) {
44
44
  if (stripped.trim().length === 0) {
45
45
  return { ok: false, code: "empty" };
46
46
  }
47
- if (text.length > maxChars) {
48
- return { ok: false, code: "too-long", count: text.length, max: maxChars };
47
+ const codePoints = [...text].length;
48
+ if (codePoints > maxChars) {
49
+ return { ok: false, code: "too-long", count: codePoints, max: maxChars };
49
50
  }
50
51
  return { ok: true };
51
52
  }
@@ -60,6 +61,22 @@ var EnhanceClientError = class extends Error {
60
61
  this.detail = detail;
61
62
  }
62
63
  };
64
+ var KNOWN_ERROR_CODES = /* @__PURE__ */ new Set(["rejected", "rate", "timeout", "upstream", "unconfigured", "internal"]);
65
+ function parseError(value) {
66
+ const record = value;
67
+ if (record !== null && typeof record === "object" && typeof record.message === "string" && record.message !== "") {
68
+ const code = typeof record.code === "string" && KNOWN_ERROR_CODES.has(record.code) ? record.code : "internal";
69
+ return { code, message: record.message };
70
+ }
71
+ return { code: "internal", message: "\u5BBF\u4E3B\u670D\u52A1\u8FD4\u56DE\u5F02\u5E38\u3002" };
72
+ }
73
+ function parseResult(value) {
74
+ const record = value;
75
+ if (record !== null && typeof record === "object" && typeof record.text === "string" && record.text !== "" && typeof record.provider === "string" && record.provider !== "" && typeof record.model === "string" && record.model !== "" && typeof record.elapsedMs === "number" && Number.isFinite(record.elapsedMs)) {
76
+ return { text: record.text, provider: record.provider, model: record.model, elapsedMs: record.elapsedMs };
77
+ }
78
+ throw new EnhanceClientError({ code: "internal", message: "\u5BBF\u4E3B\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u89E3\u6790\u7684\u7ED3\u679C\u3002" });
79
+ }
63
80
  async function readEnvelope(response) {
64
81
  let parsed;
65
82
  try {
@@ -69,11 +86,10 @@ async function readEnvelope(response) {
69
86
  }
70
87
  const envelope = parsed;
71
88
  if (envelope !== null && typeof envelope === "object" && envelope.ok === true) {
72
- const value = envelope.value;
73
- if (value !== void 0 && typeof value.text === "string" && value.text !== "") return value;
89
+ return parseResult(envelope.value);
74
90
  }
75
- if (envelope !== null && typeof envelope === "object" && envelope.error !== void 0 && typeof envelope.error.message === "string") {
76
- throw new EnhanceClientError(envelope.error);
91
+ if (envelope !== null && typeof envelope === "object" && envelope.error !== void 0) {
92
+ throw new EnhanceClientError(parseError(envelope.error));
77
93
  }
78
94
  throw new EnhanceClientError({ code: "internal", message: `\u5BBF\u4E3B\u670D\u52A1\u8FD4\u56DE\u5F02\u5E38\uFF08HTTP ${response.status}\uFF09\u3002` });
79
95
  }
@@ -104,6 +120,8 @@ function localizedErrorMessage(t, code) {
104
120
  switch (code) {
105
121
  case "rejected":
106
122
  return t("error.rejected");
123
+ case "rate":
124
+ return t("error.rate");
107
125
  case "timeout":
108
126
  return t("error.timeout");
109
127
  case "unconfigured":
@@ -557,7 +575,8 @@ var zh = {
557
575
  "error.timeout": "\u589E\u5F3A\u8D85\u65F6\uFF0C\u8BF7\u91CD\u8BD5\uFF1B\u539F\u8F93\u5165\u672A\u6539\u52A8\u3002",
558
576
  "error.unconfigured": "\u5C1A\u672A\u786E\u5B9A\u589E\u5F3A\u7528\u7684\u6A21\u578B\uFF1A\u8BF7\u5728\u63D2\u4EF6\u8BBE\u7F6E\u4E2D\u6210\u5BF9\u586B\u5199 provider/model\u3002",
559
577
  "error.upstream": "\u6A21\u578B\u670D\u52A1\u8FD4\u56DE\u9519\u8BEF\uFF0C\u8BF7\u91CD\u8BD5\uFF1B\u539F\u8F93\u5165\u672A\u6539\u52A8\u3002",
560
- "error.internal": "\u589E\u5F3A\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\uFF1B\u539F\u8F93\u5165\u672A\u6539\u52A8\u3002"
578
+ "error.internal": "\u589E\u5F3A\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\uFF1B\u539F\u8F93\u5165\u672A\u6539\u52A8\u3002",
579
+ "error.rate": "\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\u6216\u5E76\u53D1\u5DF2\u6EE1\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002"
561
580
  };
562
581
  var en = {
563
582
  "button.title": "Enhance prompt (rewrite into a structured prompt)",
@@ -590,7 +609,8 @@ var en = {
590
609
  "error.timeout": "The enhancement timed out. Retry; your draft is untouched.",
591
610
  "error.unconfigured": "No model resolved for the enhancement: pair provider/model in the plugin settings.",
592
611
  "error.upstream": "The model provider returned an error. Retry; your draft is untouched.",
593
- "error.internal": "Enhancement failed. Retry; your draft is untouched."
612
+ "error.internal": "Enhancement failed. Retry; your draft is untouched.",
613
+ "error.rate": "Too many requests or the concurrency cap is full \u2014 retry in a moment."
594
614
  };
595
615
  var dictionaries = { zh, en };
596
616
  var NS = "prompt-enhance";
package/lib/index.js CHANGED
@@ -11,7 +11,9 @@ var DEFAULT_CONFIG = {
11
11
  maxInputChars: 12e3,
12
12
  timeoutMs: 6e4,
13
13
  systemPrompt: "",
14
- shortcut: "ctrl+alt+e"
14
+ shortcut: "ctrl+alt+e",
15
+ maxConcurrent: 2,
16
+ rateLimitPerMinute: 10
15
17
  };
16
18
  var Config = z.object({
17
19
  enabled: z.boolean().default(DEFAULT_CONFIG.enabled).description("\u603B\u5F00\u5173\uFF1A\u5173\u95ED\u540E\u9690\u85CF\u8F93\u5165\u6846\u589E\u5F3A\u6309\u94AE\u5E76\u505C\u7528\u6240\u6709\u89E6\u53D1\u65B9\u5F0F"),
@@ -22,30 +24,45 @@ var Config = z.object({
22
24
  maxInputChars: z.number().step(1).min(200).max(2e5).default(DEFAULT_CONFIG.maxInputChars).description("\u8F93\u5165\u5B57\u6570\u4E0A\u9650\uFF1B\u8D85\u9650\u62D2\u7EDD\u800C\u4E0D\u622A\u65AD\uFF0C\u907F\u514D\u6539\u53D8\u539F\u610F"),
23
25
  timeoutMs: z.number().step(1).min(5e3).max(6e5).default(DEFAULT_CONFIG.timeoutMs).description("\u5355\u6B21\u589E\u5F3A\u7684\u8D85\u65F6\uFF08\u6BEB\u79D2\uFF09"),
24
26
  systemPrompt: z.string().role("textarea").default(DEFAULT_CONFIG.systemPrompt).description("\u7CFB\u7EDF\u63D0\u793A\u8BCD\uFF1B\u7559\u7A7A\u4F7F\u7528\u5185\u7F6E\u589E\u5F3A\u7B56\u7565\uFF0C\u53EF\u6574\u4F53\u66FF\u6362"),
25
- shortcut: z.string().default(DEFAULT_CONFIG.shortcut).description("\u89E6\u53D1\u5FEB\u6377\u952E\uFF08\u5982 ctrl+alt+e\uFF1B\u987B\u5305\u542B ctrl/alt/meta \u4E2D\u81F3\u5C11\u4E00\u4E2A\u4FEE\u9970\u952E\uFF0Cshift \u4EC5\u53EF\u4F5C\u9644\u52A0\uFF0C\u7EAF\u5B57\u6BCD/\u6570\u5B57\u6216 shift+\u5B57\u6BCD\u4F1A\u88AB\u5FFD\u7565\uFF1B\u7559\u7A7A\u7981\u7528\uFF09")
27
+ shortcut: z.string().default(DEFAULT_CONFIG.shortcut).description("\u89E6\u53D1\u5FEB\u6377\u952E\uFF08\u5982 ctrl+alt+e\uFF1B\u987B\u5305\u542B ctrl/alt/meta \u4E2D\u81F3\u5C11\u4E00\u4E2A\u4FEE\u9970\u952E\uFF0Cshift \u4EC5\u53EF\u4F5C\u9644\u52A0\uFF0C\u7EAF\u5B57\u6BCD/\u6570\u5B57\u6216 shift+\u5B57\u6BCD\u4F1A\u88AB\u5FFD\u7565\uFF1B\u7559\u7A7A\u7981\u7528\uFF09"),
28
+ maxConcurrent: z.number().step(1).min(1).max(16).default(DEFAULT_CONFIG.maxConcurrent).description("\u5BBF\u4E3B\u4FA7\u5E76\u53D1\u4E0A\u9650\uFF1A\u540C\u65F6\u8FDB\u884C\u7684\u589E\u5F3A\u8C03\u7528\u6570\uFF0C\u8D85\u51FA\u7684\u8BF7\u6C42\u8FD4\u56DE 429"),
29
+ rateLimitPerMinute: z.number().step(1).min(1).max(600).default(DEFAULT_CONFIG.rateLimitPerMinute).description("\u6BCF\u5206\u949F\u589E\u5F3A\u6B21\u6570\u4E0A\u9650\uFF08\u6ED1\u52A8\u7A97\u53E3\uFF09\uFF0C\u8D85\u51FA\u8FD4\u56DE 429")
26
30
  });
27
31
  function resolveConfig(config) {
28
32
  if (config === null || typeof config !== "object") throw new Error("prompt-enhance: configuration is required");
33
+ if (typeof config.enabled !== "boolean") throw new Error("prompt-enhance: enabled \u5FC5\u987B\u662F\u5E03\u5C14\u503C");
29
34
  const hasProvider = config.provider !== void 0 && config.provider !== "";
30
35
  const hasModel = config.model !== void 0 && config.model !== "";
31
36
  if (hasProvider !== hasModel) {
32
37
  throw new Error("prompt-enhance: provider \u4E0E model \u5FC5\u987B\u6210\u5BF9\u586B\u5199\uFF08\u8981\u4E48\u90FD\u586B\uFF0C\u8981\u4E48\u90FD\u7559\u7A7A\u4EE5\u8DDF\u968F\u5F53\u524D\u4F1A\u8BDD\u6A21\u578B\uFF09");
33
38
  }
34
- const providerBlank = hasProvider && (config.provider ?? "").trim() === "";
35
- const modelBlank = hasModel && (config.model ?? "").trim() === "";
36
- if (providerBlank || modelBlank) {
39
+ const provider = hasProvider ? (config.provider ?? "").trim() : config.provider;
40
+ const model = hasModel ? (config.model ?? "").trim() : config.model;
41
+ if (hasProvider && provider === "" || hasModel && model === "") {
37
42
  throw new Error("prompt-enhance: provider/model \u8986\u76D6\u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32");
38
43
  }
44
+ const temperature = config.temperature;
45
+ if (typeof temperature !== "number" || !Number.isFinite(temperature) || temperature < 0 || temperature > 1) {
46
+ throw new Error("prompt-enhance: temperature \u5FC5\u987B\u662F 0\u20131 \u4E4B\u95F4\u7684\u6709\u9650\u6570\u5B57");
47
+ }
48
+ const intInRange = (value, name2, min, max) => {
49
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) {
50
+ throw new Error(`prompt-enhance: ${name2} \u5FC5\u987B\u662F ${min}\u2013${max} \u4E4B\u95F4\u7684\u6574\u6570`);
51
+ }
52
+ return value;
53
+ };
39
54
  return {
40
55
  enabled: config.enabled,
41
- provider: config.provider,
42
- model: config.model,
43
- temperature: config.temperature,
44
- maxOutputTokens: config.maxOutputTokens,
45
- maxInputChars: config.maxInputChars,
46
- timeoutMs: config.timeoutMs,
47
- systemPrompt: config.systemPrompt,
48
- shortcut: config.shortcut
56
+ provider,
57
+ model,
58
+ temperature,
59
+ maxOutputTokens: intInRange(config.maxOutputTokens, "maxOutputTokens", 256, 32768),
60
+ maxInputChars: intInRange(config.maxInputChars, "maxInputChars", 200, 2e5),
61
+ timeoutMs: intInRange(config.timeoutMs, "timeoutMs", 5e3, 6e5),
62
+ systemPrompt: typeof config.systemPrompt === "string" ? config.systemPrompt : "",
63
+ shortcut: typeof config.shortcut === "string" ? config.shortcut : "",
64
+ maxConcurrent: intInRange(config.maxConcurrent, "maxConcurrent", 1, 16),
65
+ rateLimitPerMinute: intInRange(config.rateLimitPerMinute, "rateLimitPerMinute", 1, 600)
49
66
  };
50
67
  }
51
68
  function effectiveSystemPrompt(config, builtin) {
@@ -63,8 +80,9 @@ function checkInputText(text, maxChars) {
63
80
  if (stripped.trim().length === 0) {
64
81
  return { ok: false, code: "empty" };
65
82
  }
66
- if (text.length > maxChars) {
67
- return { ok: false, code: "too-long", count: text.length, max: maxChars };
83
+ const codePoints = [...text].length;
84
+ if (codePoints > maxChars) {
85
+ return { ok: false, code: "too-long", count: codePoints, max: maxChars };
68
86
  }
69
87
  return { ok: true };
70
88
  }
@@ -81,9 +99,13 @@ import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
81
99
  var EXCESS_BLANK_LINES = /\n{3,}/g;
82
100
  function normalizeOutput(raw) {
83
101
  let text = raw.trim();
84
- const fenceLineCount = (text.match(/^```/gm) ?? []).length;
85
- if (text.startsWith("```") && /```[\s]*$/.test(text) && fenceLineCount === 2) {
86
- text = text.slice(text.indexOf("\n") + 1).replace(/```[\s]*$/, "").trim();
102
+ const fenceMarkCount = (text.match(/```/g) ?? []).length;
103
+ if (text.startsWith("```") && /```[\s]*$/.test(text) && fenceMarkCount === 2) {
104
+ if (text.includes("\n")) {
105
+ text = text.slice(text.indexOf("\n") + 1).replace(/```[\s]*$/, "").trim();
106
+ } else {
107
+ text = text.slice(3, -3).trim();
108
+ }
87
109
  }
88
110
  text = text.replace(EXCESS_BLANK_LINES, "\n\n");
89
111
  return text.trim();
@@ -104,6 +126,7 @@ var DEFAULT_SYSTEM_PROMPT = [
104
126
  "Hard rules:",
105
127
  "- Preserve the user's intent exactly. Do not remove, alter, or contradict any information the user provided.",
106
128
  '- Never fabricate requirements. When a needed detail is unknown, insert a short explicit placeholder such as "(\u5F85\u8865\u5145\uFF1A\u2026)" / "(TBD: \u2026)" instead of making one up.',
129
+ "- Every addition must be traceable to explicit evidence or a strong implication in the raw prompt; when in doubt, use a placeholder instead of adding content.",
107
130
  "- Keep the prompt's scope unchanged: do not widen, narrow, or redirect the task.",
108
131
  "- Output ONLY the rewritten prompt body. No explanations, no preamble, no comparison with the original, no code fences, no pleasantries.",
109
132
  "- Write the rewritten prompt in the SAME language as the user's input (Chinese input \u2192 Chinese prompt; English input \u2192 English prompt).",
@@ -274,10 +297,12 @@ function toEnhanceError(error) {
274
297
  }
275
298
 
276
299
  // src/orchestrate.ts
300
+ import { randomUUID } from "node:crypto";
277
301
  function routeOf(config) {
278
- if (config === void 0) return void 0;
279
- const { provider, model } = config;
280
- if (typeof provider !== "string" || typeof model !== "string" || provider === "" || model === "") return void 0;
302
+ if (config === null || config === void 0) return void 0;
303
+ const provider = typeof config.provider === "string" ? config.provider.trim() : "";
304
+ const model = typeof config.model === "string" ? config.model.trim() : "";
305
+ if (provider === "" || model === "") return void 0;
281
306
  return { provider, model };
282
307
  }
283
308
  function sessionRouteOf(ctx, sessionId) {
@@ -289,27 +314,39 @@ function defaultRouteOf(ctx) {
289
314
  return routeOf(value);
290
315
  }
291
316
  async function runEnhance(ctx, config, options) {
292
- const route = resolveRoute(config, options.sessionRoute, defaultRouteOf(ctx));
293
- if (route === void 0) {
294
- throw new EnhanceFailure({
295
- code: "unconfigured",
296
- message: "\u5C1A\u672A\u786E\u5B9A\u589E\u5F3A\u7528\u7684\u6A21\u578B\uFF1A\u8BF7\u5728\u63D2\u4EF6\u8BBE\u7F6E\u4E2D\u6210\u5BF9\u586B\u5199 provider/model\uFF0C\u6216\u5148\u5728\u5F53\u524D\u4F1A\u8BDD\u53D1\u9001\u4E00\u6761\u6D88\u606F\uFF08\u5C06\u8DDF\u968F\u4F1A\u8BDD\u6A21\u578B\uFF09\u3002"
317
+ const requestId = randomUUID().slice(0, 8);
318
+ const started = Date.now();
319
+ let routeText = "?";
320
+ try {
321
+ const route = resolveRoute(config, options.sessionRoute, defaultRouteOf(ctx));
322
+ if (route === void 0) {
323
+ throw new EnhanceFailure({
324
+ code: "unconfigured",
325
+ message: "\u5C1A\u672A\u786E\u5B9A\u589E\u5F3A\u7528\u7684\u6A21\u578B\uFF1A\u8BF7\u5728\u63D2\u4EF6\u8BBE\u7F6E\u4E2D\u6210\u5BF9\u586B\u5199 provider/model\uFF0C\u6216\u5148\u5728\u5F53\u524D\u4F1A\u8BDD\u53D1\u9001\u4E00\u6761\u6D88\u606F\uFF08\u5C06\u8DDF\u968F\u4F1A\u8BDD\u6A21\u578B\uFF09\u3002"
326
+ });
327
+ }
328
+ routeText = `${route.provider}/${route.model}`;
329
+ const llm = ctx.get("llm");
330
+ if (llm === void 0) {
331
+ throw new EnhanceFailure({ code: "internal", message: "LLM \u670D\u52A1\u4E0D\u53EF\u7528\u3002" });
332
+ }
333
+ const result = await enhanceText(llm, {
334
+ route,
335
+ system: effectiveSystemPrompt(config, DEFAULT_SYSTEM_PROMPT),
336
+ text: options.text,
337
+ temperature: config.temperature,
338
+ maxTokens: config.maxOutputTokens,
339
+ timeoutMs: config.timeoutMs,
340
+ signal: options.signal,
341
+ ...options.sessionId !== void 0 ? { sessionId: options.sessionId } : {}
297
342
  });
343
+ console.info(`[prompt-enhance] ${requestId} route=${routeText} in=${options.text.length} out=${result.text.length} ${result.elapsedMs}ms ok`);
344
+ return result;
345
+ } catch (error) {
346
+ const wire = toEnhanceError(error);
347
+ console.info(`[prompt-enhance] ${requestId} route=${routeText} in=${options.text.length} error=${wire.code} ${Date.now() - started}ms`);
348
+ throw error;
298
349
  }
299
- const llm = ctx.get("llm");
300
- if (llm === void 0) {
301
- throw new EnhanceFailure({ code: "internal", message: "LLM \u670D\u52A1\u4E0D\u53EF\u7528\u3002" });
302
- }
303
- return enhanceText(llm, {
304
- route,
305
- system: effectiveSystemPrompt(config, DEFAULT_SYSTEM_PROMPT),
306
- text: options.text,
307
- temperature: config.temperature,
308
- maxTokens: config.maxOutputTokens,
309
- timeoutMs: config.timeoutMs,
310
- signal: options.signal,
311
- ...options.sessionId !== void 0 ? { sessionId: options.sessionId } : {}
312
- });
313
350
  }
314
351
 
315
352
  // src/loopback.ts
@@ -327,12 +364,23 @@ function hostnameOf(hostHeader) {
327
364
  const colon = trimmed.lastIndexOf(":");
328
365
  return colon === -1 ? trimmed : trimmed.slice(0, colon);
329
366
  }
367
+ function isTrustedOrigin(origin) {
368
+ try {
369
+ const url = new URL(origin);
370
+ return (url.protocol === "http:" || url.protocol === "https:") && TRUSTED_HOSTNAMES.has(url.hostname);
371
+ } catch {
372
+ return false;
373
+ }
374
+ }
330
375
  function isTrustedRequest(req) {
331
376
  if (req.headers["x-forwarded-for"] !== void 0 || req.headers.forwarded !== void 0) return false;
332
377
  if (!isLoopbackRequest(req)) return false;
333
378
  const host = req.headers.host;
334
379
  if (typeof host !== "string" || host.trim() === "") return true;
335
- return TRUSTED_HOSTNAMES.has(hostnameOf(host));
380
+ if (!TRUSTED_HOSTNAMES.has(hostnameOf(host))) return false;
381
+ const origin = req.headers.origin;
382
+ if (typeof origin === "string" && origin.trim() !== "" && !isTrustedOrigin(origin)) return false;
383
+ return true;
336
384
  }
337
385
 
338
386
  // src/http.ts
@@ -358,7 +406,7 @@ function writeJson(res, status, body) {
358
406
 
359
407
  // src/enhance-routes.ts
360
408
  var bodyCapOf = (maxInputChars) => maxInputChars * 6 + 4096;
361
- async function serveEnhance(ctx, readConfig, req, res) {
409
+ async function serveEnhance(ctx, readConfig, gate, req, res) {
362
410
  if (!isTrustedRequest(req)) {
363
411
  writeJson(res, 403, { ok: false, error: { code: "internal", message: "forbidden: loopback-only" } });
364
412
  return;
@@ -399,6 +447,18 @@ async function serveEnhance(ctx, readConfig, req, res) {
399
447
  writeJson(res, 422, { ok: false, error: { code: "rejected", message: formatInputCheckZh(check) } });
400
448
  return;
401
449
  }
450
+ const now = Date.now();
451
+ while (gate.stamps.length > 0 && now - (gate.stamps[0] ?? now) >= 6e4) gate.stamps.shift();
452
+ if (gate.stamps.length >= config.rateLimitPerMinute) {
453
+ writeJson(res, 429, { ok: false, error: { code: "rate", message: `\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\uFF1A\u6BCF\u5206\u949F\u6700\u591A ${config.rateLimitPerMinute} \u6B21\u589E\u5F3A\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002` } });
454
+ return;
455
+ }
456
+ if (gate.active >= config.maxConcurrent) {
457
+ writeJson(res, 429, { ok: false, error: { code: "rate", message: `\u5DF2\u6709 ${config.maxConcurrent} \u4E2A\u589E\u5F3A\u5728\u8FDB\u884C\u4E2D\uFF0C\u8BF7\u7B49\u5F85\u5B8C\u6210\u540E\u518D\u8BD5\u3002` } });
458
+ return;
459
+ }
460
+ gate.stamps.push(now);
461
+ gate.active += 1;
402
462
  const callerAbort = new AbortController();
403
463
  const onConnectionClosed = () => {
404
464
  if (!res.writableEnded) callerAbort.abort();
@@ -417,15 +477,17 @@ async function serveEnhance(ctx, readConfig, req, res) {
417
477
  writeJson(res, wire.code === "timeout" ? 504 : wire.code === "unconfigured" ? 409 : 502, { ok: false, error: wire });
418
478
  } finally {
419
479
  res.off("close", onConnectionClosed);
480
+ gate.active -= 1;
420
481
  }
421
482
  }
422
483
  function registerEnhanceRoute(ctx, readConfig) {
423
484
  const webserver = ctx.get("webServer");
424
485
  if (webserver === void 0) return;
486
+ const gate = { stamps: [], active: 0 };
425
487
  webserver.register({
426
488
  kind: "prefix",
427
489
  path: ENHANCE_ENDPOINT.replace(/\/enhance$/, ""),
428
- handler: (req, res) => serveEnhance(ctx, readConfig, req, res)
490
+ handler: (req, res) => serveEnhance(ctx, readConfig, gate, req, res)
429
491
  });
430
492
  }
431
493