xiaolongmr commited on
Commit
e510d0d
·
1 Parent(s): 0d06d34

提示词引用参考图:@ 随手可唤出 + 手打「图N」自动补空格

Browse files

- @ 候选菜单改成光标前有 @ 就唤出,不再要求前面是空格或行首。
参考项目 infinite-canvas 用的是 (^|\s)@,这里放宽成 @([^\s@]*)$,
「把@」「中间@」都能直接唤出,插入位置仍按 @ 的起点算。
- 手动打完「图N」后自动补一个空格,跟 @ 选中插入的「图N 」一致,
接着写文字不会和标签粘在一起。
· 参考图 ≥10 张时「图1」可能是「图12」的前缀,此时等用户打完数字再补,
避免把「图12」拆成「图1 2」。
· 标签后面已经紧跟汉字(如「图2的」)时在标签与汉字之间插入,不影响语义。
· 越界编号(图9 而只有 2 张图)不补空格,保持纯文本。
· 组字期间(isComposing)不插空格,避免打断 IME 的拼音串。
- 真机验证 artifacts/prompt-refs/at-check.sh:
把@ / 中间@ / @ / 已有文字后面@ 四种都能唤出候选;
用图1 → 用图1 ;用图2的 → 用图2 的;用图9 不动;@城市 只剩图2。

web/src/components/prompt-composer.tsx CHANGED
@@ -429,6 +429,31 @@ function escapeRegExp(value: string) {
429
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
430
  }
431
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  /**
433
  * 退格 / 删除把「图1」当成一个整体删掉。
434
  *
@@ -590,12 +615,13 @@ export const PromptComposerTextarea = forwardRef<HTMLTextAreaElement, PromptComp
590
  setMentionActiveIndex(next);
591
  };
592
 
593
- /** 光标正好在一段「@xxx」后面时,才认为在挑参考图。 */
594
  const syncMention = (nextValue: string, cursor: number) => {
595
  let next: MentionState | null = null;
596
  if (referenceList.length > 0) {
597
- const match = /(^|\s)@([^\s@]*)$/.exec(nextValue.slice(0, cursor));
598
- if (match) next = { start: cursor - match[2].length - 1, query: match[2] };
 
599
  }
600
  const current = mentionRef.current;
601
  if (!next && !current) return;
@@ -722,8 +748,23 @@ export const PromptComposerTextarea = forwardRef<HTMLTextAreaElement, PromptComp
722
  style={style}
723
  onChange={(event) => {
724
  const next = event.target.value;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
725
  onValueChange(next);
726
- syncMention(next, event.target.selectionStart);
727
  }}
728
  onPaste={onPaste}
729
  onCompositionStart={() => setIsComposing(true)}
 
429
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
430
  }
431
 
432
+ /**
433
+ * 手动打完「图N」后自动补一个空格,跟 @ 选中插入的「图N 」保持一致,
434
+ * 不然接着写文字会粘成一团。
435
+ *
436
+ * 只在标签没有歧义时补:参考图超过 9 张时「图1」可能只是「图12」的前缀,
437
+ * 得等用户把数字打完。1~9 张的时候不存在这个问题,直接补。
438
+ */
439
+ function autoSpaceAfterReferenceLabel(
440
+ value: string,
441
+ caret: number,
442
+ labels: string[],
443
+ ): { value: string; caret: number } | null {
444
+ const before = value.slice(0, caret);
445
+ const after = value.slice(caret);
446
+ if (/^\s/.test(after)) return null; // 后面已经有空白了
447
+ // labels 已按长度倒序,先用长的匹配,避免「图12」被拆成「图1」+「2」。
448
+ const label = labels.find((item) => item && before.endsWith(item));
449
+ if (!label) return null;
450
+ // 还有更长的标签以它开头 → 数字没打完,先别动。
451
+ if (labels.some((other) => other !== label && other.length > label.length && other.startsWith(label))) {
452
+ return null;
453
+ }
454
+ return { value: `${before} ${after}`, caret: caret + 1 };
455
+ }
456
+
457
  /**
458
  * 退格 / 删除把「图1」当成一个整体删掉。
459
  *
 
615
  setMentionActiveIndex(next);
616
  };
617
 
618
+ /** 光标前有一段「@xxx」时,才认为在挑参考图。前面不需要空格(行首/词中都能唤出)。 */
619
  const syncMention = (nextValue: string, cursor: number) => {
620
  let next: MentionState | null = null;
621
  if (referenceList.length > 0) {
622
+ // 只认紧贴光标的这一段:@ 后面连续的非空白非 @ 字符当作搜索词。
623
+ const match = /@([^\s@]*)$/.exec(nextValue.slice(0, cursor));
624
+ if (match) next = { start: cursor - match[1].length - 1, query: match[1] };
625
  }
626
  const current = mentionRef.current;
627
  if (!next && !current) return;
 
748
  style={style}
749
  onChange={(event) => {
750
  const next = event.target.value;
751
+ const caret = event.target.selectionStart;
752
+ // 组字期间不要插空格:IME 中间的拼音串会被打断。
753
+ const spaced = isComposing || referenceLabels.length === 0
754
+ ? null
755
+ : autoSpaceAfterReferenceLabel(next, caret, referenceLabels);
756
+ if (spaced) {
757
+ onValueChange(spaced.value);
758
+ syncMention(spaced.value, spaced.caret);
759
+ requestAnimationFrame(() => {
760
+ const element = textareaElementRef.current;
761
+ element?.focus();
762
+ element?.setSelectionRange(spaced.caret, spaced.caret);
763
+ });
764
+ return;
765
+ }
766
  onValueChange(next);
767
+ syncMention(next, caret);
768
  }}
769
  onPaste={onPaste}
770
  onCompositionStart={() => setIsComposing(true)}