openclaw-openagent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +114 -0
- package/openclaw.plugin.json +159 -0
- package/package.json +79 -0
- package/skills/clawlink/SKILL.md +145 -0
- package/skills/clawlink/SKILL.md.bak +165 -0
- package/src/app/channel-tools.ts +249 -0
- package/src/app/discovery-tools.ts +273 -0
- package/src/app/hooks.ts +60 -0
- package/src/app/index.ts +78 -0
- package/src/app/messaging-tools.ts +79 -0
- package/src/app/ops-tools.ts +155 -0
- package/src/app/remote-agent-tool.ts +476 -0
- package/src/app/types.ts +67 -0
- package/src/app/verbose-preflight.ts +190 -0
- package/src/auth/config.ts +197 -0
- package/src/auth/credential-manager.ts +146 -0
- package/src/auth/index.ts +24 -0
- package/src/auth/verify.ts +99 -0
- package/src/channel.ts +565 -0
- package/src/compat.ts +82 -0
- package/src/config/config-schema.ts +39 -0
- package/src/messaging/aggregator.ts +120 -0
- package/src/messaging/collector.ts +89 -0
- package/src/messaging/executor.ts +72 -0
- package/src/messaging/inbound.ts +150 -0
- package/src/messaging/index.ts +11 -0
- package/src/messaging/mention-protocol.ts +94 -0
- package/src/messaging/process-c2c-request.ts +564 -0
- package/src/messaging/process-message.ts +373 -0
- package/src/messaging/scheduler.ts +55 -0
- package/src/messaging/types.ts +38 -0
- package/src/plugin-ui/assets/agentbook-icon.svg +5 -0
- package/src/plugin-ui/assets/magic.svg +5 -0
- package/src/plugin-ui/assets/openagent-override.js +9329 -0
- package/src/plugin-ui/build.cjs +175 -0
- package/src/plugin-ui/index.ts +18 -0
- package/src/plugin-ui/modules/agent-book/panel/agent-book.js +458 -0
- package/src/plugin-ui/modules/agent-book/panel/agent-card.js +154 -0
- package/src/plugin-ui/modules/agent-book/panel/agent-data.js +644 -0
- package/src/plugin-ui/modules/agent-book/panel/inject-ui.js +456 -0
- package/src/plugin-ui/modules/agent-book/panel/mention-state.js +206 -0
- package/src/plugin-ui/modules/agent-book/panel/styles.js +670 -0
- package/src/plugin-ui/modules/agent-book/remote-agent-tool/components-core.js +293 -0
- package/src/plugin-ui/modules/agent-book/remote-agent-tool/thought-chain-card.js +208 -0
- package/src/plugin-ui/modules/agent-book/scanner.js +119 -0
- package/src/plugin-ui/modules/agent-book/travelcard/travel-cards.js +500 -0
- package/src/plugin-ui/modules/agent-book/travelcard/travel-engine.js +652 -0
- package/src/plugin-ui/modules/agent-book/travelcard/travel-styles.js +251 -0
- package/src/plugin-ui/modules/loader/bootstrap.js +38 -0
- package/src/plugin-ui/modules/loader/shared-state.js +560 -0
- package/src/plugin-ui/modules/loader/ws-intercept.js +199 -0
- package/src/plugin-ui/modules/remote-agent/chunk-parser.js +161 -0
- package/src/plugin-ui/modules/remote-agent/execution-card.js +269 -0
- package/src/plugin-ui/modules/remote-agent/markdown-renderer.js +256 -0
- package/src/plugin-ui/modules/remote-agent/native-style-adapter.js +146 -0
- package/src/plugin-ui/modules/remote-agent/output-card.js +259 -0
- package/src/plugin-ui/modules/remote-agent/progress-store.js +363 -0
- package/src/plugin-ui/modules/remote-agent/render-hooks.js +1609 -0
- package/src/plugin-ui/modules/remote-agent/styles.js +668 -0
- package/src/plugin-ui/modules/remote-agent/tool-card-model.js +56 -0
- package/src/plugin-ui/ui-extension-loader/backup.ts +92 -0
- package/src/plugin-ui/ui-extension-loader/index.ts +276 -0
- package/src/plugin-ui/ui-extension-loader/locator.ts +152 -0
- package/src/plugin-ui/ui-extension-loader/manifest.ts +57 -0
- package/src/plugin-ui/ui-extension-loader/registry-regex.ts +729 -0
- package/src/plugin-ui/ui-extension-loader/removed-extensions.ts +70 -0
- package/src/plugin-ui/ui-extension-loader/types.ts +68 -0
- package/src/proxy/auth-proxy.ts +356 -0
- package/src/runtime/account.ts +572 -0
- package/src/runtime/index.ts +7 -0
- package/src/runtime/plugin-runtime.ts +94 -0
- package/src/runtime/registry.ts +71 -0
- package/src/sdk/CLASS_MAP.md +143 -0
- package/src/sdk/index.d.ts +126 -0
- package/src/sdk/index.js +23990 -0
- package/src/sdk/modules/cloud-search-module.js +1117 -0
- package/src/sdk/modules/follow-module.js +1069 -0
- package/src/sdk/modules/group-module.js +7397 -0
- package/src/sdk/modules/relationship-module.js +2269 -0
- package/src/sdk/modules/signaling-module.js +1468 -0
- package/src/sdk/modules/tim-upload-plugin.js +730 -0
- package/src/sdk/node-env/http-request.js +90 -0
- package/src/sdk/node-env/index.js +57 -0
- package/src/sdk/node-env/storage.js +114 -0
- package/src/sdk/package.json +10 -0
- package/src/sdk/tsconfig.json +16 -0
- package/src/state/pending-invocation-store.ts +43 -0
- package/src/state/store.ts +676 -0
- package/src/tim/c2c.ts +451 -0
- package/src/tim/channels.ts +364 -0
- package/src/tim/client.ts +330 -0
- package/src/tim/index.ts +18 -0
- package/src/tim/messages.ts +166 -0
- package/src/tim/sdk-logger-init.ts +50 -0
- package/src/tools.ts +10 -0
- package/src/transport/factory.ts +95 -0
- package/src/transport/oasn/index.ts +17 -0
- package/src/transport/oasn/oasn-agent-card.ts +111 -0
- package/src/transport/oasn/oasn-discovery.ts +108 -0
- package/src/transport/oasn/oasn-files.ts +210 -0
- package/src/transport/oasn/oasn-http.ts +483 -0
- package/src/transport/oasn/oasn-invocation.ts +527 -0
- package/src/transport/oasn/oasn-normalize.ts +159 -0
- package/src/transport/oasn/oasn-register.ts +106 -0
- package/src/transport/oasn/oasn-transport.ts +341 -0
- package/src/transport/oasn/oasn-types.ts +353 -0
- package/src/transport/tim/index.ts +8 -0
- package/src/transport/tim/tim-transport.ts +515 -0
- package/src/transport/types.ts +541 -0
- package/src/types/openclaw.d.ts +97 -0
- package/src/types/tencentcloud-chat.d.ts +15 -0
- package/src/util/http.ts +113 -0
- package/src/util/logger.ts +131 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
|
|
2
|
+
// ── 注入逻辑(v3 — OC handleKeyDown/handleInput 钩子驱动)──
|
|
3
|
+
//
|
|
4
|
+
// 事件处理策略:
|
|
5
|
+
// keydown / input → 由 OC 注入钩子(mention-hooks UIExtension)调用 window.__clMention
|
|
6
|
+
// selectionchange → document 级事件委托(不绑 textarea,不怕 Lit 重建)
|
|
7
|
+
// beforeinput → document 级事件委托(同上)
|
|
8
|
+
// compositionstart/end → document 级事件委托(同上)
|
|
9
|
+
//
|
|
10
|
+
// tryInject() 只做 DOM 注入(@ 按钮 + CSS class),不绑定任何事件。
|
|
11
|
+
|
|
12
|
+
// ── textarea 选择器(全局复用)──
|
|
13
|
+
const _CL_TEXTAREA_SELECTOR = 'textarea[placeholder*="Message"], textarea[placeholder*="Enter to send"]';
|
|
14
|
+
|
|
15
|
+
function _getTextarea() {
|
|
16
|
+
return document.querySelector(_CL_TEXTAREA_SELECTOR);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function _setTextareaValue(textarea, value) {
|
|
20
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
|
21
|
+
if (setter) setter.call(textarea, value);
|
|
22
|
+
else textarea.value = value;
|
|
23
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function _buildRemoteAgentMarker(mention, userText) {
|
|
27
|
+
const hasChinese = /[\u4e00-\u9fff]/.test(userText || '');
|
|
28
|
+
const instruction = hasChinese
|
|
29
|
+
? '请调用 openagent_call_remote_agent 工具将此请求转发给远端 Agent 处理'
|
|
30
|
+
: 'Please invoke the openagent_call_remote_agent tool to forward this request to the remote agent';
|
|
31
|
+
return `{openagent_call_remote_agent:agent_id="${mention.agent_id}",agent_name="${mention.name}",instruction="${instruction}"}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _composeRemoteAgentMessage(textarea, mention) {
|
|
35
|
+
const currentVal = textarea.value || '';
|
|
36
|
+
const marker = _buildRemoteAgentMarker(mention, currentVal);
|
|
37
|
+
const escapedName = mention.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
+
const atPattern = new RegExp('@' + escapedName + '\\s*');
|
|
39
|
+
return {
|
|
40
|
+
currentVal,
|
|
41
|
+
marker,
|
|
42
|
+
message: currentVal.replace(atPattern, marker + ' ')
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _isLikelyNativeSendButton(target, textarea) {
|
|
47
|
+
const button = target && target.closest ? target.closest('button, [role="button"]') : null;
|
|
48
|
+
if (!button || !textarea) return false;
|
|
49
|
+
if (button.classList && button.classList.contains('openagent-at-btn')) return false;
|
|
50
|
+
|
|
51
|
+
const inputContainer = textarea.closest('.chat-input')
|
|
52
|
+
|| textarea.closest('[class*="chat-composer"]')
|
|
53
|
+
|| textarea.closest('[class*="chat"]')
|
|
54
|
+
|| textarea.parentElement;
|
|
55
|
+
if (!inputContainer || !inputContainer.contains(button)) return false;
|
|
56
|
+
|
|
57
|
+
const label = [
|
|
58
|
+
button.getAttribute('aria-label'),
|
|
59
|
+
button.getAttribute('title'),
|
|
60
|
+
button.textContent
|
|
61
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
62
|
+
if (/send|发送/.test(label)) return true;
|
|
63
|
+
|
|
64
|
+
const buttons = Array.from(inputContainer.querySelectorAll('button, [role="button"]'))
|
|
65
|
+
.filter(function(btn) {
|
|
66
|
+
if (btn.classList && btn.classList.contains('openagent-at-btn')) return false;
|
|
67
|
+
if (btn.disabled || btn.getAttribute('aria-disabled') === 'true') return false;
|
|
68
|
+
return btn.offsetParent !== null;
|
|
69
|
+
});
|
|
70
|
+
return buttons.length > 0 && buttons[buttons.length - 1] === button;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _sendPendingMention(textarea) {
|
|
74
|
+
if (!textarea || !CL.pendingMention) return false;
|
|
75
|
+
if (CL.remoteSendInFlight) return true;
|
|
76
|
+
|
|
77
|
+
const mention = CL.pendingMention;
|
|
78
|
+
const payload = _composeRemoteAgentMessage(textarea, mention);
|
|
79
|
+
CL.remoteSendInFlight = true;
|
|
80
|
+
|
|
81
|
+
console.log('[cl-diag][send-marker]', {
|
|
82
|
+
agentId: mention.agent_id,
|
|
83
|
+
agentName: mention.name,
|
|
84
|
+
userTextPreview: payload.currentVal.slice(0, 200),
|
|
85
|
+
markerPreview: payload.marker.slice(0, 240),
|
|
86
|
+
sendValuePreview: payload.message.slice(0, 260),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// 不把内部 marker 写入 textarea。可见输入框按原生发送行为立即清空;
|
|
90
|
+
// 真正发给 OpenClaw 的带 marker 文本只作为 handleSendChat 参数传入。
|
|
91
|
+
_setTextareaValue(textarea, '');
|
|
92
|
+
|
|
93
|
+
(async () => {
|
|
94
|
+
try {
|
|
95
|
+
CL.lastMentionAgentId = mention.agent_id;
|
|
96
|
+
CL.pendingMention = null;
|
|
97
|
+
await CL.sendRemoteAgentMessage(payload.message);
|
|
98
|
+
console.log('[openagent] remote call sent');
|
|
99
|
+
} catch (err) {
|
|
100
|
+
CL.pendingMention = mention;
|
|
101
|
+
_setTextareaValue(textarea, payload.currentVal);
|
|
102
|
+
console.error('[openagent] remote call send failed', err);
|
|
103
|
+
window.alert?.('OpenAgent remote call 未发送:请检查 gateway 连接后重试。');
|
|
104
|
+
} finally {
|
|
105
|
+
CL.remoteSendInFlight = false;
|
|
106
|
+
}
|
|
107
|
+
})();
|
|
108
|
+
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _installSendClickInterceptor() {
|
|
113
|
+
if (window.__openagentSendClickInterceptorInstalled) return;
|
|
114
|
+
window.__openagentSendClickInterceptorInstalled = true;
|
|
115
|
+
document.addEventListener('click', function(e) {
|
|
116
|
+
if (!CL.pendingMention || CL.remoteSendInFlight) return;
|
|
117
|
+
const textarea = _getTextarea();
|
|
118
|
+
if (!_isLikelyNativeSendButton(e.target, textarea)) return;
|
|
119
|
+
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
e.stopPropagation();
|
|
122
|
+
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
|
|
123
|
+
_sendPendingMention(textarea);
|
|
124
|
+
}, true);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function tryInject() {
|
|
128
|
+
const textarea = _getTextarea();
|
|
129
|
+
|
|
130
|
+
if (!textarea) return false;
|
|
131
|
+
_installSendClickInterceptor();
|
|
132
|
+
if (document.querySelector('.openagent-at-btn')) return true; // 已注入
|
|
133
|
+
|
|
134
|
+
// ── 定位插入点:麦克风图标所在的 toolbar 行 ──
|
|
135
|
+
const textareaParent = textarea.parentElement;
|
|
136
|
+
let toolbarRow = null;
|
|
137
|
+
|
|
138
|
+
if (textareaParent) {
|
|
139
|
+
for (const child of textareaParent.children) {
|
|
140
|
+
if (child !== textarea && child.querySelector('svg')) {
|
|
141
|
+
toolbarRow = child;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!toolbarRow && textareaParent) {
|
|
147
|
+
const grandparent = textareaParent.parentElement;
|
|
148
|
+
if (grandparent) {
|
|
149
|
+
for (const child of grandparent.children) {
|
|
150
|
+
if (child !== textareaParent && child.querySelector('svg')) {
|
|
151
|
+
toolbarRow = child;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── 创建 Agent Book 按钮 ──
|
|
159
|
+
const atBtn = document.createElement('button');
|
|
160
|
+
atBtn.className = 'openagent-at-btn';
|
|
161
|
+
atBtn.innerHTML = '<span class="at-sym">@</span> Agent Book';
|
|
162
|
+
atBtn.addEventListener('click', () => {
|
|
163
|
+
// 在 textarea 中插入 @ 字符,让状态机自然检测 → openPanel
|
|
164
|
+
const ta = _getTextarea();
|
|
165
|
+
if (!ta) return;
|
|
166
|
+
ta.focus();
|
|
167
|
+
const val = ta.value;
|
|
168
|
+
const pos = ta.selectionStart;
|
|
169
|
+
// 如果光标前已经是 @,不重复插入
|
|
170
|
+
if (pos > 0 && val[pos - 1] === '@') {
|
|
171
|
+
_handleStateChange(ta);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// 插入 @ 字符(前面加空格分隔,除非行首/空白后)
|
|
175
|
+
const needSpace = pos > 0 && !/[\s\n]/.test(val[pos - 1]);
|
|
176
|
+
const insert = needSpace ? ' @' : '@';
|
|
177
|
+
const newVal = val.slice(0, pos) + insert + val.slice(pos);
|
|
178
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
179
|
+
HTMLTextAreaElement.prototype, 'value'
|
|
180
|
+
)?.set;
|
|
181
|
+
if (setter) setter.call(ta, newVal);
|
|
182
|
+
else ta.value = newVal;
|
|
183
|
+
ta.selectionStart = ta.selectionEnd = pos + insert.length;
|
|
184
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
185
|
+
// input 事件 → OC handleInput → __clMention.onInput → 状态机打开面板
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (toolbarRow) {
|
|
189
|
+
const toolbarLeft = toolbarRow.querySelector('[class*="toolbar-left"]') || toolbarRow;
|
|
190
|
+
const iconButtons = toolbarLeft.querySelectorAll('button, [role="button"]');
|
|
191
|
+
let micBtn = null;
|
|
192
|
+
if (iconButtons.length >= 2) {
|
|
193
|
+
micBtn = iconButtons[1];
|
|
194
|
+
} else if (iconButtons.length === 1) {
|
|
195
|
+
micBtn = iconButtons[0];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (micBtn) {
|
|
199
|
+
micBtn.after(atBtn);
|
|
200
|
+
} else {
|
|
201
|
+
toolbarLeft.appendChild(atBtn);
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
(textareaParent || textarea.parentElement).appendChild(atBtn);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── 给输入区域加 position: relative 包裹层 ──
|
|
208
|
+
const inputContainer = textarea.closest('.chat-input')
|
|
209
|
+
|| textarea.closest('[class*="chat-composer"]')
|
|
210
|
+
|| textarea.closest('[class*="chat"]')
|
|
211
|
+
|| textareaParent?.parentElement
|
|
212
|
+
|| textareaParent;
|
|
213
|
+
if (inputContainer) {
|
|
214
|
+
inputContainer.classList.add('openagent-input-wrapper');
|
|
215
|
+
inputWrapperRef = inputContainer;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log('[openagent] ✅ Agent Book UI injected (v3 hook-driven)');
|
|
219
|
+
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ════════════════════════════════════════════════════════════════
|
|
224
|
+
// ── window.__clMention — OC 注入钩子的桥接对象 ──
|
|
225
|
+
// ════════════════════════════════════════════════════════════════
|
|
226
|
+
//
|
|
227
|
+
// OC 的 handleKeyDown 中注入了:
|
|
228
|
+
// if (window.__clMention?.onKeyDown(e)) return;
|
|
229
|
+
//
|
|
230
|
+
// OC 的 handleInput 中注入了:
|
|
231
|
+
// window.__clMention?.onInput?.(target);
|
|
232
|
+
//
|
|
233
|
+
// 所有业务逻辑在此处理,注入端只做调用转发。
|
|
234
|
+
|
|
235
|
+
window.__clMention = {
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* OC handleKeyDown 钩子。
|
|
239
|
+
* 返回 true → OC 跳过后续处理(包括 Enter 发送)。
|
|
240
|
+
* 返回 false → OC 继续正常处理。
|
|
241
|
+
*/
|
|
242
|
+
onKeyDown(e) {
|
|
243
|
+
const textarea = e.target;
|
|
244
|
+
|
|
245
|
+
// ── 1. 面板打开时:键盘导航 ──
|
|
246
|
+
if (isActive()) {
|
|
247
|
+
switch (e.key) {
|
|
248
|
+
case 'ArrowDown':
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
moveSelectionDown();
|
|
251
|
+
return true;
|
|
252
|
+
|
|
253
|
+
case 'ArrowUp':
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
moveSelectionUp();
|
|
256
|
+
return true;
|
|
257
|
+
|
|
258
|
+
case 'Enter':
|
|
259
|
+
if (e.shiftKey) return false; // Shift+Enter = 换行,交给 OC
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
selectHighlightedAgent(textarea);
|
|
262
|
+
return true; // 阻止 OC 发送
|
|
263
|
+
|
|
264
|
+
case 'Escape':
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
setDismissed();
|
|
267
|
+
closePanel();
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
// 其他键(字符输入):不拦截,让 OC 继续 → handleInput → onInput
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── 2. Backspace 原子删除 @mention ──
|
|
275
|
+
// 对标 TIM mention.ts:321-356
|
|
276
|
+
// 整体删除 @AgentName(含 @ 符号),不保留 @,不重新弹出面板
|
|
277
|
+
if (e.key === 'Backspace' && CL.pendingMention) {
|
|
278
|
+
const val = textarea.value;
|
|
279
|
+
const pos = textarea.selectionStart;
|
|
280
|
+
const name = CL.pendingMention.name;
|
|
281
|
+
const mention = '@' + name;
|
|
282
|
+
|
|
283
|
+
const searchStart = Math.max(0, pos - mention.length - 1);
|
|
284
|
+
const nearText = val.substring(searchStart, pos);
|
|
285
|
+
const idx = nearText.lastIndexOf(mention);
|
|
286
|
+
|
|
287
|
+
if (idx !== -1) {
|
|
288
|
+
const mentionStart = searchStart + idx;
|
|
289
|
+
let mentionEnd = mentionStart + mention.length;
|
|
290
|
+
if (val[mentionEnd] === ' ') mentionEnd++; // 包含尾部空格
|
|
291
|
+
|
|
292
|
+
if (pos > mentionStart && pos <= mentionEnd) {
|
|
293
|
+
e.preventDefault();
|
|
294
|
+
// 整体删除 @AgentName(含 @ 符号)
|
|
295
|
+
const newVal = val.slice(0, mentionStart) + val.slice(mentionEnd);
|
|
296
|
+
|
|
297
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
298
|
+
HTMLTextAreaElement.prototype, 'value'
|
|
299
|
+
)?.set;
|
|
300
|
+
if (setter) setter.call(textarea, newVal);
|
|
301
|
+
else textarea.value = newVal;
|
|
302
|
+
|
|
303
|
+
// 光标放在删除位置
|
|
304
|
+
textarea.selectionStart = textarea.selectionEnd = mentionStart;
|
|
305
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
306
|
+
CL.pendingMention = null;
|
|
307
|
+
// @ 已被删除,状态机不会检测到 @ → 面板不会弹出
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── 3. Enter 发送时:标记替换(面板关闭 + 有 pendingMention)──
|
|
314
|
+
// 这里接管原生发送,只是为了把可见 @mention 替换成 OpenAgent 内部 marker。
|
|
315
|
+
if (e.key === 'Enter' && !e.shiftKey && CL.pendingMention) {
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
_sendPendingMention(textarea);
|
|
318
|
+
|
|
319
|
+
// 返回 true:OC 注入钩子会停止原生 Enter 发送,避免重复发送。
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return false;
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* OC handleInput 钩子。
|
|
328
|
+
* 在 OC 的 props.onDraftChange(target.value) 之后调用。
|
|
329
|
+
*/
|
|
330
|
+
onInput(textarea) {
|
|
331
|
+
_handleStateChange(textarea);
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
// ── 暴露给外部查询的辅助方法 ──
|
|
335
|
+
active() { return isActive(); },
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// ════════════════════════════════════════════════════════════════
|
|
339
|
+
// ── 事件委托(document 级,不怕 Lit 重建)──
|
|
340
|
+
// ════════════════════════════════════════════════════════════════
|
|
341
|
+
|
|
342
|
+
// ── 输入法组合状态跟踪 ──
|
|
343
|
+
// 对标 suggestion.ts:508 "editor.view.composing"
|
|
344
|
+
// 输入法组合中的 @ 不应触发面板(§1.1 ⑥)
|
|
345
|
+
document.addEventListener('compositionstart', (e) => {
|
|
346
|
+
if (e.target?.matches?.(_CL_TEXTAREA_SELECTOR)) {
|
|
347
|
+
setComposing(true);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
document.addEventListener('compositionend', (e) => {
|
|
351
|
+
if (e.target?.matches?.(_CL_TEXTAREA_SELECTOR)) {
|
|
352
|
+
setComposing(false);
|
|
353
|
+
// 输入法确认后可能产生 @,OC 的 handleInput 会触发 onInput
|
|
354
|
+
// 但 compositionend 在 input 之前触发,需要主动评估一次
|
|
355
|
+
_handleStateChange(e.target);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ── 光标变化检测 ──
|
|
360
|
+
// 对标 suggestion.ts:473 "选区变化也产生 transaction"
|
|
361
|
+
// 实现 §1.3 ⑤ "点击输入框内其他位置 → 面板关闭"
|
|
362
|
+
let _guardSelection = false;
|
|
363
|
+
let _lastCursorPos = -1; // 追踪上次光标位置,用于判断进入 mention 的方向
|
|
364
|
+
document.addEventListener('selectionchange', () => {
|
|
365
|
+
const textarea = _getTextarea();
|
|
366
|
+
if (!textarea || document.activeElement !== textarea) return;
|
|
367
|
+
if (_guardSelection) return;
|
|
368
|
+
|
|
369
|
+
// mention 光标保护(§1.6 原子标签)
|
|
370
|
+
// 对标 TIM:左键从右侧进入 → 跳到 @ 之前;右键从左侧进入 → 跳到末尾
|
|
371
|
+
const mentionRange = _findMentionRange(textarea.value);
|
|
372
|
+
if (mentionRange) {
|
|
373
|
+
const pos = textarea.selectionStart;
|
|
374
|
+
if (pos > mentionRange.start && pos < mentionRange.end &&
|
|
375
|
+
textarea.selectionStart === textarea.selectionEnd) {
|
|
376
|
+
_guardSelection = true;
|
|
377
|
+
if (_lastCursorPos >= 0 && _lastCursorPos >= mentionRange.end) {
|
|
378
|
+
// 箭头左键从右侧进入 → 跳到 @ 之前
|
|
379
|
+
textarea.selectionStart = textarea.selectionEnd = mentionRange.start;
|
|
380
|
+
} else if (_lastCursorPos >= 0 && _lastCursorPos <= mentionRange.start) {
|
|
381
|
+
// 箭头右键从左侧进入 → 跳到末尾
|
|
382
|
+
textarea.selectionStart = textarea.selectionEnd = mentionRange.end;
|
|
383
|
+
} else {
|
|
384
|
+
// 鼠标点击跳入 或 _lastCursorPos 未知(-1) → 跳到最近边界
|
|
385
|
+
const distToStart = pos - mentionRange.start;
|
|
386
|
+
const distToEnd = mentionRange.end - pos;
|
|
387
|
+
textarea.selectionStart = textarea.selectionEnd =
|
|
388
|
+
distToStart <= distToEnd ? mentionRange.start : mentionRange.end;
|
|
389
|
+
}
|
|
390
|
+
_lastCursorPos = textarea.selectionStart;
|
|
391
|
+
_guardSelection = false;
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
_lastCursorPos = textarea.selectionStart;
|
|
397
|
+
// 状态机评估(光标移走 → 可能关闭面板)
|
|
398
|
+
_handleStateChange(textarea);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// ── mention 原子保护:阻止在 @mention 内部输入字符(§1.6)──
|
|
402
|
+
// 事件委托:OC 不监听 beforeinput,不会冲突
|
|
403
|
+
document.addEventListener('beforeinput', (e) => {
|
|
404
|
+
if (!e.target?.matches?.(_CL_TEXTAREA_SELECTOR)) return;
|
|
405
|
+
const textarea = e.target;
|
|
406
|
+
const mentionRange = _findMentionRange(textarea.value);
|
|
407
|
+
if (!mentionRange) return;
|
|
408
|
+
const selStart = textarea.selectionStart;
|
|
409
|
+
const selEnd = textarea.selectionEnd;
|
|
410
|
+
// 编辑范围与 mention 重叠 → 阻止
|
|
411
|
+
if (selStart > mentionRange.start && selStart < mentionRange.end) {
|
|
412
|
+
e.preventDefault();
|
|
413
|
+
} else if (selEnd > mentionRange.start && selEnd < mentionRange.end) {
|
|
414
|
+
e.preventDefault();
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// ── 辅助函数 ──
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* 统一的状态变化处理:调用状态机 → 根据结果操作面板。
|
|
422
|
+
*/
|
|
423
|
+
function _handleStateChange(textarea) {
|
|
424
|
+
const val = textarea.value;
|
|
425
|
+
const result = evaluateState(val, textarea.selectionStart, textarea.selectionEnd);
|
|
426
|
+
|
|
427
|
+
switch (result.action) {
|
|
428
|
+
case 'open':
|
|
429
|
+
openPanel(textarea);
|
|
430
|
+
break;
|
|
431
|
+
case 'update':
|
|
432
|
+
updatePanelSearch(textarea);
|
|
433
|
+
break;
|
|
434
|
+
case 'close':
|
|
435
|
+
closePanel();
|
|
436
|
+
break;
|
|
437
|
+
// 'none': 不做任何操作
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 找到 textarea.value 中当前 mention 的范围。
|
|
443
|
+
* 用于原子保护(beforeinput / selectionchange)。
|
|
444
|
+
*
|
|
445
|
+
* @returns {{ start: number, end: number }|null}
|
|
446
|
+
*/
|
|
447
|
+
function _findMentionRange(value) {
|
|
448
|
+
if (!CL.pendingMention) return null;
|
|
449
|
+
const name = CL.pendingMention.name;
|
|
450
|
+
const mention = '@' + name;
|
|
451
|
+
const idx = value.lastIndexOf(mention);
|
|
452
|
+
if (idx === -1) return null;
|
|
453
|
+
let end = idx + mention.length;
|
|
454
|
+
if (value[end] === ' ') end++; // 包含尾部空格
|
|
455
|
+
return { start: idx, end };
|
|
456
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mention-state.js — @Mention 状态机
|
|
3
|
+
*
|
|
4
|
+
* 模拟 Tiptap suggestion.ts 的核心逻辑,适配原生 textarea。
|
|
5
|
+
* 纯逻辑模块,不依赖 DOM、不操作面板 UI。
|
|
6
|
+
*
|
|
7
|
+
* 参考:
|
|
8
|
+
* - TIM suggestion.ts apply() L508-568(状态计算)
|
|
9
|
+
* - TIM findSuggestionMatch.ts L37-77(@ 匹配)
|
|
10
|
+
* - TIM suggestion.ts L491, L543-563(dismissed 机制)
|
|
11
|
+
*
|
|
12
|
+
* 需求文档:024-B-at-mention-trigger-logic-v1.md
|
|
13
|
+
* §1.1 弹出条件(⑤光标范围 ⑥选区/输入法 ⑦dismissed)
|
|
14
|
+
* §1.3 关闭条件(③删@ ④空格 ⑤光标移走)
|
|
15
|
+
* §1.4 dismissed 机制
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ── 状态变量 ──
|
|
19
|
+
// 对标 TIM suggestion.ts 的 Plugin state
|
|
20
|
+
|
|
21
|
+
let _active = false; // 面板是否激活(suggestion.ts:474 prev.active / next.active)
|
|
22
|
+
let _anchorPos = -1; // @ 字符在 value 中的位置(suggestion.ts:558 next.range.from)
|
|
23
|
+
let _dismissedPos = null; // ESC 关闭时记录的 @ 位置(suggestion.ts:491 dismissedRange)
|
|
24
|
+
let _isComposing = false; // 输入法组合中(suggestion.ts:508 editor.view.composing)
|
|
25
|
+
|
|
26
|
+
// ── 前缀正则 ──
|
|
27
|
+
// CL 的前缀规则:空白、换行、CJK、全角标点
|
|
28
|
+
// 对标 inject-ui.js:110 现有逻辑,保持兼容
|
|
29
|
+
const PREFIX_RE = /[\s\n\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 在 value 中找光标前最近的有效 @ 位置。
|
|
33
|
+
*
|
|
34
|
+
* "有效" = @ 前面是允许的前缀(行首、空白、CJK、全角标点)。
|
|
35
|
+
* 从光标往前逐字符扫描,遇到空格则停止(不跨空格找 @)。
|
|
36
|
+
*
|
|
37
|
+
* 对标 findSuggestionMatch.ts:37-71
|
|
38
|
+
*
|
|
39
|
+
* @param {string} value - textarea.value
|
|
40
|
+
* @param {number} cursorPos - textarea.selectionStart
|
|
41
|
+
* @returns {number} @ 的位置索引,找不到返回 -1
|
|
42
|
+
*/
|
|
43
|
+
function findAtAnchor(value, cursorPos) {
|
|
44
|
+
for (let i = cursorPos - 1; i >= 0; i--) {
|
|
45
|
+
const ch = value[i];
|
|
46
|
+
if (ch === '@') {
|
|
47
|
+
// 行首 或 前一字符满足前缀条件
|
|
48
|
+
if (i === 0 || PREFIX_RE.test(value[i - 1])) {
|
|
49
|
+
return i;
|
|
50
|
+
}
|
|
51
|
+
// 前缀不合法(如 "hello@"),继续往前找?不,直接返回 -1
|
|
52
|
+
// 因为更前面的 @ 已经跟当前光标不连续了
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
55
|
+
// @ 到光标之间只允许非空白字符(query 部分)
|
|
56
|
+
// 遇到空白就停止——说明光标已经离开 @xxx 范围
|
|
57
|
+
if (/\s/.test(ch)) return -1;
|
|
58
|
+
}
|
|
59
|
+
return -1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 核心函数:评估当前 textarea 状态,决定面板动作。
|
|
64
|
+
*
|
|
65
|
+
* 每次 textarea 内容变化 或 光标移动 时调用。
|
|
66
|
+
* 对标 suggestion.ts apply() L508-568。
|
|
67
|
+
*
|
|
68
|
+
* @param {string} value - textarea.value
|
|
69
|
+
* @param {number} cursorPos - textarea.selectionStart
|
|
70
|
+
* @param {number} selectionEnd - textarea.selectionEnd
|
|
71
|
+
* @returns {{ action: 'open'|'update'|'close'|'none', query: string, anchorPos: number }}
|
|
72
|
+
*/
|
|
73
|
+
function evaluateState(value, cursorPos, selectionEnd) {
|
|
74
|
+
const result = { action: 'none', query: '', anchorPos: -1 };
|
|
75
|
+
|
|
76
|
+
// ── 条件 ⑥ 前半:输入法组合中 → 不处理 ──
|
|
77
|
+
// 对标 suggestion.ts:508 "empty || editor.view.composing"
|
|
78
|
+
if (_isComposing) {
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── 条件 ⑥ 后半:有文字选区 → 关闭 ──
|
|
83
|
+
if (cursorPos !== selectionEnd) {
|
|
84
|
+
if (_active) {
|
|
85
|
+
_active = false;
|
|
86
|
+
_anchorPos = -1;
|
|
87
|
+
return { action: 'close', query: '', anchorPos: -1 };
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── 全空检测:输入框清空 → 关闭 + 清 dismissed ──
|
|
93
|
+
if (value.trim() === '') {
|
|
94
|
+
_dismissedPos = null;
|
|
95
|
+
if (_active) {
|
|
96
|
+
_active = false;
|
|
97
|
+
_anchorPos = -1;
|
|
98
|
+
return { action: 'close', query: '', anchorPos: -1 };
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── 条件 ⑤:找光标前的有效 @ ──
|
|
104
|
+
const anchorPos = findAtAnchor(value, cursorPos);
|
|
105
|
+
|
|
106
|
+
if (anchorPos === -1) {
|
|
107
|
+
// 没有有效的 @ → 关闭 + 清 dismissed
|
|
108
|
+
// 对标 suggestion.ts:565 "if (!match) { next.dismissedRange = null }"
|
|
109
|
+
_dismissedPos = null;
|
|
110
|
+
if (_active) {
|
|
111
|
+
_active = false;
|
|
112
|
+
_anchorPos = -1;
|
|
113
|
+
return { action: 'close', query: '', anchorPos: -1 };
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── 提取 query(@ 后面到光标的文字)──
|
|
119
|
+
const query = value.slice(anchorPos + 1, cursorPos);
|
|
120
|
+
|
|
121
|
+
// ── 条件:@ 后有空格 → 关闭 ──
|
|
122
|
+
// 对标 findSuggestionMatch.ts:33 正则不匹配含空格
|
|
123
|
+
// 注意:query 中出现空格说明用户已经结束 @mention 输入
|
|
124
|
+
if (/\s/.test(query)) {
|
|
125
|
+
_dismissedPos = null;
|
|
126
|
+
if (_active) {
|
|
127
|
+
_active = false;
|
|
128
|
+
_anchorPos = -1;
|
|
129
|
+
return { action: 'close', query: '', anchorPos: -1 };
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── 条件 ⑦:dismissed 检查 ──
|
|
135
|
+
// 对标 suggestion.ts:543-563 "match.range.from === dismissedRange.from"
|
|
136
|
+
if (_dismissedPos !== null && _dismissedPos === anchorPos) {
|
|
137
|
+
// 同一个 @ 位置被 ESC 关闭过 → 不弹出
|
|
138
|
+
if (_active) {
|
|
139
|
+
_active = false;
|
|
140
|
+
_anchorPos = -1;
|
|
141
|
+
return { action: 'close', query: '', anchorPos: -1 };
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── 所有条件满足 → 打开或更新 ──
|
|
147
|
+
if (!_active) {
|
|
148
|
+
_active = true;
|
|
149
|
+
_anchorPos = anchorPos;
|
|
150
|
+
return { action: 'open', query, anchorPos };
|
|
151
|
+
} else {
|
|
152
|
+
_anchorPos = anchorPos;
|
|
153
|
+
return { action: 'update', query, anchorPos };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── 状态管理函数 ──
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* ESC 关闭时调用:记录 dismissed 位置。
|
|
161
|
+
* 对标 suggestion.ts:491 设置 dismissedRange。
|
|
162
|
+
*
|
|
163
|
+
* dismissed 恢复条件(§1.4):
|
|
164
|
+
* ① 输入空格 → evaluateState 检测到 query 含空格 → _dismissedPos 被清空
|
|
165
|
+
* ② 新位置输入 @ → anchorPos !== _dismissedPos → 不受 dismissed 影响
|
|
166
|
+
* ③ 删掉原 @ → anchorPos === -1 → _dismissedPos 被清空
|
|
167
|
+
*/
|
|
168
|
+
function setDismissed() {
|
|
169
|
+
_dismissedPos = _anchorPos;
|
|
170
|
+
_active = false;
|
|
171
|
+
_anchorPos = -1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 面板正常关闭(选中 Agent / 点击 backdrop / 点击 ✕)。
|
|
176
|
+
* 不设置 dismissed——下次输入 @ 应该能正常弹出。
|
|
177
|
+
*/
|
|
178
|
+
function closeMention() {
|
|
179
|
+
_active = false;
|
|
180
|
+
_anchorPos = -1;
|
|
181
|
+
// 不动 _dismissedPos
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 完全重置(选中 Agent 后、@ 被删除后)。
|
|
186
|
+
* 清空所有状态包括 dismissed。
|
|
187
|
+
*/
|
|
188
|
+
function resetMention() {
|
|
189
|
+
_active = false;
|
|
190
|
+
_anchorPos = -1;
|
|
191
|
+
_dismissedPos = null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 设置输入法组合状态。
|
|
196
|
+
* 由 compositionstart / compositionend 事件驱动。
|
|
197
|
+
* @param {boolean} composing
|
|
198
|
+
*/
|
|
199
|
+
function setComposing(composing) {
|
|
200
|
+
_isComposing = composing;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── 状态读取 ──
|
|
204
|
+
|
|
205
|
+
function isActive() { return _active; }
|
|
206
|
+
function getAnchorPos() { return _anchorPos; }
|