dsh-prompt-rewriter 1.2.1 → 1.5.1
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 +14 -2
- package/README.zh.md +14 -2
- package/client.js +129 -14
- package/index.js +418 -45
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,6 +36,14 @@ The third column is not something you wrote. It read your sentence and filled it
|
|
|
36
36
|
|
|
37
37
|
Changed your mind mid-flight? Click again to cancel. The draft stays exactly as it was — no half-finished text left behind.
|
|
38
38
|
|
|
39
|
+
### For faster results, pick a stable model
|
|
40
|
+
|
|
41
|
+
An enhancement is a real model call, and **how fast the result comes back depends mostly on the model you have selected**.
|
|
42
|
+
|
|
43
|
+
If it feels slow, switch the session to a model that is more stable and responds faster — the same draft can take a few seconds on one model and well over ten on another. The button does not favour any particular model; it uses whichever one you picked.
|
|
44
|
+
|
|
45
|
+
> Want a fixed model just for enhancement, without affecting normal chat? Open `Settings → Prompt rewriter` and turn on **Use a fixed model**.
|
|
46
|
+
|
|
39
47
|
### You can navigate away mid-rewrite
|
|
40
48
|
|
|
41
49
|
Once you've clicked, you don't have to sit and wait. **Switch to another session and the task keeps running.**
|
|
@@ -63,8 +71,10 @@ That wasn't true in 1.0.x: switching sessions aborted the task, and coming back
|
|
|
63
71
|
|
|
64
72
|
The **small dot in the button's lower-right corner** is the whole difference:
|
|
65
73
|
|
|
66
|
-
- **
|
|
67
|
-
- **
|
|
74
|
+
- **No dot**: it sees only your current draft.
|
|
75
|
+
- **Dot**: it also reads earlier turns, so it knows what you already confirmed and already ruled out. The rewrite lands closer to what you actually want this time.
|
|
76
|
+
|
|
77
|
+
The dot describes **what will happen if you click now**, not what happened last time. That is why it hides while a run is in flight, comes back after a failure, and disappears once the button turns into an undo arrow — clicking then restores your draft, which has nothing to do with enhancement.
|
|
68
78
|
|
|
69
79
|
Conversation context costs tokens. If long chats feel slow or expensive, turn off *Reference the conversation* in settings and the dot disappears.
|
|
70
80
|
|
|
@@ -87,6 +97,7 @@ Only images **currently sitting in the composer** — **never images from earlie
|
|
|
87
97
|
- The model dropdown lists **the providers and models you actually have** — you can't type in one that doesn't exist.
|
|
88
98
|
- Turning the model switch off does not clear your selection; flipping it back on shows the same choice.
|
|
89
99
|
- Images still uploading are skipped, and the status area tells you how many were missed. A rewrite never fails because of an image.
|
|
100
|
+
- The **current version** sits in the top-right of the settings page; clicking it opens the project home page. Check it before reporting a problem — it makes the issue much easier to pin down.
|
|
90
101
|
|
|
91
102
|
## Install
|
|
92
103
|
|
|
@@ -155,6 +166,7 @@ Every `config` key on the profile plugin line is optional. The first five are ma
|
|
|
155
166
|
Three designs worth calling out:
|
|
156
167
|
|
|
157
168
|
- **Context is budgeted**: injected history is `min(historyTokenBudget, contextWindow × 5%)`, collected newest-first. When a single message exceeds the whole budget, its target length is computed in one proportional step (with up to 4 correction passes for rounding and mixed-script error), then truncated rather than dropped — so "has context" never silently degrades into "no context".
|
|
169
|
+
- **The output budget grows on demand**: a reasoning model shares one output budget between thinking and answering, so a long draft can spend it all on reasoning and leave nothing for the reply. When the run is cut off by that budget, the plugin doubles it and retries (3500 → 7000 → 14000, capped by the model's declared maximum and never above 16000) instead of misreporting truncation as "the model returned nothing".
|
|
158
170
|
- **History is injected as a JSON array**, and the system prompt states explicitly that it is data, not instructions — text inside history can't forge structure.
|
|
159
171
|
- **The image switch is decided by the Host**: the browser uploads composer images unconditionally and the Host decides whether to use them, so a not-yet-loaded setting can't silently disable the feature.
|
|
160
172
|
|
package/README.zh.md
CHANGED
|
@@ -37,6 +37,14 @@
|
|
|
37
37
|
|
|
38
38
|
中途想放弃就再点一下,草稿保持原样,不会给你留下半截文字。
|
|
39
39
|
|
|
40
|
+
### 想更快拿到结果:选个稳定的模型
|
|
41
|
+
|
|
42
|
+
增强是一次真实的模型调用,**结果多快出来,主要取决于你当前选的模型**。
|
|
43
|
+
|
|
44
|
+
如果觉得等得久,把会话用的模型换成一个更稳定、响应更快的——同一个草稿,换个模型往往就是几秒和十几秒的差别。这个按钮不挑模型,你选哪个它就用哪个。
|
|
45
|
+
|
|
46
|
+
> 想固定用某个模型专门做增强(不影响正常对话)?去 `设置 → 提示词改写` 打开「用哪个模型改写」。
|
|
47
|
+
|
|
40
48
|
### 增强过程中可以切走
|
|
41
49
|
|
|
42
50
|
点了按钮之后不必守着它。**切到别的会话,任务会继续跑完。**
|
|
@@ -64,8 +72,10 @@
|
|
|
64
72
|
|
|
65
73
|
按钮右下角**有没有那个小圆点**,就是全部区别:
|
|
66
74
|
|
|
67
|
-
-
|
|
68
|
-
-
|
|
75
|
+
- **没有圆点**:只看你当前这句草稿。
|
|
76
|
+
- **有圆点**:还会读前面聊过的内容,知道你已经确认过什么、排除过什么,改写更贴你这次真正想要的东西。
|
|
77
|
+
|
|
78
|
+
圆点描述的是**此刻点下去会怎样**,不是上次发生了什么。所以它在运行中隐藏、失败后恢复、按钮变成回退箭头时消失——那时点击是还原草稿,与增强无关。
|
|
69
79
|
|
|
70
80
|
读前文是要花 token 的。长对话里觉得慢或觉得贵,去设置里关掉「参考之前的对话」,按钮上的圆点随之消失。
|
|
71
81
|
|
|
@@ -88,6 +98,7 @@
|
|
|
88
98
|
- 模型下拉列出你**实际拥有**的渠道与模型,填不进不存在的模型。
|
|
89
99
|
- 关掉模型开关不会清空你的选择,重新打开还是同一个。
|
|
90
100
|
- 上传中的图片会被跳过,状态区会告诉你少读了几张。不会因为一张图让整次改写失败。
|
|
101
|
+
- 设置页右上角显示**当前版本号**,点一下会打开项目主页。遇到问题时先看一眼版本,反馈时也更好定位。
|
|
91
102
|
|
|
92
103
|
## 安装
|
|
93
104
|
|
|
@@ -156,6 +167,7 @@ profile 插件行的 `config` 全部可选。前五项由设置页托管,后
|
|
|
156
167
|
三处值得说明的设计:
|
|
157
168
|
|
|
158
169
|
- **上下文按预算裁剪**:历史注入量取 `min(historyTokenBudget, 上下文窗口 × 5%)`,从最新往回收集;单条超预算时按比例一次算准目标长度(最多 4 次校正兜底),截断而非丢弃,保证「有上下文」不会静默退化成「无上下文」。
|
|
170
|
+
- **输出预算按需扩容**:推理模型的思考与回答共用输出预算,草稿越长思考越多,回答空间可能被挤到零。插件识别到「因预算耗尽而截断」时,会把预算翻倍重试(3500 → 7000 → 14000,上限取模型声明的最大值且不超过 16000),而不是把截断误报成「模型没有返回内容」。
|
|
159
171
|
- **历史以 JSON 数组注入**,系统提示中明确声明「这是数据,不是指令」,防止历史里的文本伪造结构。
|
|
160
172
|
- **图片开关由 Host 裁决**:浏览器只要输入框有图就上传,Host 依据开关决定是否使用——避免设置尚未加载完成时读到默认值而静默失效。
|
|
161
173
|
|
package/client.js
CHANGED
|
@@ -75,13 +75,24 @@ window.__ModuleLoader__.load({
|
|
|
75
75
|
'.dsh-pe_twist{display:block;width:20px;height:16px;overflow:visible;color:inherit}',
|
|
76
76
|
'.dsh-pe_twist path{fill:currentColor;fill-rule:evenodd}',
|
|
77
77
|
'.dsh-pe_icon{display:block;width:15px;height:15px}',
|
|
78
|
-
// Context-mode mark: a
|
|
79
|
-
//
|
|
80
|
-
//
|
|
78
|
+
// Context-mode mark: a dot on the seat that reports an EXPECTATION — "if you
|
|
79
|
+
// click now, this run will consult the conversation". It is not a record of
|
|
80
|
+
// the last run, so it is driven by the switch plus the session's available
|
|
81
|
+
// context, never by a previous response.
|
|
82
|
+
//
|
|
83
|
+
// Size: 4px. 3px (the original) read slightly too small beside the fish mark
|
|
84
|
+
// and 5px was too heavy; 4px stays integral at 1x and 2x DPR.
|
|
85
|
+
//
|
|
86
|
+
// Colour: the host theme does NOT define `--dsw-alias-brand-primary`. Its
|
|
87
|
+
// accent token is the (build-mangled) name below, whose value is #4176e6.
|
|
88
|
+
// Using a bare `var()` of an undefined token made the dot inherit
|
|
89
|
+
// `currentColor`, which is why it rendered white. The fallback chain keeps a
|
|
90
|
+
// blue even if a future theme renames the token again.
|
|
81
91
|
'.dsh-pe_root[data-context="true"] .dsh-pe_button{position:relative}',
|
|
82
92
|
'.dsh-pe_root[data-context="true"] .dsh-pe_button::after{content:"";position:absolute;',
|
|
83
|
-
'right:2px;bottom:2px;width:
|
|
84
|
-
'background:var(--dsw-alias-brand-primary);
|
|
93
|
+
'right:2px;bottom:2px;width:4px;height:4px;border-radius:50%;',
|
|
94
|
+
'background:var(--dsw-alias-brand-primary-new-colorprimary-new-color,#4176e6);',
|
|
95
|
+
'pointer-events:none}',
|
|
85
96
|
// Visible failure notice. A red mark alone never told the user WHY the run
|
|
86
97
|
// failed; the message now has a real, on-screen outlet instead of living
|
|
87
98
|
// only in `title` and the visually hidden live region.
|
|
@@ -100,8 +111,21 @@ window.__ModuleLoader__.load({
|
|
|
100
111
|
// ---- Settings page -------------------------------------------------
|
|
101
112
|
// Host theme tokens only, so the page follows light/dark automatically.
|
|
102
113
|
'.dsh-pe-settings{display:flex;flex-direction:column;gap:4px;max-width:640px}',
|
|
103
|
-
|
|
114
|
+
// Header line: intro on the left, version on the right, the way the plugin
|
|
115
|
+
// market pairs a description with its package version.
|
|
116
|
+
'.dsh-pe-settingsHead{display:flex;flex-wrap:wrap;align-items:baseline;',
|
|
117
|
+
'justify-content:space-between;gap:4px 12px;margin:0 0 8px}',
|
|
118
|
+
'.dsh-pe-settingsIntro{margin:0;color:var(--dsw-alias-label-secondary);',
|
|
104
119
|
'font-size:13px;line-height:20px}',
|
|
120
|
+
// Same weight and colour as the host's own package version labels: muted,
|
|
121
|
+
// monospace, and unobtrusive until hovered.
|
|
122
|
+
'.dsh-pe-version{flex:none;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary);',
|
|
123
|
+
'font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);',
|
|
124
|
+
'text-decoration:none;border-radius:var(--dsw-radius-sm)}',
|
|
125
|
+
'a.dsh-pe-version{cursor:pointer}',
|
|
126
|
+
'a.dsh-pe-version:hover{color:var(--dsw-alias-label-secondary);text-decoration:underline}',
|
|
127
|
+
'a.dsh-pe-version:focus-visible{outline:none;color:var(--dsw-alias-label-secondary);',
|
|
128
|
+
'box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}',
|
|
105
129
|
// Each row is a two-column flex that WRAPS. Wrapping is what makes the
|
|
106
130
|
// narrow case work: `@container` cannot be used here because the host
|
|
107
131
|
// settings panel owns the ancestor chain and declares no container-type,
|
|
@@ -379,8 +403,57 @@ window.__ModuleLoader__.load({
|
|
|
379
403
|
const busy = task?.phase === 'running';
|
|
380
404
|
const failure = task?.failure ?? null;
|
|
381
405
|
const announce = task?.announce ?? null;
|
|
406
|
+
// `contextAware` records what the LAST run did. It feeds the accessible name
|
|
407
|
+
// only; the seat's dot must not use it (see `willUseContext` below).
|
|
382
408
|
const contextAware = task?.contextAware === true;
|
|
383
409
|
const restore = task?.restore ?? null;
|
|
410
|
+
|
|
411
|
+
// What the NEXT click will do — the only thing the dot reports.
|
|
412
|
+
//
|
|
413
|
+
// Two facts decide it, both knowable before any run: whether the
|
|
414
|
+
// reference-context switch is on, and whether this session actually has
|
|
415
|
+
// conversation to consult. A previous run's outcome is irrelevant: a failed
|
|
416
|
+
// or never-attempted enhancement does not change what the next one will do.
|
|
417
|
+
const [willUseContext, setWillUseContext] = useState(false);
|
|
418
|
+
// Exposed on the seat as data-ctx-debug so a missing dot can be diagnosed
|
|
419
|
+
// without guessing: it records which of the two facts was false, and whether
|
|
420
|
+
// either request failed. Not shown to users; harmless in the DOM.
|
|
421
|
+
const [ctxProbe, setCtxProbe] = useState('pending');
|
|
422
|
+
useEffect(() => {
|
|
423
|
+
let cancelled = false;
|
|
424
|
+
const read = async () => {
|
|
425
|
+
try {
|
|
426
|
+
const [settingsResponse, statusResponse] = await Promise.all([
|
|
427
|
+
fetch('/api/prompt-enhance/settings', { credentials: 'same-origin' }),
|
|
428
|
+
fetch(`/api/prompt-enhance/context-status?sessionId=${encodeURIComponent(sessionId)}`, { credentials: 'same-origin' }),
|
|
429
|
+
]);
|
|
430
|
+
if (cancelled) return;
|
|
431
|
+
const settings = settingsResponse.ok ? await settingsResponse.json() : null;
|
|
432
|
+
const status = statusResponse.ok ? await statusResponse.json() : null;
|
|
433
|
+
if (cancelled) return;
|
|
434
|
+
const switchOn = settings?.ok === true && settings.values?.referenceContext !== false;
|
|
435
|
+
const hasContext = status?.ok === true && status.hasContext === true;
|
|
436
|
+
setWillUseContext(switchOn && hasContext);
|
|
437
|
+
setCtxProbe(
|
|
438
|
+
`sw=${switchOn ? 1 : 0},hc=${hasContext ? 1 : 0},`
|
|
439
|
+
+ `sHttp=${settingsResponse.status},cHttp=${statusResponse.status}`,
|
|
440
|
+
);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
// An unreachable route means "we cannot promise context"; showing no dot
|
|
443
|
+
// is the honest default rather than promising a context-aware run.
|
|
444
|
+
if (!cancelled) {
|
|
445
|
+
setWillUseContext(false);
|
|
446
|
+
setCtxProbe(`error:${error?.message ?? 'unknown'}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
read();
|
|
451
|
+
return () => {
|
|
452
|
+
cancelled = true;
|
|
453
|
+
};
|
|
454
|
+
// Re-read when the session changes or when a run finishes: the first turn
|
|
455
|
+
// of a conversation creates the context this mark advertises.
|
|
456
|
+
}, [sessionId, task?.phase]);
|
|
384
457
|
const latestRev = useRef(draftRev);
|
|
385
458
|
latestRev.current = draftRev;
|
|
386
459
|
|
|
@@ -633,6 +706,11 @@ window.__ModuleLoader__.load({
|
|
|
633
706
|
if (inputActions === undefined || typeof inputActions.insertText !== 'function') return null;
|
|
634
707
|
|
|
635
708
|
const canUndo = restore !== null && restore.pending !== true && !busy;
|
|
709
|
+
// The seat appears only when there is something to act on. An empty composer
|
|
710
|
+
// with no work in flight has nothing to enhance, so the control stays out of
|
|
711
|
+
// the way entirely — an available context dot is not a reason to render it,
|
|
712
|
+
// because a mark on a button that cannot do anything is just noise in the
|
|
713
|
+
// toolbar for every conversation that has history.
|
|
636
714
|
const visible = busy || canUndo || failure !== null || draft.trim().length > 0;
|
|
637
715
|
if (!visible || phase !== 'plain') return null;
|
|
638
716
|
|
|
@@ -644,19 +722,31 @@ window.__ModuleLoader__.load({
|
|
|
644
722
|
: failure !== null
|
|
645
723
|
? t('state.failedWith', { reason: failure })
|
|
646
724
|
: t('label.idle');
|
|
647
|
-
//
|
|
725
|
+
// The accessible name may still mention what the last run did; that is a
|
|
726
|
+
// statement about the past and belongs in a tooltip, not in the dot.
|
|
648
727
|
const title = contextAware && !busy ? t('label.contextAware', { base: baseTitle }) : baseTitle;
|
|
649
728
|
const onClick = busy ? run : canUndo ? undo : run;
|
|
650
729
|
// A pause leaves `failure` null, so the mark returns to the resting fish and
|
|
651
730
|
// only the live region carries the cancellation text.
|
|
652
731
|
const status = announce !== null ? announce : title;
|
|
732
|
+
// The dot advertises the next click, so it is suppressed while a run is in
|
|
733
|
+
// flight (there is no "next click" to describe) and returns afterwards —
|
|
734
|
+
// including after a failure, because a failed run does not change what the
|
|
735
|
+
// next one would do.
|
|
736
|
+
//
|
|
737
|
+
// It is also suppressed on the UNDO seat: in that state the button restores
|
|
738
|
+
// the previous draft instead of starting an enhancement, so a mark describing
|
|
739
|
+
// "what the next enhancement will do" would describe an action this button no
|
|
740
|
+
// longer performs.
|
|
741
|
+
const showContextMark = willUseContext && !busy && !canUndo;
|
|
653
742
|
|
|
654
743
|
return h(
|
|
655
744
|
'div',
|
|
656
745
|
{
|
|
657
746
|
className: 'dsh-pe_root',
|
|
658
747
|
'data-prompt-enhance': '1',
|
|
659
|
-
'data-context':
|
|
748
|
+
'data-context': showContextMark ? 'true' : 'false',
|
|
749
|
+
'data-ctx-debug': ctxProbe,
|
|
660
750
|
'data-notice': failure !== null ? 'true' : 'false',
|
|
661
751
|
},
|
|
662
752
|
h(
|
|
@@ -712,6 +802,9 @@ window.__ModuleLoader__.load({
|
|
|
712
802
|
*/
|
|
713
803
|
function SettingsSection({ t, getRemote }) {
|
|
714
804
|
const [values, setValues] = useState(null);
|
|
805
|
+
// `{ version, url }` reported by the Host from the package manifest. Never
|
|
806
|
+
// hard-coded here: a release only bumps package.json.
|
|
807
|
+
const [about, setAbout] = useState(null);
|
|
715
808
|
const [catalog, setCatalog] = useState({ status: 'loading', groups: [] });
|
|
716
809
|
const [error, setError] = useState(null);
|
|
717
810
|
const [saving, setSaving] = useState(false);
|
|
@@ -777,6 +870,7 @@ window.__ModuleLoader__.load({
|
|
|
777
870
|
if (!alive.current) return;
|
|
778
871
|
if (body?.ok !== true) throw new Error(body?.message || t('settings.loadFailed', { status: response.status }));
|
|
779
872
|
setValues(body.values);
|
|
873
|
+
if (body.about !== undefined) setAbout(body.about);
|
|
780
874
|
} catch (failure) {
|
|
781
875
|
if (alive.current && !controller.signal.aborted) setError(failure instanceof Error ? failure.message : String(failure));
|
|
782
876
|
}
|
|
@@ -852,7 +946,21 @@ window.__ModuleLoader__.load({
|
|
|
852
946
|
return h(
|
|
853
947
|
'div',
|
|
854
948
|
{ className: 'dsh-pe-settings' },
|
|
855
|
-
|
|
949
|
+
// Intro line with the version at the right, the way the plugin market shows a
|
|
950
|
+
// package version beside its description. The version links to the project
|
|
951
|
+
// page and opens in a new tab, so the current session is left untouched.
|
|
952
|
+
h('div', { className: 'dsh-pe-settingsHead' },
|
|
953
|
+
h('p', { className: 'dsh-pe-settingsIntro' }, t('settings.intro')),
|
|
954
|
+
about?.version !== undefined && (about?.url === undefined
|
|
955
|
+
? h('span', { className: 'dsh-pe-version' }, `v${about.version}`)
|
|
956
|
+
: h('a', {
|
|
957
|
+
className: 'dsh-pe-version',
|
|
958
|
+
href: about.url,
|
|
959
|
+
target: '_blank',
|
|
960
|
+
rel: 'noopener noreferrer',
|
|
961
|
+
title: about.url,
|
|
962
|
+
}, `v${about.version}`)),
|
|
963
|
+
),
|
|
856
964
|
|
|
857
965
|
row(
|
|
858
966
|
'model',
|
|
@@ -910,7 +1018,10 @@ window.__ModuleLoader__.load({
|
|
|
910
1018
|
row(
|
|
911
1019
|
'context',
|
|
912
1020
|
t('settings.context.title'),
|
|
913
|
-
|
|
1021
|
+
// The dot's meaning rides along in the same hint, separated by a space,
|
|
1022
|
+
// so the row stays one comfortable block instead of growing a second
|
|
1023
|
+
// paragraph.
|
|
1024
|
+
`${values.referenceContext ? t('settings.context.on') : t('settings.context.off')} ${t('settings.context.mark')}`,
|
|
914
1025
|
toggle('settings.context', values.referenceContext, (next) => save({ referenceContext: next }, 'settings.context')),
|
|
915
1026
|
),
|
|
916
1027
|
|
|
@@ -1032,8 +1143,11 @@ window.__ModuleLoader__.load({
|
|
|
1032
1143
|
'settings.model.loadError': '读取模型列表失败,请重试。',
|
|
1033
1144
|
'settings.model.placeholder': '还没选过模型',
|
|
1034
1145
|
'settings.context.title': '参考之前的对话',
|
|
1035
|
-
'settings.context.on': '
|
|
1036
|
-
'settings.context.off': '
|
|
1146
|
+
'settings.context.on': '开启:参考前文的目标与已排除方案,更耗 token。',
|
|
1147
|
+
'settings.context.off': '关闭:只看当前输入。',
|
|
1148
|
+
// One short line, merged into the hint above rather than added as its
|
|
1149
|
+
// own row: the dot's meaning is a detail of this switch.
|
|
1150
|
+
'settings.context.mark': '蓝点=本次会参考上下文。',
|
|
1037
1151
|
'settings.images.title': '参考输入框里的图片',
|
|
1038
1152
|
'settings.images.on': '开启:输入框里的图片一并参考(历史图片始终不读)。会多耗 token。',
|
|
1039
1153
|
'settings.images.off': '关闭:只处理文字。',
|
|
@@ -1063,8 +1177,9 @@ window.__ModuleLoader__.load({
|
|
|
1063
1177
|
'settings.model.loadError': 'Could not load the model list. Please retry.',
|
|
1064
1178
|
'settings.model.placeholder': 'No model chosen yet',
|
|
1065
1179
|
'settings.context.title': 'Use earlier messages',
|
|
1066
|
-
'settings.context.on': 'On:
|
|
1067
|
-
'settings.context.off': 'Off:
|
|
1180
|
+
'settings.context.on': 'On: uses earlier goals and ruled-out options; costs more tokens.',
|
|
1181
|
+
'settings.context.off': 'Off: uses only what you typed.',
|
|
1182
|
+
'settings.context.mark': 'Blue dot = this run uses context.',
|
|
1068
1183
|
'settings.images.title': 'Use images in the composer',
|
|
1069
1184
|
'settings.images.on': 'On: images already attached to your draft are included. Earlier images never are. Costs more tokens.',
|
|
1070
1185
|
'settings.images.off': 'Off: text only.',
|
package/index.js
CHANGED
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* @module dsh-prompt-rewriter
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { zstdDecompressSync } from 'node:zlib';
|
|
18
23
|
import z from '@deepseek-ai/schemastery';
|
|
19
24
|
|
|
20
25
|
/** Exact route the browser half posts to. */
|
|
@@ -69,9 +74,59 @@ const DEFAULTS = Object.freeze({
|
|
|
69
74
|
referenceContext: true,
|
|
70
75
|
});
|
|
71
76
|
|
|
77
|
+
/** Hard stop for the adaptive budget, independent of what a model declares. */
|
|
78
|
+
const MAX_OUTPUT_CEILING = 16000;
|
|
79
|
+
|
|
72
80
|
/** Share of a model's context window the history may ever consume. */
|
|
73
81
|
const HISTORY_WINDOW_SHARE = 0.05;
|
|
74
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Extra completion budget granted on top of `maxOutputTokens`.
|
|
85
|
+
*
|
|
86
|
+
* On reasoning-capable models the completion budget covers reasoning AND visible
|
|
87
|
+
* text, and the adapter reports reasoning as `reasoning-delta` — which this plugin
|
|
88
|
+
* deliberately does not treat as output. A budget sized only for the rewritten
|
|
89
|
+
* prompt could therefore be spent entirely on thinking, producing zero text and an
|
|
90
|
+
* "empty response" that looks like a model fault. The headroom keeps the answer
|
|
91
|
+
* reachable without changing what the caller asked for.
|
|
92
|
+
*/
|
|
93
|
+
const REASONING_HEADROOM_TOKENS = 1500;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How many times a truncated run may be retried with a larger budget.
|
|
97
|
+
* Two doublings from the starting budget cover 4x, which is enough for the
|
|
98
|
+
* reasoning overhead of a long draft without risking a runaway cost.
|
|
99
|
+
*/
|
|
100
|
+
const MAX_BUDGET_ATTEMPTS = 3;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the largest completion budget this model will accept.
|
|
104
|
+
*
|
|
105
|
+
* The plugin must not invent a ceiling: an adapter rejects a `maxTokens` above the
|
|
106
|
+
* model's own capacity, so the retry ladder has to stop at what the model declares.
|
|
107
|
+
* When the metadata is unavailable the configured budget stands, which keeps the
|
|
108
|
+
* old single-shot behaviour rather than guessing high and failing.
|
|
109
|
+
*
|
|
110
|
+
* @param scope - plugin context exposing `ctx.llm`.
|
|
111
|
+
* @param route - provider/model selection.
|
|
112
|
+
* @param signal - cancellation.
|
|
113
|
+
* @param config - resolved limits.
|
|
114
|
+
* @returns the ceiling in tokens.
|
|
115
|
+
*/
|
|
116
|
+
async function resolveTokenCeiling(scope, route, signal, config, cache) {
|
|
117
|
+
const requested = config.maxOutputTokens + REASONING_HEADROOM_TOKENS;
|
|
118
|
+
const key = `${route.provider}\u0000${route.model}`;
|
|
119
|
+
// Reuse the context-window lookup: same metadata, same cache entry, one adapter
|
|
120
|
+
// call per route instead of two.
|
|
121
|
+
await resolveContextWindow(scope, route, signal, cache);
|
|
122
|
+
const declared = cache.get(key)?.outputCap;
|
|
123
|
+
if (Number.isInteger(declared) && declared > 0) return Math.max(requested, Math.min(declared, MAX_OUTPUT_CEILING));
|
|
124
|
+
// No declared capacity: allow the full ladder up to the hard ceiling. Returning
|
|
125
|
+
// `requested` (or 2x it) made the first or second truncation hit the ceiling, so
|
|
126
|
+
// the adaptive budget silently degraded back to one retry.
|
|
127
|
+
return MAX_OUTPUT_CEILING;
|
|
128
|
+
}
|
|
129
|
+
|
|
75
130
|
/**
|
|
76
131
|
* CJK code-point ranges, checked numerically instead of by regex.
|
|
77
132
|
* `estimateTokens` runs once per character over the whole candidate history, so
|
|
@@ -109,6 +164,10 @@ const BASE_RULES = [
|
|
|
109
164
|
// Ambiguity must be surfaced, never silently resolved: guessing a specific value
|
|
110
165
|
// invents a requirement, while leaving it blank silently degrades the prompt.
|
|
111
166
|
'- When a detail the task genuinely needs is missing or ambiguous, do NOT invent a value and do NOT drop the requirement. Write an explicit placeholder the user can fill (for example "<target file path>") and say briefly what is expected there. Prefer at most a few such placeholders; if the draft already settles a detail, never reopen it.',
|
|
167
|
+
// A vague draft can tempt the model to template every field, producing a form of
|
|
168
|
+
// placeholders instead of a usable prompt. The cap below forces a priority call:
|
|
169
|
+
// keep the blanks that block execution, summarise the rest in one line.
|
|
170
|
+
'- Use at most 3 placeholders in total. If more details are missing than that, keep only the ones that block execution, and fold the remainder into a single closing line such as "其余细节请补充" rather than inventing a placeholder for each. A prompt the user can run after filling two blanks is better than a complete form they must fill entirely.',
|
|
112
171
|
// The instruction an executing model follows most reliably is a direct one.
|
|
113
172
|
'- Address the executing assistant directly and in the imperative. State the task as an instruction to be carried out, not as a description of a task that exists.',
|
|
114
173
|
'- Keep it tight. No filler, no praise, no meta commentary. Do not add markdown headings unless the draft itself is a document request, and do not use a heading for every field — prefer short labelled lines or a compact list when structure genuinely helps.',
|
|
@@ -424,6 +483,153 @@ function trimNewestFirst(derived, config, budget) {
|
|
|
424
483
|
return { turns: picked, truncated, tokens: used };
|
|
425
484
|
}
|
|
426
485
|
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// Persisted-log fallback
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// The Host keeps sessions on disk as `session.vN.jsonl[.zstd]` under
|
|
490
|
+
// `~/.dsh/sessions/<workspace>/<session-id>/`. After a restart a session that has
|
|
491
|
+
// not been reopened has no in-memory log, so anything that asks "does this
|
|
492
|
+
// conversation have history?" must be able to look at the artifact itself.
|
|
493
|
+
|
|
494
|
+
/** zstd frame magic; the log is written as one independent frame per row. */
|
|
495
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Cache of sessionId → { mtimeMs, hasUser }.
|
|
499
|
+
*
|
|
500
|
+
* The probe is cheap but not free (directory walk + a few frame decodes), and the
|
|
501
|
+
* composer asks on every mount. Keying on the file's mtime keeps the cache honest:
|
|
502
|
+
* a conversation that gains its first turn changes the file, so the next probe
|
|
503
|
+
* re-reads instead of serving a stale "no".
|
|
504
|
+
*/
|
|
505
|
+
const persistedProbeCache = new Map();
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Workspace directories under the session store, cached for a short while.
|
|
509
|
+
*
|
|
510
|
+
* `findSessionLog` has to look for the session's directory inside each workspace.
|
|
511
|
+
* Re-reading the workspace list on every probe is wasted work: there are hundreds
|
|
512
|
+
* of session directories and the workspace set changes only when the user opens a
|
|
513
|
+
* new project. A short TTL keeps a newly created workspace discoverable without
|
|
514
|
+
* paying a directory read per request.
|
|
515
|
+
*/
|
|
516
|
+
let workspaceCache = { at: 0, dirs: [] };
|
|
517
|
+
const WORKSPACE_CACHE_MS = 30_000;
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The session store root.
|
|
521
|
+
*
|
|
522
|
+
* Overridable through `DSH_SESSIONS_DIR` so the probe can be exercised without
|
|
523
|
+
* touching the user's real store, and so a deployment with a non-default DSH home
|
|
524
|
+
* still works.
|
|
525
|
+
* @returns the absolute path of the session store.
|
|
526
|
+
*/
|
|
527
|
+
function sessionStoreRoot() {
|
|
528
|
+
const override = process.env.DSH_SESSIONS_DIR;
|
|
529
|
+
if (typeof override === 'string' && override.length > 0) return override;
|
|
530
|
+
return path.join(os.homedir(), '.dsh', 'sessions');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** @returns the workspace directories under the session store. */
|
|
534
|
+
function sessionWorkspaces() {
|
|
535
|
+
const now = Date.now();
|
|
536
|
+
if (now - workspaceCache.at < WORKSPACE_CACHE_MS && workspaceCache.dirs.length > 0) return workspaceCache.dirs;
|
|
537
|
+
try {
|
|
538
|
+
const root = sessionStoreRoot();
|
|
539
|
+
const dirs = fs.readdirSync(root).filter((name) => !name.startsWith('.'));
|
|
540
|
+
workspaceCache = { at: now, dirs };
|
|
541
|
+
return dirs;
|
|
542
|
+
} catch {
|
|
543
|
+
return [];
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Locate one session's log file, or undefined. */
|
|
548
|
+
function findSessionLog(sessionId) {
|
|
549
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined;
|
|
550
|
+
try {
|
|
551
|
+
const root = sessionStoreRoot();
|
|
552
|
+
for (const workspace of sessionWorkspaces()) {
|
|
553
|
+
const dir = path.join(root, workspace, sessionId);
|
|
554
|
+
let entries;
|
|
555
|
+
try {
|
|
556
|
+
entries = fs.readdirSync(dir);
|
|
557
|
+
} catch {
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const log = entries.find((name) => /^session\.v\d+\.jsonl(\.zstd)?$/u.test(name));
|
|
561
|
+
if (log !== undefined) return path.join(dir, log);
|
|
562
|
+
}
|
|
563
|
+
} catch {
|
|
564
|
+
// A missing or unreadable store simply means "no persisted history".
|
|
565
|
+
}
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Whether a persisted log already contains a user turn.
|
|
571
|
+
*
|
|
572
|
+
* Decodes frame by frame and stops at the first row that proves a user message, so
|
|
573
|
+
* the common case reads a handful of frames instead of the whole file (measured:
|
|
574
|
+
* 4 of 413 frames, ~0.2ms versus ~18ms for a full decode).
|
|
575
|
+
*
|
|
576
|
+
* @param sessionId - the Session id.
|
|
577
|
+
* @returns true when the log holds at least one user message.
|
|
578
|
+
*/
|
|
579
|
+
function persistedLogHasUser(sessionId) {
|
|
580
|
+
const file = findSessionLog(sessionId);
|
|
581
|
+
if (file === undefined) return false;
|
|
582
|
+
let stat;
|
|
583
|
+
try {
|
|
584
|
+
stat = fs.statSync(file);
|
|
585
|
+
} catch {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
const cached = persistedProbeCache.get(sessionId);
|
|
589
|
+
if (cached !== undefined && cached.mtimeMs === stat.mtimeMs) return cached.hasUser;
|
|
590
|
+
|
|
591
|
+
let hasUser = false;
|
|
592
|
+
try {
|
|
593
|
+
const raw = fs.readFileSync(file);
|
|
594
|
+
if (file.endsWith('.zstd')) {
|
|
595
|
+
const starts = [];
|
|
596
|
+
let at = 0;
|
|
597
|
+
while ((at = raw.indexOf(ZSTD_MAGIC, at)) >= 0) {
|
|
598
|
+
starts.push(at);
|
|
599
|
+
at += ZSTD_MAGIC.length;
|
|
600
|
+
}
|
|
601
|
+
let pending = '';
|
|
602
|
+
for (let i = 0; i < starts.length && !hasUser; i += 1) {
|
|
603
|
+
const from = starts[i];
|
|
604
|
+
const to = i + 1 < starts.length ? starts[i + 1] : raw.length;
|
|
605
|
+
let chunk;
|
|
606
|
+
try {
|
|
607
|
+
chunk = zstdDecompressSync(raw.subarray(from, to)).toString('utf8');
|
|
608
|
+
} catch {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
pending += chunk;
|
|
612
|
+
const cut = pending.lastIndexOf('\n');
|
|
613
|
+
if (cut < 0) continue;
|
|
614
|
+
hasUser = logTextHasUser(pending.slice(0, cut));
|
|
615
|
+
pending = pending.slice(cut + 1);
|
|
616
|
+
}
|
|
617
|
+
} else {
|
|
618
|
+
hasUser = logTextHasUser(raw.toString('utf8'));
|
|
619
|
+
}
|
|
620
|
+
} catch {
|
|
621
|
+
hasUser = false;
|
|
622
|
+
}
|
|
623
|
+
persistedProbeCache.set(sessionId, { mtimeMs: stat.mtimeMs, hasUser });
|
|
624
|
+
return hasUser;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Cheap textual test for a user-authored row in JSONL session text. */
|
|
628
|
+
function logTextHasUser(text) {
|
|
629
|
+
// Session rows carry the author as `"role":"user"` inside a message payload.
|
|
630
|
+
return /"role"\s*:\s*"user"/u.test(text);
|
|
631
|
+
}
|
|
632
|
+
|
|
427
633
|
/**
|
|
428
634
|
* Read the derived message log for one session.
|
|
429
635
|
* @param scope - plugin context with optional Session services.
|
|
@@ -444,6 +650,37 @@ function readDerivedMessages(scope, sessionId) {
|
|
|
444
650
|
}
|
|
445
651
|
}
|
|
446
652
|
|
|
653
|
+
/**
|
|
654
|
+
* Whether this session already has conversation the rewrite could consult.
|
|
655
|
+
*
|
|
656
|
+
* Two sources, cheapest first:
|
|
657
|
+
*
|
|
658
|
+
* 1. The live Session, via `deriveMessages()`. Authoritative and free when the
|
|
659
|
+
* conversation is loaded.
|
|
660
|
+
* 2. The persisted log on disk. After a restart the Host keeps sessions on disk and
|
|
661
|
+
* only materialises a log once something touches it, so `deriveMessages()` is
|
|
662
|
+
* empty for a conversation the user has not reopened — yet the conversation is
|
|
663
|
+
* still there, and the next enhancement WILL read it (see `readDerivedMessages`,
|
|
664
|
+
* which uses the same fallback). Without this second source the mark vanished
|
|
665
|
+
* after every restart until the user sent a message.
|
|
666
|
+
*
|
|
667
|
+
* The disk probe stops at the first frame that proves a user message exists, so the
|
|
668
|
+
* usual case costs a few frames rather than the whole file.
|
|
669
|
+
*
|
|
670
|
+
* @param scope - plugin context with optional Session services.
|
|
671
|
+
* @param sessionId - the browser-reported Session id.
|
|
672
|
+
* @returns true when at least one user turn exists.
|
|
673
|
+
*/
|
|
674
|
+
function sessionHasContext(scope, sessionId) {
|
|
675
|
+
const derived = readDerivedMessages(scope, sessionId);
|
|
676
|
+
for (const message of derived) {
|
|
677
|
+
if (message?.role === 'user' && messageText(message).length > 0) return true;
|
|
678
|
+
}
|
|
679
|
+
if (derived.length > 0) return false;
|
|
680
|
+
// Nothing in memory: fall back to the persisted log.
|
|
681
|
+
return persistedLogHasUser(sessionId);
|
|
682
|
+
}
|
|
683
|
+
|
|
447
684
|
/**
|
|
448
685
|
* Collect the conversation that precedes the draft.
|
|
449
686
|
* @param derived - messages from {@link readDerivedMessages}.
|
|
@@ -479,19 +716,25 @@ function collectHistory(derived, config, contextWindow) {
|
|
|
479
716
|
async function resolveContextWindow(scope, route, signal, cache) {
|
|
480
717
|
const key = `${route.provider}\u0000${route.model}`;
|
|
481
718
|
const hit = cache.get(key);
|
|
482
|
-
if (hit !== undefined) return hit;
|
|
719
|
+
if (hit !== undefined) return hit.window;
|
|
483
720
|
try {
|
|
484
721
|
const info = await scope.llm.resolveModelInfo(route.provider, route.model, signal);
|
|
485
722
|
const window = info?.context?.contextWindow;
|
|
486
723
|
const resolved = Number.isInteger(window) && window > 0 ? window : undefined;
|
|
724
|
+
// The declared output capacity rides along in the same entry: it comes from the
|
|
725
|
+
// same metadata call, and querying the adapter twice per request would defeat
|
|
726
|
+
// the point of this cache (each call is an adapter round trip).
|
|
727
|
+
const declaredOutput = info?.limits?.maxOutputTokens ?? info?.maxOutputTokens ?? info?.context?.maxOutputTokens;
|
|
728
|
+
const outputCap = Number.isInteger(declaredOutput) && declaredOutput > 0 ? declaredOutput : undefined;
|
|
487
729
|
// Cache both a real window and a definitive "no window" so a provider that
|
|
488
730
|
// omits the field is not re-queried on every request.
|
|
489
|
-
cache.set(key, resolved);
|
|
731
|
+
cache.set(key, { window: resolved, outputCap });
|
|
490
732
|
return resolved;
|
|
491
733
|
} catch (error) {
|
|
492
734
|
// A cancelled lookup says nothing about the model; caching it would poison
|
|
493
|
-
// every later request
|
|
494
|
-
|
|
735
|
+
// every later request. The same holds for any transient metadata failure:
|
|
736
|
+
// only a definitive ANSWER is memoized, so a failure leaves the cache
|
|
737
|
+
// untouched and the next request retries.
|
|
495
738
|
return undefined;
|
|
496
739
|
}
|
|
497
740
|
}
|
|
@@ -579,6 +822,18 @@ async function admitComposerImage(scope, image, signal) {
|
|
|
579
822
|
|
|
580
823
|
/**
|
|
581
824
|
* Stream one enhancement request and return the plain rewritten prompt.
|
|
825
|
+
*
|
|
826
|
+
* The completion budget is ADAPTIVE. A reasoning model spends `maxTokens` on
|
|
827
|
+
* thinking before it writes anything, so the budget a plain answer needs is not
|
|
828
|
+
* the budget a reasoning model needs — and the extra amount depends on the draft,
|
|
829
|
+
* which the plugin cannot know in advance. Guessing one number is what made long
|
|
830
|
+
* drafts, drafts containing a URL, or drafts with attachments fail with an empty
|
|
831
|
+
* reply: the guess was simply too small.
|
|
832
|
+
*
|
|
833
|
+
* So the budget grows on demand. If the model stops with `max-tokens`, the run is
|
|
834
|
+
* retried with a larger budget, up to a ceiling derived from the model's own
|
|
835
|
+
* capacity. The user sees one slightly slower request, never a spurious failure.
|
|
836
|
+
*
|
|
582
837
|
* @param scope - plugin context exposing `ctx.llm`.
|
|
583
838
|
* @param config - resolved limits.
|
|
584
839
|
* @param route - provider/model/reasoning selection.
|
|
@@ -588,55 +843,98 @@ async function admitComposerImage(scope, image, signal) {
|
|
|
588
843
|
* @param signal - combined cancellation for this request.
|
|
589
844
|
* @returns the rewritten prompt text.
|
|
590
845
|
*/
|
|
591
|
-
async function generate(scope, config, route, draft, history, images, signal) {
|
|
846
|
+
async function generate(scope, config, route, draft, history, images, signal, windowCache) {
|
|
592
847
|
const llm = scope.llm;
|
|
593
848
|
if (llm === undefined) throw new EnhanceFailure('UNAVAILABLE', '当前运行的 Harness 未提供模型服务,无法增强提示词。');
|
|
594
849
|
const providers = llm.listProviders();
|
|
595
850
|
if (!providers.some((entry) => entry.id === route.provider)) {
|
|
596
851
|
throw new EnhanceFailure('NO_ADAPTER', `模型提供商「${route.provider}」未启用,请重新选择模型。`);
|
|
597
852
|
}
|
|
598
|
-
|
|
599
|
-
try {
|
|
600
|
-
call = await llm.prepareCall(
|
|
601
|
-
{
|
|
602
|
-
provider: route.provider,
|
|
603
|
-
model: route.model,
|
|
604
|
-
maxTokens: config.maxOutputTokens,
|
|
605
|
-
...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
|
|
606
|
-
},
|
|
607
|
-
signal,
|
|
608
|
-
);
|
|
609
|
-
} catch (error) {
|
|
610
|
-
if (signal.aborted) throw error;
|
|
611
|
-
const code = typeof error?.code === 'string' ? error.code : 'UNKNOWN';
|
|
612
|
-
throw new EnhanceFailure(code, describeFailure(code, `模型准备失败:${error?.message ?? '未知错误'}`));
|
|
613
|
-
}
|
|
853
|
+
|
|
614
854
|
const system = history.hasContext ? SYSTEM_PROMPT_CONTEXT : SYSTEM_PROMPT_PLAIN;
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
855
|
+
const messages = [{ role: 'user', content: buildUserContent(draft, history, images) }];
|
|
856
|
+
|
|
857
|
+
// Start from the configured answer budget plus reasoning headroom, then grow.
|
|
858
|
+
let budget = config.maxOutputTokens + REASONING_HEADROOM_TOKENS;
|
|
859
|
+
const ceiling = await resolveTokenCeiling(scope, route, signal, config, windowCache);
|
|
860
|
+
let lastTruncation = null;
|
|
861
|
+
|
|
862
|
+
for (let attempt = 0; attempt < MAX_BUDGET_ATTEMPTS; attempt += 1) {
|
|
863
|
+
let call;
|
|
864
|
+
try {
|
|
865
|
+
call = await llm.prepareCall(
|
|
866
|
+
{
|
|
867
|
+
provider: route.provider,
|
|
868
|
+
model: route.model,
|
|
869
|
+
maxTokens: Math.min(budget, ceiling),
|
|
870
|
+
...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
|
|
871
|
+
},
|
|
872
|
+
signal,
|
|
873
|
+
);
|
|
874
|
+
} catch (error) {
|
|
875
|
+
if (signal.aborted) throw error;
|
|
876
|
+
const code = typeof error?.code === 'string' ? error.code : 'UNKNOWN';
|
|
877
|
+
throw new EnhanceFailure(code, describeFailure(code, `模型准备失败:${error?.message ?? '未知错误'}`));
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
let text = '';
|
|
881
|
+
let finish;
|
|
882
|
+
// Reasoning is counted for diagnosis only; it is never part of the prompt.
|
|
883
|
+
let reasoningChars = 0;
|
|
884
|
+
for await (const chunk of call.stream({ ...call.config, system, messages, signal })) {
|
|
885
|
+
signal.throwIfAborted();
|
|
886
|
+
if (chunk.type === 'text-delta') {
|
|
887
|
+
text += chunk.text;
|
|
888
|
+
if (text.length > config.maxOutputChars * 4) throw new EnhanceFailure('TOO_LARGE', '模型返回内容过长,已停止,草稿保持不变。');
|
|
889
|
+
} else if (chunk.type === 'reasoning-delta') {
|
|
890
|
+
reasoningChars += typeof chunk.text === 'string' ? chunk.text.length : 0;
|
|
891
|
+
} else if (chunk.type === 'finish') {
|
|
892
|
+
finish = chunk.reason;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
623
895
|
signal.throwIfAborted();
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
896
|
+
|
|
897
|
+
if (finish !== undefined && (finish.kind === 'error' || finish.kind === 'aborted')) {
|
|
898
|
+
const failure = finish.failure;
|
|
899
|
+
const code = typeof failure?.code === 'string' ? failure.code : 'UNKNOWN';
|
|
900
|
+
throw new EnhanceFailure(code, describeFailure(code, `模型请求失败${failure?.message === undefined ? '' : `:${failure.message}`}`));
|
|
629
901
|
}
|
|
902
|
+
|
|
903
|
+
// The budget ran out. `max-tokens` is the adapter's explicit signal, and it is
|
|
904
|
+
// not an error: retry larger rather than blaming the model.
|
|
905
|
+
if (finish !== undefined && finish.kind === 'max-tokens') {
|
|
906
|
+
lastTruncation = { reasoningChars };
|
|
907
|
+
if (budget >= ceiling) break;
|
|
908
|
+
budget = Math.min(budget * 2, ceiling);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const normalized = normalizeOutput(text, config.maxOutputChars);
|
|
913
|
+
if (normalized.length === 0) {
|
|
914
|
+
// Nothing usable. Reasoning with no text means the budget was spent thinking,
|
|
915
|
+
// which a larger retry can still rescue.
|
|
916
|
+
lastTruncation = { reasoningChars };
|
|
917
|
+
if (reasoningChars > 0 && budget < ceiling) {
|
|
918
|
+
budget = Math.min(budget * 2, ceiling);
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
if (reasoningChars > 0) {
|
|
922
|
+
throw new EnhanceFailure('EMPTY_REASONING', '模型把输出预算用在了思考上,没有产出提示词。请重试,或在「设置 → 提示词增强」换一个模型。');
|
|
923
|
+
}
|
|
924
|
+
throw new EnhanceFailure('EMPTY', '模型没有返回可用的提示词,草稿保持不变。');
|
|
925
|
+
}
|
|
926
|
+
return normalized;
|
|
630
927
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
928
|
+
|
|
929
|
+
// Every attempt ran out of budget. Say so precisely: the cause is the length
|
|
930
|
+
// limit, and the fix is a shorter draft or a larger ceiling — not a retry.
|
|
931
|
+
const reasoning = lastTruncation?.reasoningChars ?? 0;
|
|
932
|
+
throw new EnhanceFailure(
|
|
933
|
+
'OUTPUT_TRUNCATED',
|
|
934
|
+
reasoning > 0
|
|
935
|
+
? `模型在 ${ceiling} token 的输出上限内仍在思考,未能产出提示词。请缩短草稿后重试。`
|
|
936
|
+
: `模型输出达到 ${ceiling} token 上限仍未完成。请缩短草稿后重试。`,
|
|
937
|
+
);
|
|
640
938
|
}
|
|
641
939
|
|
|
642
940
|
/**
|
|
@@ -738,7 +1036,7 @@ async function handle(scope, config, request, lifetime, windowCache) {
|
|
|
738
1036
|
}
|
|
739
1037
|
}
|
|
740
1038
|
|
|
741
|
-
const text = await generate(scope, config, route, draft, history, images, signal);
|
|
1039
|
+
const text = await generate(scope, config, route, draft, history, images, signal, windowCache);
|
|
742
1040
|
return Response.json({
|
|
743
1041
|
ok: true,
|
|
744
1042
|
text,
|
|
@@ -764,6 +1062,16 @@ export const inject = ['connection'];
|
|
|
764
1062
|
/** Exact route the browser half uses to read and write the user's preferences. */
|
|
765
1063
|
const SETTINGS_PATH = '/api/prompt-enhance/settings';
|
|
766
1064
|
|
|
1065
|
+
/**
|
|
1066
|
+
* Read-only route that answers "would an enhancement started right now consult
|
|
1067
|
+
* this session's conversation?".
|
|
1068
|
+
*
|
|
1069
|
+
* The composer needs this BEFORE any run so its mark can describe the next click
|
|
1070
|
+
* rather than the previous result. It is deliberately cheap: no model call, no
|
|
1071
|
+
* token accounting, just a scan for a prior user turn.
|
|
1072
|
+
*/
|
|
1073
|
+
const CONTEXT_STATUS_PATH = '/api/prompt-enhance/context-status';
|
|
1074
|
+
|
|
767
1075
|
/** The row config keys the settings page may write. */
|
|
768
1076
|
const SETTING_KEYS = Object.freeze(['enhanceProvider', 'enhanceModel', 'useCustomModel', 'referenceImages', 'referenceContext']);
|
|
769
1077
|
|
|
@@ -795,6 +1103,58 @@ function settingsNamespace(ctx) {
|
|
|
795
1103
|
return undefined;
|
|
796
1104
|
}
|
|
797
1105
|
|
|
1106
|
+
/**
|
|
1107
|
+
* Package facts the settings page shows: version and project URL.
|
|
1108
|
+
*
|
|
1109
|
+
* Read from this package's own `package.json` at runtime rather than hard-coded,
|
|
1110
|
+
* so bumping the manifest is the only step a release needs. Cached after the first
|
|
1111
|
+
* read: the file cannot change while the plugin is loaded.
|
|
1112
|
+
*
|
|
1113
|
+
* @returns `{ version, url }`; fields are undefined when the manifest is absent.
|
|
1114
|
+
*/
|
|
1115
|
+
function readAbout() {
|
|
1116
|
+
if (aboutCache !== undefined) return aboutCache;
|
|
1117
|
+
let version;
|
|
1118
|
+
let url;
|
|
1119
|
+
try {
|
|
1120
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot(), 'package.json'), 'utf8'));
|
|
1121
|
+
version = typeof manifest.version === 'string' ? manifest.version : undefined;
|
|
1122
|
+
// `repository` may be a string or `{ url }`; strip the git+ prefix and .git
|
|
1123
|
+
// suffix so it is directly openable in a browser.
|
|
1124
|
+
const raw = typeof manifest.repository === 'string' ? manifest.repository : manifest.repository?.url;
|
|
1125
|
+
if (typeof raw === 'string') {
|
|
1126
|
+
url = raw
|
|
1127
|
+
.replace(/^git\+/u, '')
|
|
1128
|
+
.replace(/\.git$/u, '')
|
|
1129
|
+
.replace(/^git:\/\//u, 'https://');
|
|
1130
|
+
}
|
|
1131
|
+
} catch {
|
|
1132
|
+
// A missing manifest simply hides the version line; it must not break the page.
|
|
1133
|
+
}
|
|
1134
|
+
aboutCache = { version, url };
|
|
1135
|
+
return aboutCache;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Directory holding this package's manifest.
|
|
1140
|
+
*
|
|
1141
|
+
* `fileURLToPath` is required rather than reading `url.pathname`: a pathname keeps
|
|
1142
|
+
* percent-encoding, so an install directory containing a space ("DSH project")
|
|
1143
|
+
* decoded to a literal `%20` and the manifest read failed silently.
|
|
1144
|
+
*
|
|
1145
|
+
* @returns the package root path.
|
|
1146
|
+
*/
|
|
1147
|
+
function packageRoot() {
|
|
1148
|
+
try {
|
|
1149
|
+
return path.dirname(fileURLToPath(import.meta.url));
|
|
1150
|
+
} catch {
|
|
1151
|
+
return process.cwd();
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** Memoized result of {@link readAbout}. */
|
|
1156
|
+
let aboutCache;
|
|
1157
|
+
|
|
798
1158
|
/**
|
|
799
1159
|
* Read the live preference values.
|
|
800
1160
|
*
|
|
@@ -840,7 +1200,7 @@ async function handleSettings(ctx, config, request) {
|
|
|
840
1200
|
if (ns === undefined) return fail('SETTINGS_UNAVAILABLE', '无法定位插件配置项,无法保存选项。');
|
|
841
1201
|
|
|
842
1202
|
if (request.method === 'GET') {
|
|
843
|
-
return Response.json({ ok: true, values: readPreferences(ctx, config) });
|
|
1203
|
+
return Response.json({ ok: true, values: readPreferences(ctx, config), about: readAbout() });
|
|
844
1204
|
}
|
|
845
1205
|
|
|
846
1206
|
let payload;
|
|
@@ -925,6 +1285,19 @@ export function apply(ctx, rawConfig) {
|
|
|
925
1285
|
}),
|
|
926
1286
|
'dsh-prompt-rewriter: settings route',
|
|
927
1287
|
);
|
|
1288
|
+
ctx.effect(
|
|
1289
|
+
() =>
|
|
1290
|
+
ctx.connection.fetch.register({
|
|
1291
|
+
path: CONTEXT_STATUS_PATH,
|
|
1292
|
+
methods: ['GET'],
|
|
1293
|
+
requestBody: 'buffered',
|
|
1294
|
+
fetch: (request) => {
|
|
1295
|
+
const sessionId = new URL(request.url).searchParams.get('sessionId') ?? undefined;
|
|
1296
|
+
return Response.json({ ok: true, hasContext: sessionHasContext(ctx, sessionId) });
|
|
1297
|
+
},
|
|
1298
|
+
}),
|
|
1299
|
+
'dsh-prompt-rewriter: context-status route',
|
|
1300
|
+
);
|
|
928
1301
|
ctx.inject(['llm'], (scope) => {
|
|
929
1302
|
scope.effect(
|
|
930
1303
|
() =>
|
package/package.json
CHANGED