dsh-prompt-shelf 0.1.0 → 0.1.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.en.md +5 -1
- package/README.md +4 -2
- package/lib/client.js +179 -80
- package/package.json +2 -2
- package/src/entry.jsx +225 -96
package/README.en.md
CHANGED
|
@@ -27,6 +27,7 @@ New-session blank pages show it too.
|
|
|
27
27
|
|
|
28
28
|
Write `{{name}}` or `{{name=default}}` in the body and that spot becomes an input field on the card:
|
|
29
29
|
inserting substitutes the filled value, falling back to the default, and same-named variables share one value.
|
|
30
|
+
A variable box also accepts `@` to reference workspace files (see below).
|
|
30
31
|
|
|
31
32
|
Editing a card shows the raw `{{…}}` template, which is where you change it. While creating or editing,
|
|
32
33
|
"+ Variable" below the body box inserts `{{variable}}` at the cursor and selects the word, so typing renames it.
|
|
@@ -38,6 +39,9 @@ In that body box, **Alt+V** (or Ctrl+Alt+V) does the same; it only works there,
|
|
|
38
39
|
- In the card editor's inputs, typing `@` lists workspace files and folders (arrow keys to move, Enter or Tab
|
|
39
40
|
to pick, Esc to close); picking inserts `@path` (folders end with a slash, and a path containing spaces
|
|
40
41
|
is written as `@"path"`).
|
|
42
|
+
- In a card's **variable box**, typing `@` lists the same files, sessions and plugins (the list floats below
|
|
43
|
+
the input and flips above when there is no room); picking fills `@path` into that variable's value, so after
|
|
44
|
+
inserting or replacing into the composer the model reads the file as an @ reference.
|
|
41
45
|
|
|
42
46
|
## Where the data lives
|
|
43
47
|
|
|
@@ -62,7 +66,7 @@ dsh plugin --profile desktop add dsh-prompt-shelf
|
|
|
62
66
|
```
|
|
63
67
|
|
|
64
68
|
Restart DSH afterwards. Replace `desktop` with your own profile name if you use another one.
|
|
65
|
-
Requirements:
|
|
69
|
+
Requirements: DSH 0.2.1 or later, desktop app or `dsh web` (verified on 0.2.1-alpha.3 and 0.2.2-rc.1). Requires DSH >= 0.2.1-alpha.3; verified on 0.2.1-alpha.3 and 0.2.2-rc.1, later versions untested.
|
|
66
70
|
|
|
67
71
|
## How it works
|
|
68
72
|
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
## 变量
|
|
25
25
|
|
|
26
26
|
正文里写 `{{名称}}` 或 `{{名称=默认值}}`,这张卡片的正文里那个位置就变成一个输入框:
|
|
27
|
-
|
|
27
|
+
插入时用填的值替换,不填就用默认值,同名变量共用一份值。变量框里打 `@` 可以引用工作区文件(见下)。
|
|
28
28
|
|
|
29
29
|
编辑卡片时看到的是带 `{{…}}` 的原文——改模板在那里改。新建或编辑时正文框下方有「+ 变量」,
|
|
30
30
|
把光标放到要放变量的位置点它就在光标处插入 `{{变量}}` 并选中「变量」两字,直接打字就是改名。
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
- **任务输入框**里打 `@`:菜单里多出「常用提示词」一组,选中一条把正文落到光标处。
|
|
36
36
|
- **新建 / 编辑卡片的输入框**里打 `@`:列出工作区文件与文件夹(上下键选、回车或 Tab 选中、Esc 关),
|
|
37
37
|
选中插入 `@路径`(目录带尾斜杠,路径含空格时用 `@"路径"`)。
|
|
38
|
+
- **卡片上的变量框**里打 `@`:同样列出文件、对话、插件(浮层出现在输入框下方,放不下时翻到上方),
|
|
39
|
+
选中把 `@路径` 填进这个变量的值;插入或替换进任务输入框后,模型按 @ 引用去读这个文件。
|
|
38
40
|
|
|
39
41
|
## 数据存哪
|
|
40
42
|
|
|
@@ -57,7 +59,7 @@ dsh plugin --profile desktop add dsh-prompt-shelf
|
|
|
57
59
|
```
|
|
58
60
|
|
|
59
61
|
装完重启 DSH。用别的 profile 就把 `desktop` 换成你的 profile 名。
|
|
60
|
-
要求:DSH 0.2.1
|
|
62
|
+
要求:DSH 0.2.1 及以上的桌面端或 `dsh web`(0.2.1-alpha.3、0.2.2-rc.1 验证通过)。要求 DSH >= 0.2.1-alpha.3;实测 0.2.1-alpha.3、0.2.2-rc.1,更高版本未验证。
|
|
61
63
|
|
|
62
64
|
## 机制
|
|
63
65
|
|
package/lib/client.js
CHANGED
|
@@ -39,7 +39,7 @@ var API_URL = "/dsh-prompt-shelf/api/state";
|
|
|
39
39
|
var FILES_API = "/dsh-prompt-shelf/api/files";
|
|
40
40
|
var PLUGINS_API = "/dsh-prompt-shelf/api/plugins";
|
|
41
41
|
var TITLE_MAX = 28;
|
|
42
|
-
var TEMPLATE_HINT = "\u6B63\u6587\u91CC\u5199 {{\u53D8\u91CF\u540D}}\uFF0C\u6216 {{\u53D8\u91CF\u540D=\u9ED8\u8BA4\u503C}}\uFF1B\u63D2\u5165\u65F6\u7528\u5361\u7247\u91CC\u586B\u7684\u503C\u66FF\u6362\uFF08\u4E0D\u586B\u5C31\u7528\u9ED8\u8BA4\u503C\uFF09\u3002\u6B63\u6587\u6846\u91CC\u6309 Alt+V\uFF08\u6216 Ctrl+Alt+V\uFF09\u4E5F\u80FD\u63D2\u5165\u53D8\u91CF\u3002";
|
|
42
|
+
var TEMPLATE_HINT = "\u6B63\u6587\u91CC\u5199 {{\u53D8\u91CF\u540D}}\uFF0C\u6216 {{\u53D8\u91CF\u540D=\u9ED8\u8BA4\u503C}}\uFF1B\u63D2\u5165\u65F6\u7528\u5361\u7247\u91CC\u586B\u7684\u503C\u66FF\u6362\uFF08\u4E0D\u586B\u5C31\u7528\u9ED8\u8BA4\u503C\uFF09\u3002\u6B63\u6587\u6846\u91CC\u6309 Alt+V\uFF08\u6216 Ctrl+Alt+V\uFF09\u4E5F\u80FD\u63D2\u5165\u53D8\u91CF\uFF1B\u5361\u7247\u4E0A\u7684\u53D8\u91CF\u6846\u91CC\u6253 @ \u53EF\u5F15\u7528\u5DE5\u4F5C\u533A\u6587\u4EF6\u3002";
|
|
43
43
|
var EMPTY_INPUT = { draft: "" };
|
|
44
44
|
var CSS = `
|
|
45
45
|
.zps-root {
|
|
@@ -213,13 +213,13 @@ var CSS = `
|
|
|
213
213
|
}
|
|
214
214
|
/* \u6709\u53D8\u91CF\u7684\u5361\u7247\uFF1A\u6B63\u6587\u6309\u6BB5\u6E32\u67D3\uFF0C\u53D8\u91CF\u4F4D\u7F6E\u662F\u5C0F\u8F93\u5165\u6846\uFF0C\u76F4\u63A5\u539F\u4F4D\u6539\u3002 */
|
|
215
215
|
.zps-cardtext-vars { display: block; cursor: default; }
|
|
216
|
+
.zps-varwrap { display: inline-block; margin: 0 2px; vertical-align: baseline; }
|
|
216
217
|
.zps-var {
|
|
217
218
|
display: inline-block;
|
|
218
219
|
box-sizing: border-box;
|
|
219
220
|
min-width: 84px;
|
|
220
221
|
max-width: 260px;
|
|
221
222
|
height: 22px;
|
|
222
|
-
margin: 0 2px;
|
|
223
223
|
padding: 0 6px;
|
|
224
224
|
border: 1px solid var(--dsw-alias-border-l2);
|
|
225
225
|
border-radius: var(--dsw-radius-xs);
|
|
@@ -230,6 +230,15 @@ var CSS = `
|
|
|
230
230
|
vertical-align: baseline;
|
|
231
231
|
}
|
|
232
232
|
.zps-var:focus { outline: none; border-color: var(--dsw-alias-brand-primary); }
|
|
233
|
+
/* \u53D8\u91CF\u6846\u7684 @ \u5019\u9009\uFF1Aportal \u5230 body \u7684\u6D6E\u5C42\uFF0C\u4E0D\u88AB\u5361\u7247\u6EDA\u52A8\u5BB9\u5668\u88C1\u6389\u3002 */
|
|
234
|
+
.zps-varmenu {
|
|
235
|
+
position: fixed;
|
|
236
|
+
z-index: 1100;
|
|
237
|
+
width: 360px;
|
|
238
|
+
max-width: calc(100vw - 16px);
|
|
239
|
+
border-radius: 10px;
|
|
240
|
+
box-shadow: var(--dsw-elevation-prominent);
|
|
241
|
+
}
|
|
233
242
|
.zps-varbadge { flex: none; font-size: 11px; color: var(--dsw-alias-label-secondary); }
|
|
234
243
|
.zps-placeholder {
|
|
235
244
|
flex: 1 1 auto;
|
|
@@ -522,7 +531,8 @@ function createPromptSource() {
|
|
|
522
531
|
return {
|
|
523
532
|
trigger: "@",
|
|
524
533
|
name: "\u5E38\u7528\u63D0\u793A\u8BCD",
|
|
525
|
-
|
|
534
|
+
// 20:排在「工作区文件」(10) 之后、「历史会话」(30) 之前,和别的 @ 源同序时顺序稳定。
|
|
535
|
+
order: 20,
|
|
526
536
|
async candidates(_session, req) {
|
|
527
537
|
const keyword = String(req.query || "").trim().toLowerCase();
|
|
528
538
|
const used = /* @__PURE__ */ new Map();
|
|
@@ -537,6 +547,9 @@ function createPromptSource() {
|
|
|
537
547
|
};
|
|
538
548
|
});
|
|
539
549
|
},
|
|
550
|
+
// 纯文本出口:返回 { text } 换掉触发词,草稿里只有文字、不生成引用卡片——
|
|
551
|
+
// 所以这个源不需要 codec。哪天改成返回 { insert: { source: … } } 插卡片,
|
|
552
|
+
// 就必须同时补 codec,否则发送会报 slash: no serializer for reference source "常用提示词"。
|
|
540
553
|
onPick(pick) {
|
|
541
554
|
const id = pick.candidate.value;
|
|
542
555
|
const item = knownItems().find((it) => it.id === id);
|
|
@@ -603,6 +616,9 @@ function mentionAt(value, caret) {
|
|
|
603
616
|
if (at > 0 && !/\s/.test(before[at - 1])) return null;
|
|
604
617
|
return { start: at, end: pos, query };
|
|
605
618
|
}
|
|
619
|
+
function typedAtChar(previous, next, caret) {
|
|
620
|
+
return String(next || "").length === String(previous || "").length + 1 && String(next || "").charAt(caret - 1) === "@";
|
|
621
|
+
}
|
|
606
622
|
function MentionIcon(props) {
|
|
607
623
|
const kind = props.kind;
|
|
608
624
|
const d = kind === "folder" ? "M1.8 4.2h3.4l1.2 1.3h5.8v6.1H1.8z" : kind === "session" ? "M2 3.6h10v6.1H6.6L4.2 11.7V9.7H2z" : kind === "plugin" ? "M3.2 3.2h7.6v7.6H3.2zM3.2 6.4h7.6M6.4 3.2v7.6" : "M4 1.9h4.4L10.9 4.4v7.7H4z";
|
|
@@ -690,26 +706,18 @@ function MentionMenu(props) {
|
|
|
690
706
|
}) })
|
|
691
707
|
] });
|
|
692
708
|
}
|
|
693
|
-
function
|
|
694
|
-
const editing = props.editing;
|
|
695
|
-
const onTitle = props.onTitle;
|
|
696
|
-
const onText = props.onText;
|
|
697
|
-
const onCancel = props.onCancel;
|
|
698
|
-
const onSave = props.onSave;
|
|
699
|
-
const titleRef = (0, import_react.useRef)(null);
|
|
700
|
-
const textareaRef = (0, import_react.useRef)(null);
|
|
709
|
+
function useMentionField(listFiles, applyPick) {
|
|
701
710
|
const [menu, setMenu] = (0, import_react.useState)(null);
|
|
702
711
|
const [chosen, setChosen] = (0, import_react.useState)(0);
|
|
703
712
|
const [activeSection, setActiveSection] = (0, import_react.useState)(null);
|
|
704
713
|
const [hits, setHits] = (0, import_react.useState)([]);
|
|
705
714
|
const fetchTimer = (0, import_react.useRef)(0);
|
|
706
715
|
const fetchSeq = (0, import_react.useRef)(0);
|
|
707
|
-
const prevValue = (0, import_react.useRef)("");
|
|
708
716
|
const refreshHits = (0, import_react.useCallback)((query) => {
|
|
709
717
|
window.clearTimeout(fetchTimer.current);
|
|
710
718
|
const seq = ++fetchSeq.current;
|
|
711
719
|
fetchTimer.current = window.setTimeout(() => {
|
|
712
|
-
const ask =
|
|
720
|
+
const ask = listFiles;
|
|
713
721
|
if (typeof ask !== "function") {
|
|
714
722
|
setHits([]);
|
|
715
723
|
return;
|
|
@@ -721,71 +729,51 @@ function TemplateEditor(props) {
|
|
|
721
729
|
if (seq === fetchSeq.current) setHits([]);
|
|
722
730
|
});
|
|
723
731
|
}, 120);
|
|
724
|
-
}, [
|
|
732
|
+
}, [listFiles]);
|
|
725
733
|
(0, import_react.useEffect)(() => () => window.clearTimeout(fetchTimer.current), []);
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
const openMention = (0, import_react.useCallback)((field, value, caret, element) => {
|
|
729
|
-
if (!element) {
|
|
730
|
-
setMenu(null);
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
let pos = typeof caret === "number" && caret > 0 ? caret : 0;
|
|
734
|
-
if (pos === 0 && typeof element.selectionStart === "number" && element.selectionStart > 0) {
|
|
735
|
-
pos = element.selectionStart;
|
|
736
|
-
}
|
|
737
|
-
if (pos === 0) pos = String(value || "").length;
|
|
734
|
+
const track = (0, import_react.useCallback)((field, value, caret, typedAt) => {
|
|
735
|
+
const pos = typeof caret === "number" && caret > 0 ? caret : String(value || "").length;
|
|
738
736
|
const hit = mentionAt(value, pos);
|
|
739
|
-
if (
|
|
740
|
-
|
|
737
|
+
if (typedAt) {
|
|
738
|
+
if (!hit) {
|
|
739
|
+
setMenu(null);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
setMenu({ field, start: hit.start, end: hit.end, query: hit.query });
|
|
743
|
+
setChosen(0);
|
|
744
|
+
setActiveSection(null);
|
|
745
|
+
refreshHits(hit.query);
|
|
741
746
|
return;
|
|
742
747
|
}
|
|
743
|
-
setMenu({ field, start: hit.start, end: hit.end, query: hit.query });
|
|
744
|
-
setChosen(0);
|
|
745
|
-
setActiveSection(null);
|
|
746
|
-
refreshHits(hit.query);
|
|
747
|
-
}, [refreshHits]);
|
|
748
|
-
const followMention = (0, import_react.useCallback)((field, value, caret) => {
|
|
749
748
|
setMenu((prev) => {
|
|
750
749
|
if (!prev || prev.field !== field) return prev;
|
|
751
|
-
const pos = typeof caret === "number" && caret > 0 ? caret : String(value || "").length;
|
|
752
|
-
const hit = mentionAt(value, pos);
|
|
753
750
|
if (!hit) return null;
|
|
754
751
|
refreshHits(hit.query);
|
|
755
|
-
return {
|
|
752
|
+
return { field, start: hit.start, end: hit.end, query: hit.query };
|
|
756
753
|
});
|
|
757
754
|
}, [refreshHits]);
|
|
758
|
-
const
|
|
755
|
+
const close = (0, import_react.useCallback)(() => setMenu(null), []);
|
|
756
|
+
const rows = activeSection ? hits.filter((row) => row.section === activeSection) : hits;
|
|
757
|
+
const pick = (0, import_react.useCallback)((row) => {
|
|
759
758
|
if (!menu || !row) return;
|
|
760
|
-
const
|
|
761
|
-
const current = isTitle ? String(editing.title || "") : String(editing.text || "");
|
|
762
|
-
const next = current.slice(0, menu.start) + row.mention + current.slice(menu.end);
|
|
763
|
-
if (isTitle) onTitle(next);
|
|
764
|
-
else onText(next);
|
|
765
|
-
const caret = menu.start + row.mention.length;
|
|
766
|
-
prevValue.current = next;
|
|
759
|
+
const settled = menu;
|
|
767
760
|
setMenu(null);
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
el.focus();
|
|
772
|
-
el.setSelectionRange(caret, caret);
|
|
773
|
-
});
|
|
774
|
-
}, [editing, menu, onText, onTitle]);
|
|
775
|
-
const menuKey = (0, import_react.useCallback)((event) => {
|
|
761
|
+
applyPick(row, settled);
|
|
762
|
+
}, [applyPick, menu]);
|
|
763
|
+
const onKeyDown = (0, import_react.useCallback)((event) => {
|
|
776
764
|
if (!menu) return false;
|
|
777
765
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
778
766
|
event.preventDefault();
|
|
779
|
-
if (
|
|
767
|
+
if (rows.length > 0) {
|
|
780
768
|
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
781
|
-
setChosen((prev) => (prev + step +
|
|
769
|
+
setChosen((prev) => (prev + step + rows.length) % rows.length);
|
|
782
770
|
}
|
|
783
771
|
return true;
|
|
784
772
|
}
|
|
785
773
|
if (event.key === "Enter" || event.key === "Tab") {
|
|
786
774
|
event.preventDefault();
|
|
787
|
-
const row =
|
|
788
|
-
if (row)
|
|
775
|
+
const row = rows[Math.min(chosen, Math.max(0, rows.length - 1))] || rows[0];
|
|
776
|
+
if (row) pick(row);
|
|
789
777
|
return true;
|
|
790
778
|
}
|
|
791
779
|
if (event.key === "Escape") {
|
|
@@ -794,7 +782,34 @@ function TemplateEditor(props) {
|
|
|
794
782
|
return true;
|
|
795
783
|
}
|
|
796
784
|
return false;
|
|
797
|
-
}, [chosen, menu,
|
|
785
|
+
}, [chosen, menu, pick, rows]);
|
|
786
|
+
return { menu, rows, chosen, activeSection, setActiveSection, setChosen, track, onKeyDown, close };
|
|
787
|
+
}
|
|
788
|
+
function TemplateEditor(props) {
|
|
789
|
+
const editing = props.editing;
|
|
790
|
+
const onTitle = props.onTitle;
|
|
791
|
+
const onText = props.onText;
|
|
792
|
+
const onCancel = props.onCancel;
|
|
793
|
+
const onSave = props.onSave;
|
|
794
|
+
const titleRef = (0, import_react.useRef)(null);
|
|
795
|
+
const textareaRef = (0, import_react.useRef)(null);
|
|
796
|
+
const prevValue = (0, import_react.useRef)("");
|
|
797
|
+
const applyMention = (0, import_react.useCallback)((row, menu) => {
|
|
798
|
+
const isTitle = menu.field === "title";
|
|
799
|
+
const current = isTitle ? String(editing.title || "") : String(editing.text || "");
|
|
800
|
+
const next = current.slice(0, menu.start) + row.mention + current.slice(menu.end);
|
|
801
|
+
if (isTitle) onTitle(next);
|
|
802
|
+
else onText(next);
|
|
803
|
+
const caret = menu.start + row.mention.length;
|
|
804
|
+
prevValue.current = next;
|
|
805
|
+
window.requestAnimationFrame(() => {
|
|
806
|
+
const el = isTitle ? titleRef.current : textareaRef.current;
|
|
807
|
+
if (!el) return;
|
|
808
|
+
el.focus();
|
|
809
|
+
el.setSelectionRange(caret, caret);
|
|
810
|
+
});
|
|
811
|
+
}, [editing, onText, onTitle]);
|
|
812
|
+
const mention = useMentionField(props.listFiles, applyMention);
|
|
798
813
|
const insertVariable = (0, import_react.useCallback)(() => {
|
|
799
814
|
const node = textareaRef.current;
|
|
800
815
|
const text = String(editing.text || "");
|
|
@@ -820,14 +835,13 @@ function TemplateEditor(props) {
|
|
|
820
835
|
onChange: (event) => {
|
|
821
836
|
const value = event.target.value;
|
|
822
837
|
const caret = event.target.selectionStart || 0;
|
|
823
|
-
const typedAt =
|
|
838
|
+
const typedAt = typedAtChar(prevValue.current, value, caret);
|
|
824
839
|
prevValue.current = value;
|
|
825
840
|
onTitle(value);
|
|
826
|
-
|
|
827
|
-
else followMention("title", value, caret);
|
|
841
|
+
mention.track("title", value, caret, typedAt);
|
|
828
842
|
},
|
|
829
843
|
onKeyDown: (event) => {
|
|
830
|
-
|
|
844
|
+
mention.onKeyDown(event);
|
|
831
845
|
}
|
|
832
846
|
}
|
|
833
847
|
),
|
|
@@ -841,14 +855,13 @@ function TemplateEditor(props) {
|
|
|
841
855
|
onChange: (event) => {
|
|
842
856
|
const value = event.target.value;
|
|
843
857
|
const caret = event.target.selectionStart || 0;
|
|
844
|
-
const typedAt =
|
|
858
|
+
const typedAt = typedAtChar(prevValue.current, value, caret);
|
|
845
859
|
prevValue.current = value;
|
|
846
860
|
onText(value);
|
|
847
|
-
|
|
848
|
-
else followMention("text", value, caret);
|
|
861
|
+
mention.track("text", value, caret, typedAt);
|
|
849
862
|
},
|
|
850
863
|
onKeyDown: (event) => {
|
|
851
|
-
if (
|
|
864
|
+
if (mention.onKeyDown(event)) return;
|
|
852
865
|
if (event.isComposing || event.repeat) return;
|
|
853
866
|
const key = String(event.key || "").toLowerCase();
|
|
854
867
|
if (key !== "v" || event.shiftKey || !event.altKey) return;
|
|
@@ -873,21 +886,109 @@ function TemplateEditor(props) {
|
|
|
873
886
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "zps-btn", onClick: onCancel, children: "\u53D6\u6D88" }),
|
|
874
887
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "zps-btn primary", onClick: onSave, children: "\u4FDD\u5B58" })
|
|
875
888
|
] }),
|
|
876
|
-
menu ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
889
|
+
mention.menu ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
877
890
|
MentionMenu,
|
|
878
891
|
{
|
|
879
|
-
rows:
|
|
880
|
-
active: activeSection,
|
|
892
|
+
rows: mention.rows,
|
|
893
|
+
active: mention.activeSection,
|
|
881
894
|
onSelect: (key) => {
|
|
882
|
-
setActiveSection(key);
|
|
883
|
-
setChosen(0);
|
|
895
|
+
mention.setActiveSection(key);
|
|
896
|
+
mention.setChosen(0);
|
|
884
897
|
},
|
|
885
|
-
chosen,
|
|
886
|
-
onPick:
|
|
898
|
+
chosen: mention.chosen,
|
|
899
|
+
onPick: (row) => mention.pick(row)
|
|
887
900
|
}
|
|
888
901
|
) : null
|
|
889
902
|
] });
|
|
890
903
|
}
|
|
904
|
+
function VarField(props) {
|
|
905
|
+
const value = String(props.value || "");
|
|
906
|
+
const onChange = props.onChange;
|
|
907
|
+
const inputRef = (0, import_react.useRef)(null);
|
|
908
|
+
const prevValue = (0, import_react.useRef)(value);
|
|
909
|
+
const [anchor, setAnchor] = (0, import_react.useState)(null);
|
|
910
|
+
const applyMention = (0, import_react.useCallback)((row, menu) => {
|
|
911
|
+
const current = String(props.value || "");
|
|
912
|
+
const next = current.slice(0, menu.start) + row.mention + current.slice(menu.end);
|
|
913
|
+
onChange(next);
|
|
914
|
+
prevValue.current = next;
|
|
915
|
+
const caret = menu.start + row.mention.length;
|
|
916
|
+
window.requestAnimationFrame(() => {
|
|
917
|
+
const el = inputRef.current;
|
|
918
|
+
if (!el) return;
|
|
919
|
+
el.focus();
|
|
920
|
+
el.setSelectionRange(caret, caret);
|
|
921
|
+
});
|
|
922
|
+
}, [onChange, props.value]);
|
|
923
|
+
const mention = useMentionField(props.listFiles, applyMention);
|
|
924
|
+
const open = mention.menu !== null;
|
|
925
|
+
(0, import_react.useEffect)(() => {
|
|
926
|
+
if (!open) {
|
|
927
|
+
setAnchor(null);
|
|
928
|
+
return void 0;
|
|
929
|
+
}
|
|
930
|
+
const place = () => {
|
|
931
|
+
const el = inputRef.current;
|
|
932
|
+
if (!el) return;
|
|
933
|
+
const rect = el.getBoundingClientRect();
|
|
934
|
+
const width = Math.max(280, Math.min(420, rect.width + 240));
|
|
935
|
+
const height = 248;
|
|
936
|
+
const below = rect.bottom + 4 + height <= window.innerHeight;
|
|
937
|
+
setAnchor({
|
|
938
|
+
left: Math.max(8, Math.min(rect.left, window.innerWidth - width - 8)),
|
|
939
|
+
top: below ? rect.bottom + 4 : Math.max(8, rect.top - 4 - height),
|
|
940
|
+
width
|
|
941
|
+
});
|
|
942
|
+
};
|
|
943
|
+
place();
|
|
944
|
+
window.addEventListener("resize", place);
|
|
945
|
+
window.addEventListener("scroll", place, true);
|
|
946
|
+
return () => {
|
|
947
|
+
window.removeEventListener("resize", place);
|
|
948
|
+
window.removeEventListener("scroll", place, true);
|
|
949
|
+
};
|
|
950
|
+
}, [open]);
|
|
951
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "zps-varwrap", children: [
|
|
952
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
953
|
+
"input",
|
|
954
|
+
{
|
|
955
|
+
ref: inputRef,
|
|
956
|
+
className: "zps-var",
|
|
957
|
+
value,
|
|
958
|
+
placeholder: props.name,
|
|
959
|
+
title: "\u53D8\u91CF\uFF1A" + props.name + "\uFF08\u9ED8\u8BA4\u503C\uFF1A" + (props.fallback || "\u7A7A") + "\uFF1B\u6253 @ \u53EF\u5F15\u7528\u5DE5\u4F5C\u533A\u6587\u4EF6\uFF09",
|
|
960
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
961
|
+
onClick: (event) => event.stopPropagation(),
|
|
962
|
+
onBlur: () => mention.close(),
|
|
963
|
+
onChange: (event) => {
|
|
964
|
+
const next = event.target.value;
|
|
965
|
+
const caret = event.target.selectionStart || 0;
|
|
966
|
+
const typedAt = typedAtChar(prevValue.current, next, caret);
|
|
967
|
+
prevValue.current = next;
|
|
968
|
+
onChange(next);
|
|
969
|
+
mention.track("var", next, caret, typedAt);
|
|
970
|
+
},
|
|
971
|
+
onKeyDown: (event) => mention.onKeyDown(event)
|
|
972
|
+
}
|
|
973
|
+
),
|
|
974
|
+
open && anchor ? (0, import_react_dom.createPortal)(
|
|
975
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "zps-varmenu", style: { left: anchor.left, top: anchor.top, width: anchor.width }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
976
|
+
MentionMenu,
|
|
977
|
+
{
|
|
978
|
+
rows: mention.rows,
|
|
979
|
+
active: mention.activeSection,
|
|
980
|
+
onSelect: (key) => {
|
|
981
|
+
mention.setActiveSection(key);
|
|
982
|
+
mention.setChosen(0);
|
|
983
|
+
},
|
|
984
|
+
chosen: mention.chosen,
|
|
985
|
+
onPick: (row) => mention.pick(row)
|
|
986
|
+
}
|
|
987
|
+
) }),
|
|
988
|
+
document.body
|
|
989
|
+
) : null
|
|
990
|
+
] });
|
|
991
|
+
}
|
|
891
992
|
function PromptShelf(props) {
|
|
892
993
|
const useInput = props && typeof props.useInput === "function" ? props.useInput : null;
|
|
893
994
|
const inputActions = props ? props.inputActions : void 0;
|
|
@@ -1242,15 +1343,13 @@ function PromptShelf(props) {
|
|
|
1242
1343
|
listFiles: props.listFiles
|
|
1243
1344
|
}
|
|
1244
1345
|
) : varNames.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "zps-cardtext zps-cardtext-vars", children: parts.map((part, index) => part.type === "text" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: part.value }, index) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1245
|
-
|
|
1346
|
+
VarField,
|
|
1246
1347
|
{
|
|
1247
|
-
className: "zps-var",
|
|
1248
1348
|
value: templateValue(varValues[item.id], part.name, part.fallback),
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
onChange: (event) => setVar(item.id, part.name, event.target.value)
|
|
1349
|
+
name: part.name,
|
|
1350
|
+
fallback: part.fallback,
|
|
1351
|
+
onChange: (value) => setVar(item.id, part.name, value),
|
|
1352
|
+
listFiles: props.listFiles
|
|
1254
1353
|
},
|
|
1255
1354
|
index
|
|
1256
1355
|
)) }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-prompt-shelf",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "在输入框工具行里存常用提示词:搜索、插入或整框替换,带变量与回收站。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
],
|
|
24
24
|
"engines": {
|
|
25
25
|
"node": "^22.19.0 || >=24.0.0",
|
|
26
|
-
"dsh": ">=0.2.1-0
|
|
26
|
+
"dsh": ">=0.2.1-0"
|
|
27
27
|
},
|
|
28
28
|
"dsh": {
|
|
29
29
|
"client": {
|
package/src/entry.jsx
CHANGED
|
@@ -31,7 +31,7 @@ const PLUGINS_API = '/dsh-prompt-shelf/api/plugins'
|
|
|
31
31
|
/** 没写标题时,取正文首行前多少字当标题。 */
|
|
32
32
|
const TITLE_MAX = 28
|
|
33
33
|
/** 编辑表单下的占位符写法说明。 */
|
|
34
|
-
const TEMPLATE_HINT = '正文里写 {{变量名}},或 {{变量名=默认值}};插入时用卡片里填的值替换(不填就用默认值)。正文框里按 Alt+V(或 Ctrl+Alt+V
|
|
34
|
+
const TEMPLATE_HINT = '正文里写 {{变量名}},或 {{变量名=默认值}};插入时用卡片里填的值替换(不填就用默认值)。正文框里按 Alt+V(或 Ctrl+Alt+V)也能插入变量;卡片上的变量框里打 @ 可引用工作区文件。'
|
|
35
35
|
|
|
36
36
|
/** 输入框不可用时的兜底状态(dock 是 session 范围,理论上永远有输入框)。 */
|
|
37
37
|
const EMPTY_INPUT = { draft: '' }
|
|
@@ -208,13 +208,13 @@ const CSS = `
|
|
|
208
208
|
}
|
|
209
209
|
/* 有变量的卡片:正文按段渲染,变量位置是小输入框,直接原位改。 */
|
|
210
210
|
.zps-cardtext-vars { display: block; cursor: default; }
|
|
211
|
+
.zps-varwrap { display: inline-block; margin: 0 2px; vertical-align: baseline; }
|
|
211
212
|
.zps-var {
|
|
212
213
|
display: inline-block;
|
|
213
214
|
box-sizing: border-box;
|
|
214
215
|
min-width: 84px;
|
|
215
216
|
max-width: 260px;
|
|
216
217
|
height: 22px;
|
|
217
|
-
margin: 0 2px;
|
|
218
218
|
padding: 0 6px;
|
|
219
219
|
border: 1px solid var(--dsw-alias-border-l2);
|
|
220
220
|
border-radius: var(--dsw-radius-xs);
|
|
@@ -225,6 +225,15 @@ const CSS = `
|
|
|
225
225
|
vertical-align: baseline;
|
|
226
226
|
}
|
|
227
227
|
.zps-var:focus { outline: none; border-color: var(--dsw-alias-brand-primary); }
|
|
228
|
+
/* 变量框的 @ 候选:portal 到 body 的浮层,不被卡片滚动容器裁掉。 */
|
|
229
|
+
.zps-varmenu {
|
|
230
|
+
position: fixed;
|
|
231
|
+
z-index: 1100;
|
|
232
|
+
width: 360px;
|
|
233
|
+
max-width: calc(100vw - 16px);
|
|
234
|
+
border-radius: 10px;
|
|
235
|
+
box-shadow: var(--dsw-elevation-prominent);
|
|
236
|
+
}
|
|
228
237
|
.zps-varbadge { flex: none; font-size: 11px; color: var(--dsw-alias-label-secondary); }
|
|
229
238
|
.zps-placeholder {
|
|
230
239
|
flex: 1 1 auto;
|
|
@@ -561,7 +570,8 @@ function createPromptSource() {
|
|
|
561
570
|
return {
|
|
562
571
|
trigger: '@',
|
|
563
572
|
name: '常用提示词',
|
|
564
|
-
|
|
573
|
+
// 20:排在「工作区文件」(10) 之后、「历史会话」(30) 之前,和别的 @ 源同序时顺序稳定。
|
|
574
|
+
order: 20,
|
|
565
575
|
async candidates(_session, req) {
|
|
566
576
|
const keyword = String(req.query || '').trim().toLowerCase()
|
|
567
577
|
const used = new Map()
|
|
@@ -580,6 +590,9 @@ function createPromptSource() {
|
|
|
580
590
|
}
|
|
581
591
|
})
|
|
582
592
|
},
|
|
593
|
+
// 纯文本出口:返回 { text } 换掉触发词,草稿里只有文字、不生成引用卡片——
|
|
594
|
+
// 所以这个源不需要 codec。哪天改成返回 { insert: { source: … } } 插卡片,
|
|
595
|
+
// 就必须同时补 codec,否则发送会报 slash: no serializer for reference source "常用提示词"。
|
|
583
596
|
onPick(pick) {
|
|
584
597
|
const id = pick.candidate.value
|
|
585
598
|
const item = knownItems().find((it) => it.id === id)
|
|
@@ -689,6 +702,11 @@ function mentionAt(value, caret) {
|
|
|
689
702
|
return { start: at, end: pos, query }
|
|
690
703
|
}
|
|
691
704
|
|
|
705
|
+
/** 刚敲下一个 @:长度只多 1,且新字符就是 @。打开候选菜单的时机只认这个。 */
|
|
706
|
+
function typedAtChar(previous, next, caret) {
|
|
707
|
+
return String(next || '').length === String(previous || '').length + 1 && String(next || '').charAt(caret - 1) === '@'
|
|
708
|
+
}
|
|
709
|
+
|
|
692
710
|
/** 行首图标:文件 / 文件夹 / 对话 / 插件(与输入框 @ 菜单同一套观感)。 */
|
|
693
711
|
function MentionIcon(props) {
|
|
694
712
|
const kind = props.kind
|
|
@@ -773,39 +791,26 @@ function MentionMenu(props) {
|
|
|
773
791
|
}
|
|
774
792
|
|
|
775
793
|
/**
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
* @param
|
|
780
|
-
* @
|
|
781
|
-
* @param props.onText - 正文变更。
|
|
782
|
-
* @param props.onCancel - 退出编辑。
|
|
783
|
-
* @param props.onSave - 保存。
|
|
794
|
+
* 输入框里的 @ 候选状态:标题框、正文框、卡片上的变量框共用同一套。
|
|
795
|
+
* 触发规则与宿主一致:@ 在行首或空白之后、@ 后不含空白,才算触发词。
|
|
796
|
+
* @param listFiles - 查候选:query → 行数组([{ id, label, desc, section, mention }])。
|
|
797
|
+
* @param applyPick - 选中一行后怎么改值:applyPick(row, menu),menu 带 { field, start, end, query }。
|
|
798
|
+
* @returns track(输入变化时喂)、onKeyDown(键盘)、rows / chosen / activeSection / setActiveSection / setChosen(菜单渲染)、close。
|
|
784
799
|
*/
|
|
785
|
-
function
|
|
786
|
-
const editing = props.editing
|
|
787
|
-
const onTitle = props.onTitle
|
|
788
|
-
const onText = props.onText
|
|
789
|
-
const onCancel = props.onCancel
|
|
790
|
-
const onSave = props.onSave
|
|
791
|
-
const titleRef = useRef(null)
|
|
792
|
-
const textareaRef = useRef(null)
|
|
800
|
+
function useMentionField(listFiles, applyPick) {
|
|
793
801
|
const [menu, setMenu] = useState(null)
|
|
794
802
|
const [chosen, setChosen] = useState(0)
|
|
795
|
-
/** 类别栏的当前选择:null = 全部。 */
|
|
796
803
|
const [activeSection, setActiveSection] = useState(null)
|
|
797
|
-
/** @ 候选:宿主给的工作区文件/文件夹。 */
|
|
798
804
|
const [hits, setHits] = useState([])
|
|
799
805
|
const fetchTimer = useRef(0)
|
|
800
806
|
const fetchSeq = useRef(0)
|
|
801
|
-
const prevValue = useRef('')
|
|
802
807
|
|
|
803
808
|
/** 按查询词问一下宿主:工作区里有哪些文件/文件夹。 */
|
|
804
809
|
const refreshHits = useCallback((query) => {
|
|
805
810
|
window.clearTimeout(fetchTimer.current)
|
|
806
811
|
const seq = ++fetchSeq.current
|
|
807
812
|
fetchTimer.current = window.setTimeout(() => {
|
|
808
|
-
const ask =
|
|
813
|
+
const ask = listFiles
|
|
809
814
|
if (typeof ask !== 'function') {
|
|
810
815
|
setHits([])
|
|
811
816
|
return
|
|
@@ -817,83 +822,67 @@ function TemplateEditor(props) {
|
|
|
817
822
|
if (seq === fetchSeq.current) setHits([])
|
|
818
823
|
})
|
|
819
824
|
}, 120)
|
|
820
|
-
}, [
|
|
825
|
+
}, [listFiles])
|
|
821
826
|
|
|
822
827
|
useEffect(() => () => window.clearTimeout(fetchTimer.current), [])
|
|
823
828
|
|
|
824
|
-
/**
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
return
|
|
834
|
-
}
|
|
835
|
-
let pos = typeof caret === 'number' && caret > 0 ? caret : 0
|
|
836
|
-
if (pos === 0 && typeof element.selectionStart === 'number' && element.selectionStart > 0) {
|
|
837
|
-
pos = element.selectionStart
|
|
838
|
-
}
|
|
839
|
-
if (pos === 0) pos = String(value || '').length
|
|
829
|
+
/**
|
|
830
|
+
* 喂一次输入变化。
|
|
831
|
+
* @param field - 哪个输入框(同一个 hook 服务多个框时用来隔离)。
|
|
832
|
+
* @param value - 变化后的整段文字。
|
|
833
|
+
* @param caret - 光标位置。
|
|
834
|
+
* @param typedAt - 本次变化是不是刚敲下 @。
|
|
835
|
+
*/
|
|
836
|
+
const track = useCallback((field, value, caret, typedAt) => {
|
|
837
|
+
const pos = typeof caret === 'number' && caret > 0 ? caret : String(value || '').length
|
|
840
838
|
const hit = mentionAt(value, pos)
|
|
841
|
-
if (
|
|
842
|
-
|
|
839
|
+
if (typedAt) {
|
|
840
|
+
if (!hit) {
|
|
841
|
+
setMenu(null)
|
|
842
|
+
return
|
|
843
|
+
}
|
|
844
|
+
setMenu({ field, start: hit.start, end: hit.end, query: hit.query })
|
|
845
|
+
setChosen(0)
|
|
846
|
+
setActiveSection(null)
|
|
847
|
+
refreshHits(hit.query)
|
|
843
848
|
return
|
|
844
849
|
}
|
|
845
|
-
setMenu({ field, start: hit.start, end: hit.end, query: hit.query })
|
|
846
|
-
setChosen(0)
|
|
847
|
-
setActiveSection(null)
|
|
848
|
-
refreshHits(hit.query)
|
|
849
|
-
}, [refreshHits])
|
|
850
|
-
|
|
851
|
-
/** 菜单已打开时跟随输入更新查询词;不再处于触发态就关掉。 */
|
|
852
|
-
const followMention = useCallback((field, value, caret) => {
|
|
853
850
|
setMenu((prev) => {
|
|
854
851
|
if (!prev || prev.field !== field) return prev
|
|
855
|
-
const pos = typeof caret === 'number' && caret > 0 ? caret : String(value || '').length
|
|
856
|
-
const hit = mentionAt(value, pos)
|
|
857
852
|
if (!hit) return null
|
|
858
853
|
refreshHits(hit.query)
|
|
859
|
-
return {
|
|
854
|
+
return { field, start: hit.start, end: hit.end, query: hit.query }
|
|
860
855
|
})
|
|
861
856
|
}, [refreshHits])
|
|
862
857
|
|
|
863
|
-
|
|
864
|
-
|
|
858
|
+
const close = useCallback(() => setMenu(null), [])
|
|
859
|
+
|
|
860
|
+
/** 类别栏筛选后的可见行。 */
|
|
861
|
+
const rows = activeSection ? hits.filter((row) => row.section === activeSection) : hits
|
|
862
|
+
|
|
863
|
+
/** 选中一行:关菜单,把「@查询段」交给调用方替换成 row.mention。 */
|
|
864
|
+
const pick = useCallback((row) => {
|
|
865
865
|
if (!menu || !row) return
|
|
866
|
-
const
|
|
867
|
-
const current = isTitle ? String(editing.title || '') : String(editing.text || '')
|
|
868
|
-
const next = current.slice(0, menu.start) + row.mention + current.slice(menu.end)
|
|
869
|
-
if (isTitle) onTitle(next)
|
|
870
|
-
else onText(next)
|
|
871
|
-
const caret = menu.start + row.mention.length
|
|
872
|
-
prevValue.current = next
|
|
866
|
+
const settled = menu
|
|
873
867
|
setMenu(null)
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
if (!el) return
|
|
877
|
-
el.focus()
|
|
878
|
-
el.setSelectionRange(caret, caret)
|
|
879
|
-
})
|
|
880
|
-
}, [editing, menu, onText, onTitle])
|
|
868
|
+
applyPick(row, settled)
|
|
869
|
+
}, [applyPick, menu])
|
|
881
870
|
|
|
882
871
|
/** 菜单打开时的键盘:上下选、回车/制表选中、Esc 关。返回 true 表示已处理。 */
|
|
883
|
-
const
|
|
872
|
+
const onKeyDown = useCallback((event) => {
|
|
884
873
|
if (!menu) return false
|
|
885
874
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
886
875
|
event.preventDefault()
|
|
887
|
-
if (
|
|
876
|
+
if (rows.length > 0) {
|
|
888
877
|
const step = event.key === 'ArrowDown' ? 1 : -1
|
|
889
|
-
setChosen((prev) => (prev + step +
|
|
878
|
+
setChosen((prev) => (prev + step + rows.length) % rows.length)
|
|
890
879
|
}
|
|
891
880
|
return true
|
|
892
881
|
}
|
|
893
882
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
894
883
|
event.preventDefault()
|
|
895
|
-
const row =
|
|
896
|
-
if (row)
|
|
884
|
+
const row = rows[Math.min(chosen, Math.max(0, rows.length - 1))] || rows[0]
|
|
885
|
+
if (row) pick(row)
|
|
897
886
|
return true
|
|
898
887
|
}
|
|
899
888
|
if (event.key === 'Escape') {
|
|
@@ -902,7 +891,49 @@ function TemplateEditor(props) {
|
|
|
902
891
|
return true
|
|
903
892
|
}
|
|
904
893
|
return false
|
|
905
|
-
}, [chosen, menu,
|
|
894
|
+
}, [chosen, menu, pick, rows])
|
|
895
|
+
|
|
896
|
+
return { menu, rows, chosen, activeSection, setActiveSection, setChosen, track, onKeyDown, close }
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* 一张卡片的编辑表单(新建与编辑共用):标题 + 正文 +「+ 变量」。
|
|
901
|
+
* 两个输入框里都能打 @ 选已有常用提示词:标题框插「名」,正文框插「整段正文」。
|
|
902
|
+
* 「+ 变量」把 {{变量}} 插到正文编辑框的光标处,并把「变量」两字选中——直接打字就是改名。
|
|
903
|
+
* @param props.editing - 正在编辑的草稿 {id, title, text}。
|
|
904
|
+
* @param props.onTitle - 标题变更。
|
|
905
|
+
* @param props.onText - 正文变更。
|
|
906
|
+
* @param props.onCancel - 退出编辑。
|
|
907
|
+
* @param props.onSave - 保存。
|
|
908
|
+
*/
|
|
909
|
+
function TemplateEditor(props) {
|
|
910
|
+
const editing = props.editing
|
|
911
|
+
const onTitle = props.onTitle
|
|
912
|
+
const onText = props.onText
|
|
913
|
+
const onCancel = props.onCancel
|
|
914
|
+
const onSave = props.onSave
|
|
915
|
+
const titleRef = useRef(null)
|
|
916
|
+
const textareaRef = useRef(null)
|
|
917
|
+
const prevValue = useRef('')
|
|
918
|
+
|
|
919
|
+
/** 选中一行:把「@查询段」换成 row.mention,光标落在插入内容之后。 */
|
|
920
|
+
const applyMention = useCallback((row, menu) => {
|
|
921
|
+
const isTitle = menu.field === 'title'
|
|
922
|
+
const current = isTitle ? String(editing.title || '') : String(editing.text || '')
|
|
923
|
+
const next = current.slice(0, menu.start) + row.mention + current.slice(menu.end)
|
|
924
|
+
if (isTitle) onTitle(next)
|
|
925
|
+
else onText(next)
|
|
926
|
+
const caret = menu.start + row.mention.length
|
|
927
|
+
prevValue.current = next
|
|
928
|
+
window.requestAnimationFrame(() => {
|
|
929
|
+
const el = isTitle ? titleRef.current : textareaRef.current
|
|
930
|
+
if (!el) return
|
|
931
|
+
el.focus()
|
|
932
|
+
el.setSelectionRange(caret, caret)
|
|
933
|
+
})
|
|
934
|
+
}, [editing, onText, onTitle])
|
|
935
|
+
|
|
936
|
+
const mention = useMentionField(props.listFiles, applyMention)
|
|
906
937
|
|
|
907
938
|
const insertVariable = useCallback(() => {
|
|
908
939
|
const node = textareaRef.current
|
|
@@ -929,13 +960,12 @@ function TemplateEditor(props) {
|
|
|
929
960
|
onChange={(event) => {
|
|
930
961
|
const value = event.target.value
|
|
931
962
|
const caret = event.target.selectionStart || 0
|
|
932
|
-
const typedAt =
|
|
963
|
+
const typedAt = typedAtChar(prevValue.current, value, caret)
|
|
933
964
|
prevValue.current = value
|
|
934
965
|
onTitle(value)
|
|
935
|
-
|
|
936
|
-
else followMention('title', value, caret)
|
|
966
|
+
mention.track('title', value, caret, typedAt)
|
|
937
967
|
}}
|
|
938
|
-
onKeyDown={(event) => {
|
|
968
|
+
onKeyDown={(event) => { mention.onKeyDown(event) }}
|
|
939
969
|
/>
|
|
940
970
|
<textarea
|
|
941
971
|
ref={textareaRef}
|
|
@@ -945,14 +975,13 @@ function TemplateEditor(props) {
|
|
|
945
975
|
onChange={(event) => {
|
|
946
976
|
const value = event.target.value
|
|
947
977
|
const caret = event.target.selectionStart || 0
|
|
948
|
-
const typedAt =
|
|
978
|
+
const typedAt = typedAtChar(prevValue.current, value, caret)
|
|
949
979
|
prevValue.current = value
|
|
950
980
|
onText(value)
|
|
951
|
-
|
|
952
|
-
else followMention('text', value, caret)
|
|
981
|
+
mention.track('text', value, caret, typedAt)
|
|
953
982
|
}}
|
|
954
983
|
onKeyDown={(event) => {
|
|
955
|
-
if (
|
|
984
|
+
if (mention.onKeyDown(event)) return
|
|
956
985
|
if (event.isComposing || event.repeat) return
|
|
957
986
|
const key = String(event.key || '').toLowerCase()
|
|
958
987
|
// Alt+V(两键最省力);Ctrl+Alt+V 是备用(输入法拦 Alt 时用)。
|
|
@@ -975,14 +1004,14 @@ function TemplateEditor(props) {
|
|
|
975
1004
|
<button type="button" className="zps-btn" onClick={onCancel}>取消</button>
|
|
976
1005
|
<button type="button" className="zps-btn primary" onClick={onSave}>保存</button>
|
|
977
1006
|
</div>
|
|
978
|
-
{menu
|
|
1007
|
+
{mention.menu
|
|
979
1008
|
? (
|
|
980
1009
|
<MentionMenu
|
|
981
|
-
rows={
|
|
982
|
-
active={activeSection}
|
|
983
|
-
onSelect={(key) => { setActiveSection(key); setChosen(0) }}
|
|
984
|
-
chosen={chosen}
|
|
985
|
-
onPick={
|
|
1010
|
+
rows={mention.rows}
|
|
1011
|
+
active={mention.activeSection}
|
|
1012
|
+
onSelect={(key) => { mention.setActiveSection(key); mention.setChosen(0) }}
|
|
1013
|
+
chosen={mention.chosen}
|
|
1014
|
+
onPick={(row) => mention.pick(row)}
|
|
986
1015
|
/>
|
|
987
1016
|
)
|
|
988
1017
|
: null}
|
|
@@ -990,6 +1019,108 @@ function TemplateEditor(props) {
|
|
|
990
1019
|
)
|
|
991
1020
|
}
|
|
992
1021
|
|
|
1022
|
+
/**
|
|
1023
|
+
* 卡片正文里的一个变量输入框:直接填值;打 @ 也能选工作区文件、对话、插件。
|
|
1024
|
+
* 候选浮层 portal 到 document.body——卡片列表是滚动容器,就地渲染会被裁掉。
|
|
1025
|
+
* @param props.value - 当前取值。
|
|
1026
|
+
* @param props.name - 变量名(占位文字)。
|
|
1027
|
+
* @param props.fallback - 默认值。
|
|
1028
|
+
* @param props.onChange - 值变化。
|
|
1029
|
+
* @param props.listFiles - @ 候选来源。
|
|
1030
|
+
*/
|
|
1031
|
+
function VarField(props) {
|
|
1032
|
+
const value = String(props.value || '')
|
|
1033
|
+
const onChange = props.onChange
|
|
1034
|
+
const inputRef = useRef(null)
|
|
1035
|
+
const prevValue = useRef(value)
|
|
1036
|
+
/** 候选浮层的位置:相对视口的 fixed 坐标。 */
|
|
1037
|
+
const [anchor, setAnchor] = useState(null)
|
|
1038
|
+
|
|
1039
|
+
/** 选中一行:把「@查询段」换成 row.mention。 */
|
|
1040
|
+
const applyMention = useCallback((row, menu) => {
|
|
1041
|
+
const current = String(props.value || '')
|
|
1042
|
+
const next = current.slice(0, menu.start) + row.mention + current.slice(menu.end)
|
|
1043
|
+
onChange(next)
|
|
1044
|
+
prevValue.current = next
|
|
1045
|
+
const caret = menu.start + row.mention.length
|
|
1046
|
+
window.requestAnimationFrame(() => {
|
|
1047
|
+
const el = inputRef.current
|
|
1048
|
+
if (!el) return
|
|
1049
|
+
el.focus()
|
|
1050
|
+
el.setSelectionRange(caret, caret)
|
|
1051
|
+
})
|
|
1052
|
+
}, [onChange, props.value])
|
|
1053
|
+
|
|
1054
|
+
const mention = useMentionField(props.listFiles, applyMention)
|
|
1055
|
+
const open = mention.menu !== null
|
|
1056
|
+
|
|
1057
|
+
/** 菜单打开时按输入框的位置摆浮层:下方放不下就翻到上方。 */
|
|
1058
|
+
useEffect(() => {
|
|
1059
|
+
if (!open) {
|
|
1060
|
+
setAnchor(null)
|
|
1061
|
+
return undefined
|
|
1062
|
+
}
|
|
1063
|
+
const place = () => {
|
|
1064
|
+
const el = inputRef.current
|
|
1065
|
+
if (!el) return
|
|
1066
|
+
const rect = el.getBoundingClientRect()
|
|
1067
|
+
const width = Math.max(280, Math.min(420, rect.width + 240))
|
|
1068
|
+
const height = 248
|
|
1069
|
+
const below = rect.bottom + 4 + height <= window.innerHeight
|
|
1070
|
+
setAnchor({
|
|
1071
|
+
left: Math.max(8, Math.min(rect.left, window.innerWidth - width - 8)),
|
|
1072
|
+
top: below ? rect.bottom + 4 : Math.max(8, rect.top - 4 - height),
|
|
1073
|
+
width,
|
|
1074
|
+
})
|
|
1075
|
+
}
|
|
1076
|
+
place()
|
|
1077
|
+
window.addEventListener('resize', place)
|
|
1078
|
+
window.addEventListener('scroll', place, true)
|
|
1079
|
+
return () => {
|
|
1080
|
+
window.removeEventListener('resize', place)
|
|
1081
|
+
window.removeEventListener('scroll', place, true)
|
|
1082
|
+
}
|
|
1083
|
+
}, [open])
|
|
1084
|
+
|
|
1085
|
+
return (
|
|
1086
|
+
<div className="zps-varwrap">
|
|
1087
|
+
<input
|
|
1088
|
+
ref={inputRef}
|
|
1089
|
+
className="zps-var"
|
|
1090
|
+
value={value}
|
|
1091
|
+
placeholder={props.name}
|
|
1092
|
+
title={'变量:' + props.name + '(默认值:' + (props.fallback || '空') + ';打 @ 可引用工作区文件)'}
|
|
1093
|
+
onMouseDown={(event) => event.stopPropagation()}
|
|
1094
|
+
onClick={(event) => event.stopPropagation()}
|
|
1095
|
+
onBlur={() => mention.close()}
|
|
1096
|
+
onChange={(event) => {
|
|
1097
|
+
const next = event.target.value
|
|
1098
|
+
const caret = event.target.selectionStart || 0
|
|
1099
|
+
const typedAt = typedAtChar(prevValue.current, next, caret)
|
|
1100
|
+
prevValue.current = next
|
|
1101
|
+
onChange(next)
|
|
1102
|
+
mention.track('var', next, caret, typedAt)
|
|
1103
|
+
}}
|
|
1104
|
+
onKeyDown={(event) => mention.onKeyDown(event)}
|
|
1105
|
+
/>
|
|
1106
|
+
{open && anchor
|
|
1107
|
+
? createPortal(
|
|
1108
|
+
<div className="zps-varmenu" style={{ left: anchor.left, top: anchor.top, width: anchor.width }}>
|
|
1109
|
+
<MentionMenu
|
|
1110
|
+
rows={mention.rows}
|
|
1111
|
+
active={mention.activeSection}
|
|
1112
|
+
onSelect={(key) => { mention.setActiveSection(key); mention.setChosen(0) }}
|
|
1113
|
+
chosen={mention.chosen}
|
|
1114
|
+
onPick={(row) => mention.pick(row)}
|
|
1115
|
+
/>
|
|
1116
|
+
</div>,
|
|
1117
|
+
document.body,
|
|
1118
|
+
)
|
|
1119
|
+
: null}
|
|
1120
|
+
</div>
|
|
1121
|
+
)
|
|
1122
|
+
}
|
|
1123
|
+
|
|
993
1124
|
/**
|
|
994
1125
|
* 输入栏下方的入口与独立大窗口(窗口内一条提示词一张卡片)。
|
|
995
1126
|
* @param props - slot 标准 props:useInput(草稿选择器)与 inputActions(输入动作)。
|
|
@@ -1397,15 +1528,13 @@ export function PromptShelf(props) {
|
|
|
1397
1528
|
part.type === 'text' ? (
|
|
1398
1529
|
<span key={index}>{part.value}</span>
|
|
1399
1530
|
) : (
|
|
1400
|
-
<
|
|
1531
|
+
<VarField
|
|
1401
1532
|
key={index}
|
|
1402
|
-
className="zps-var"
|
|
1403
1533
|
value={templateValue(varValues[item.id], part.name, part.fallback)}
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
onChange={(event) => setVar(item.id, part.name, event.target.value)}
|
|
1534
|
+
name={part.name}
|
|
1535
|
+
fallback={part.fallback}
|
|
1536
|
+
onChange={(value) => setVar(item.id, part.name, value)}
|
|
1537
|
+
listFiles={props.listFiles}
|
|
1409
1538
|
/>
|
|
1410
1539
|
)
|
|
1411
1540
|
))}
|