chatablex-web-sdk 1.0.31 → 1.0.34
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 +61 -0
- package/README.zh-CN.md +68 -0
- package/dist/index.d.mts +144 -1
- package/dist/index.d.ts +144 -1
- package/dist/index.js +417 -6
- package/dist/index.mjs +413 -6
- package/package.json +3 -3
- package/src/assets/bee.png +0 -0
- package/src/assets.d.ts +4 -0
- package/src/index.ts +20 -2
- package/src/modules/agentLock.ts +255 -0
- package/src/modules/cloud.ts +297 -0
- package/src/modules/tool.ts +12 -1
- package/src/types.ts +133 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import type { Bridge } from '../bridge';
|
|
2
|
+
import type {
|
|
3
|
+
AgentLockConfig,
|
|
4
|
+
AgentLockEventType,
|
|
5
|
+
AgentLockEventData,
|
|
6
|
+
AgentLockEventHandler,
|
|
7
|
+
ChatableXAgentLock,
|
|
8
|
+
} from '../types';
|
|
9
|
+
import beeLogo from '../assets/bee.png';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_CONFIG: Required<AgentLockConfig> = {
|
|
12
|
+
enabled: true,
|
|
13
|
+
mode: 'overlay',
|
|
14
|
+
logoUrl: '',
|
|
15
|
+
message: 'Agent 正在操作,请稍候…',
|
|
16
|
+
allowCancel: true,
|
|
17
|
+
opacity: 0.3,
|
|
18
|
+
timeout: 30_000,
|
|
19
|
+
debounceUnlock: 200,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const OVERLAY_ID = '__chatablex_agent_lock_overlay__';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Blocked event types — we intercept these on the overlay to prevent user
|
|
26
|
+
* interaction from reaching the app underneath.
|
|
27
|
+
*/
|
|
28
|
+
const BLOCKED_EVENTS: string[] = [
|
|
29
|
+
'mousedown', 'mouseup', 'click', 'dblclick', 'contextmenu',
|
|
30
|
+
'keydown', 'keyup', 'keypress',
|
|
31
|
+
'touchstart', 'touchmove', 'touchend',
|
|
32
|
+
'wheel', 'scroll',
|
|
33
|
+
'pointerdown', 'pointerup',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const CANCEL_BTN_ID = '__ctx_agent_lock_cancel__';
|
|
37
|
+
|
|
38
|
+
function blockEvent(e: Event): void {
|
|
39
|
+
const target = e.target as HTMLElement | null;
|
|
40
|
+
if (target?.id === CANCEL_BTN_ID) return;
|
|
41
|
+
e.stopPropagation();
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AgentLockModule extends ChatableXAgentLock {
|
|
46
|
+
/**
|
|
47
|
+
* @internal — called by tool module to lock before dispatch and schedule
|
|
48
|
+
* unlock after result. Uses ref-counting to support consecutive tools.
|
|
49
|
+
*/
|
|
50
|
+
_autoLock(requestId: string): void;
|
|
51
|
+
/** @internal */
|
|
52
|
+
_autoUnlock(requestId: string): void;
|
|
53
|
+
/** @internal */
|
|
54
|
+
_destroy(): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createAgentLockModule(
|
|
58
|
+
_bridge: Bridge,
|
|
59
|
+
userConfig: AgentLockConfig = {},
|
|
60
|
+
): AgentLockModule {
|
|
61
|
+
const cfg: Required<AgentLockConfig> = { ...DEFAULT_CONFIG, ...userConfig };
|
|
62
|
+
const logoSrc = cfg.logoUrl || beeLogo;
|
|
63
|
+
|
|
64
|
+
const listeners = new Map<AgentLockEventType, Set<AgentLockEventHandler>>();
|
|
65
|
+
let overlayEl: HTMLDivElement | null = null;
|
|
66
|
+
let locked = false;
|
|
67
|
+
let lockCount = 0;
|
|
68
|
+
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
69
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
70
|
+
let currentMessage = cfg.message;
|
|
71
|
+
|
|
72
|
+
function emit(event: AgentLockEventType, data: Partial<AgentLockEventData> = {}): void {
|
|
73
|
+
const payload: AgentLockEventData = { timestamp: Date.now(), ...data };
|
|
74
|
+
const handlers = listeners.get(event);
|
|
75
|
+
if (handlers) {
|
|
76
|
+
for (const fn of handlers) {
|
|
77
|
+
try { fn(payload); } catch (e) { console.error('[ChatableX AgentLock] event handler error:', e); }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function injectOverlay(message: string): void {
|
|
83
|
+
if (typeof document === 'undefined') return;
|
|
84
|
+
if (document.getElementById(OVERLAY_ID)) return;
|
|
85
|
+
|
|
86
|
+
const el = document.createElement('div');
|
|
87
|
+
el.id = OVERLAY_ID;
|
|
88
|
+
el.setAttribute('aria-hidden', 'true');
|
|
89
|
+
el.style.cssText = [
|
|
90
|
+
'position:fixed', 'inset:0', `z-index:2147483646`,
|
|
91
|
+
`background:rgba(255,255,255,${cfg.opacity})`,
|
|
92
|
+
'display:flex', 'flex-direction:column',
|
|
93
|
+
'align-items:center', 'justify-content:center',
|
|
94
|
+
'pointer-events:all', 'user-select:none',
|
|
95
|
+
'backdrop-filter:blur(1px)', '-webkit-backdrop-filter:blur(1px)',
|
|
96
|
+
].join(';');
|
|
97
|
+
|
|
98
|
+
el.innerHTML = `
|
|
99
|
+
<img src="${logoSrc}" alt="" style="width:48px;height:48px;animation:__ctx_spin 1.5s linear infinite;" />
|
|
100
|
+
<p style="margin:12px 0 0;font:14px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:#666;">${message}</p>
|
|
101
|
+
${cfg.allowCancel ? '<button id="__ctx_agent_lock_cancel__" style="margin-top:16px;background:none;border:none;color:#6366f1;font:13px -apple-system,BlinkMacSystemFont,sans-serif;cursor:pointer;text-decoration:underline;padding:4px 8px;">取消</button>' : ''}
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
if (!document.getElementById('__ctx_agent_lock_style__')) {
|
|
105
|
+
const style = document.createElement('style');
|
|
106
|
+
style.id = '__ctx_agent_lock_style__';
|
|
107
|
+
style.textContent = '@keyframes __ctx_spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}';
|
|
108
|
+
document.head.appendChild(style);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const evt of BLOCKED_EVENTS) {
|
|
112
|
+
el.addEventListener(evt, blockEvent, { capture: true, passive: false });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (cfg.allowCancel) {
|
|
116
|
+
// Defer binding so the DOM is ready
|
|
117
|
+
queueMicrotask(() => {
|
|
118
|
+
const btn = el.querySelector('#__ctx_agent_lock_cancel__');
|
|
119
|
+
if (btn) {
|
|
120
|
+
btn.addEventListener('click', (e) => {
|
|
121
|
+
e.stopPropagation();
|
|
122
|
+
handleCancel();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
document.body.appendChild(el);
|
|
129
|
+
overlayEl = el;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function removeOverlay(): void {
|
|
133
|
+
if (overlayEl) {
|
|
134
|
+
for (const evt of BLOCKED_EVENTS) {
|
|
135
|
+
overlayEl.removeEventListener(evt, blockEvent, { capture: true } as EventListenerOptions);
|
|
136
|
+
}
|
|
137
|
+
overlayEl.remove();
|
|
138
|
+
overlayEl = null;
|
|
139
|
+
}
|
|
140
|
+
const style = typeof document !== 'undefined' ? document.getElementById('__ctx_agent_lock_style__') : null;
|
|
141
|
+
if (style) style.remove();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function startTimeout(ms: number, requestId?: string): void {
|
|
145
|
+
clearTimeoutTimer();
|
|
146
|
+
if (ms <= 0) return;
|
|
147
|
+
timeoutTimer = setTimeout(() => {
|
|
148
|
+
console.warn('[ChatableX] Agent lock timeout — auto-unlocking');
|
|
149
|
+
forceUnlock();
|
|
150
|
+
emit('timeout', { requestId });
|
|
151
|
+
}, ms);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function clearTimeoutTimer(): void {
|
|
155
|
+
if (timeoutTimer !== null) {
|
|
156
|
+
clearTimeout(timeoutTimer);
|
|
157
|
+
timeoutTimer = null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function clearDebounceTimer(): void {
|
|
162
|
+
if (debounceTimer !== null) {
|
|
163
|
+
clearTimeout(debounceTimer);
|
|
164
|
+
debounceTimer = null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function doLock(message: string, timeout: number, requestId?: string): void {
|
|
169
|
+
if (locked) return;
|
|
170
|
+
locked = true;
|
|
171
|
+
currentMessage = message;
|
|
172
|
+
|
|
173
|
+
if (cfg.mode === 'overlay') {
|
|
174
|
+
injectOverlay(currentMessage);
|
|
175
|
+
}
|
|
176
|
+
startTimeout(timeout, requestId);
|
|
177
|
+
emit('lock', { requestId });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function forceUnlock(requestId?: string): void {
|
|
181
|
+
if (!locked) return;
|
|
182
|
+
locked = false;
|
|
183
|
+
lockCount = 0;
|
|
184
|
+
clearTimeoutTimer();
|
|
185
|
+
clearDebounceTimer();
|
|
186
|
+
if (cfg.mode === 'overlay') {
|
|
187
|
+
removeOverlay();
|
|
188
|
+
}
|
|
189
|
+
emit('unlock', { requestId });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function handleCancel(): void {
|
|
193
|
+
const rid = undefined; // auto mode tracks this externally
|
|
194
|
+
forceUnlock(rid);
|
|
195
|
+
emit('cancel', { requestId: rid });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Public API -----------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
function lock(opts?: { message?: string; timeout?: number }): void {
|
|
201
|
+
if (!cfg.enabled) return;
|
|
202
|
+
const msg = opts?.message ?? cfg.message;
|
|
203
|
+
const timeout = opts?.timeout ?? cfg.timeout;
|
|
204
|
+
doLock(msg, timeout);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function unlock(): void {
|
|
208
|
+
forceUnlock();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isLocked(): boolean {
|
|
212
|
+
return locked;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function on(event: AgentLockEventType, handler: AgentLockEventHandler): () => void {
|
|
216
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
217
|
+
listeners.get(event)!.add(handler);
|
|
218
|
+
return () => off(event, handler);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function off(event: AgentLockEventType, handler: AgentLockEventHandler): void {
|
|
222
|
+
listeners.get(event)?.delete(handler);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Internal auto-mode API (called by tool module) ----------------------------
|
|
226
|
+
|
|
227
|
+
function _autoLock(requestId: string): void {
|
|
228
|
+
if (!cfg.enabled) return;
|
|
229
|
+
clearDebounceTimer();
|
|
230
|
+
lockCount++;
|
|
231
|
+
if (!locked) {
|
|
232
|
+
doLock(cfg.message, cfg.timeout, requestId);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function _autoUnlock(requestId: string): void {
|
|
237
|
+
if (!cfg.enabled) return;
|
|
238
|
+
lockCount = Math.max(0, lockCount - 1);
|
|
239
|
+
if (lockCount === 0) {
|
|
240
|
+
clearDebounceTimer();
|
|
241
|
+
debounceTimer = setTimeout(() => {
|
|
242
|
+
if (lockCount === 0) {
|
|
243
|
+
forceUnlock(requestId);
|
|
244
|
+
}
|
|
245
|
+
}, cfg.debounceUnlock);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function _destroy(): void {
|
|
250
|
+
forceUnlock();
|
|
251
|
+
listeners.clear();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { lock, unlock, isLocked, on, off, _autoLock, _autoUnlock, _destroy };
|
|
255
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import type { Bridge } from '../bridge';
|
|
2
|
+
import type {
|
|
3
|
+
ChatableXAuth,
|
|
4
|
+
ChatableXCloud,
|
|
5
|
+
CloudFileInfo,
|
|
6
|
+
CloudListOptions,
|
|
7
|
+
CloudUploadData,
|
|
8
|
+
CloudUploadOptions,
|
|
9
|
+
CloudUploadResult,
|
|
10
|
+
CloudUsage,
|
|
11
|
+
} from '../types';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Errors (instanceof-checkable so apps can branch their UI)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** Base error for all `sdk.cloud` failures. */
|
|
18
|
+
export class CloudError extends Error {
|
|
19
|
+
/** Business code from auth-fc (or the HTTP status when none was returned). */
|
|
20
|
+
readonly code: number;
|
|
21
|
+
constructor(message: string, code: number) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'CloudError';
|
|
24
|
+
this.code = code;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Thrown when no authenticated session is available. The app should prompt the
|
|
30
|
+
* user to log in to ChatableX before retrying.
|
|
31
|
+
*/
|
|
32
|
+
export class CloudAuthRequiredError extends CloudError {
|
|
33
|
+
constructor(message = 'cloud storage requires an authenticated session') {
|
|
34
|
+
super(message, 401);
|
|
35
|
+
this.name = 'CloudAuthRequiredError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Thrown when the user lacks the entitlement (purchased tool / membership)
|
|
41
|
+
* required to write to cloud storage. Maps to auth-fc code `40302`.
|
|
42
|
+
*/
|
|
43
|
+
export class CloudSubscriptionRequiredError extends CloudError {
|
|
44
|
+
constructor(message = 'cloud storage requires an active subscription') {
|
|
45
|
+
super(message, 40302);
|
|
46
|
+
this.name = 'CloudSubscriptionRequiredError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Thrown when the upload would exceed the user's storage quota (code `40301`). */
|
|
51
|
+
export class CloudQuotaExceededError extends CloudError {
|
|
52
|
+
readonly usedBytes: number;
|
|
53
|
+
readonly quotaBytes: number;
|
|
54
|
+
constructor(usedBytes: number, quotaBytes: number, message = 'storage quota exceeded') {
|
|
55
|
+
super(message, 40301);
|
|
56
|
+
this.name = 'CloudQuotaExceededError';
|
|
57
|
+
this.usedBytes = usedBytes;
|
|
58
|
+
this.quotaBytes = quotaBytes;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Internal helpers
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
|
|
67
|
+
|
|
68
|
+
/** auth-fc response envelope: { success, code, message, data }. */
|
|
69
|
+
interface ApiEnvelope<T> {
|
|
70
|
+
success?: boolean;
|
|
71
|
+
code?: number;
|
|
72
|
+
message?: string;
|
|
73
|
+
data?: T;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface UploadURLData {
|
|
77
|
+
upload_url: string;
|
|
78
|
+
object_key: string;
|
|
79
|
+
expires_in: number;
|
|
80
|
+
}
|
|
81
|
+
interface DownloadURLData {
|
|
82
|
+
download_url: string;
|
|
83
|
+
object_key: string;
|
|
84
|
+
expires_in: number;
|
|
85
|
+
}
|
|
86
|
+
interface ListFilesData {
|
|
87
|
+
files: Array<{ file_key: string; size: number; last_modified: string }>;
|
|
88
|
+
total: number;
|
|
89
|
+
}
|
|
90
|
+
interface UsageData {
|
|
91
|
+
used_bytes: number;
|
|
92
|
+
quota_bytes: number;
|
|
93
|
+
file_count: number;
|
|
94
|
+
reconciled_at?: string;
|
|
95
|
+
}
|
|
96
|
+
interface QuotaErrorData {
|
|
97
|
+
used_bytes?: number;
|
|
98
|
+
quota_bytes?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeData(data: CloudUploadData): { body: BodyInit; size: number; type: string } {
|
|
102
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob) {
|
|
103
|
+
return { body: data, size: data.size, type: data.type || '' };
|
|
104
|
+
}
|
|
105
|
+
if (typeof data === 'string') {
|
|
106
|
+
const blob = new Blob([data]);
|
|
107
|
+
return { body: blob, size: blob.size, type: '' };
|
|
108
|
+
}
|
|
109
|
+
if (data instanceof ArrayBuffer) {
|
|
110
|
+
return { body: data, size: data.byteLength, type: '' };
|
|
111
|
+
}
|
|
112
|
+
if (ArrayBuffer.isView(data)) {
|
|
113
|
+
return { body: data as unknown as BodyInit, size: data.byteLength, type: '' };
|
|
114
|
+
}
|
|
115
|
+
throw new CloudError('unsupported upload data type', 400);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface CloudModuleDeps {
|
|
119
|
+
appId: string;
|
|
120
|
+
auth: ChatableXAuth;
|
|
121
|
+
/** Explicit cloud API base URL (overrides the host-provided one). */
|
|
122
|
+
apiBaseUrl?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createCloudModule(bridge: Bridge, deps: CloudModuleDeps): ChatableXCloud {
|
|
126
|
+
const { appId, auth } = deps;
|
|
127
|
+
let resolvedBase: string | null = deps.apiBaseUrl ? stripTrailingSlash(deps.apiBaseUrl) : null;
|
|
128
|
+
|
|
129
|
+
function stripTrailingSlash(url: string): string {
|
|
130
|
+
return url.replace(/\/+$/, '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Resolve the cloud API base URL: explicit config > host bridge > error. */
|
|
134
|
+
async function baseUrl(): Promise<string> {
|
|
135
|
+
if (resolvedBase) return resolvedBase;
|
|
136
|
+
try {
|
|
137
|
+
// Short timeout: a hosted-but-unimplemented method shouldn't hang the call.
|
|
138
|
+
const r = await bridge.sendMessage('host.getApiBaseUrl', {}, 5_000);
|
|
139
|
+
const url =
|
|
140
|
+
typeof r === 'string'
|
|
141
|
+
? r
|
|
142
|
+
: r && typeof r === 'object' && typeof (r as { base_url?: unknown }).base_url === 'string'
|
|
143
|
+
? (r as { base_url: string }).base_url
|
|
144
|
+
: '';
|
|
145
|
+
if (url) {
|
|
146
|
+
resolvedBase = stripTrailingSlash(url);
|
|
147
|
+
return resolvedBase;
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// host doesn't implement it / not hosted — fall through to error
|
|
151
|
+
}
|
|
152
|
+
throw new CloudError(
|
|
153
|
+
'cloud API base URL is not configured; pass apiBaseUrl to ChatableX.init()',
|
|
154
|
+
0,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* fetch against auth-fc that injects the host login session and retries once
|
|
160
|
+
* on 401 after letting `sdk.auth` refresh. Rejects with CloudAuthRequiredError
|
|
161
|
+
* when there is no valid token (no unauthenticated request is sent).
|
|
162
|
+
*/
|
|
163
|
+
async function authedFetch(path: string, init: RequestInit): Promise<Response> {
|
|
164
|
+
const token = await auth.getToken();
|
|
165
|
+
if (!token) throw new CloudAuthRequiredError();
|
|
166
|
+
|
|
167
|
+
const base = await baseUrl();
|
|
168
|
+
const url = `${base}${path}`;
|
|
169
|
+
|
|
170
|
+
const build = async (): Promise<RequestInit> => ({
|
|
171
|
+
...init,
|
|
172
|
+
headers: { ...(init.headers ?? {}), ...(await auth.getAuthHeaders()) },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
let res = await fetch(url, await build());
|
|
176
|
+
if (res.status === 401 && (await auth.refresh())) {
|
|
177
|
+
res = await fetch(url, await build());
|
|
178
|
+
}
|
|
179
|
+
return res;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Call an auth-fc JSON endpoint and unwrap the `data` payload. */
|
|
183
|
+
async function callApi<T>(path: string, init: RequestInit): Promise<T> {
|
|
184
|
+
const res = await authedFetch(path, init);
|
|
185
|
+
|
|
186
|
+
let body: ApiEnvelope<unknown> | null = null;
|
|
187
|
+
try {
|
|
188
|
+
body = (await res.json()) as ApiEnvelope<unknown>;
|
|
189
|
+
} catch {
|
|
190
|
+
// non-JSON body
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const code = body?.code;
|
|
194
|
+
const message = body?.message || `HTTP ${res.status}`;
|
|
195
|
+
|
|
196
|
+
if (!res.ok || body?.success === false) {
|
|
197
|
+
if (code === 40301) {
|
|
198
|
+
const q = (body?.data ?? {}) as QuotaErrorData;
|
|
199
|
+
throw new CloudQuotaExceededError(q.used_bytes ?? 0, q.quota_bytes ?? 0, message);
|
|
200
|
+
}
|
|
201
|
+
if (code === 40302) throw new CloudSubscriptionRequiredError(message);
|
|
202
|
+
if (res.status === 401) throw new CloudAuthRequiredError(message);
|
|
203
|
+
throw new CloudError(message, code ?? res.status);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return (body?.data ?? null) as T;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function validateFileKey(fileKey: string): void {
|
|
210
|
+
if (!fileKey || typeof fileKey !== 'string') {
|
|
211
|
+
throw new CloudError('fileKey is required', 400);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
async upload(
|
|
217
|
+
fileKey: string,
|
|
218
|
+
data: CloudUploadData,
|
|
219
|
+
options: CloudUploadOptions = {},
|
|
220
|
+
): Promise<CloudUploadResult> {
|
|
221
|
+
validateFileKey(fileKey);
|
|
222
|
+
const { body, size, type } = normalizeData(data);
|
|
223
|
+
const contentType = options.contentType || type || DEFAULT_CONTENT_TYPE;
|
|
224
|
+
|
|
225
|
+
const signed = await callApi<UploadURLData>('/api/storage/upload-url', {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'Content-Type': 'application/json' },
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
app_id: appId,
|
|
230
|
+
file_key: fileKey,
|
|
231
|
+
content_type: contentType,
|
|
232
|
+
size_bytes: size,
|
|
233
|
+
}),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Direct client → OSS PUT. Content-Type MUST match what was signed.
|
|
237
|
+
const put = await fetch(signed.upload_url, {
|
|
238
|
+
method: 'PUT',
|
|
239
|
+
headers: { 'Content-Type': contentType },
|
|
240
|
+
body,
|
|
241
|
+
});
|
|
242
|
+
if (!put.ok) {
|
|
243
|
+
throw new CloudError(`OSS upload failed: HTTP ${put.status}`, put.status);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { fileKey, objectKey: signed.object_key, size, contentType };
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async getDownloadUrl(fileKey: string): Promise<string> {
|
|
250
|
+
validateFileKey(fileKey);
|
|
251
|
+
const signed = await callApi<DownloadURLData>('/api/storage/download-url', {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: { 'Content-Type': 'application/json' },
|
|
254
|
+
body: JSON.stringify({ app_id: appId, file_key: fileKey }),
|
|
255
|
+
});
|
|
256
|
+
return signed.download_url;
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async download(fileKey: string): Promise<Blob> {
|
|
260
|
+
const url = await this.getDownloadUrl(fileKey);
|
|
261
|
+
const res = await fetch(url, { method: 'GET' });
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
throw new CloudError(`OSS download failed: HTTP ${res.status}`, res.status);
|
|
264
|
+
}
|
|
265
|
+
return res.blob();
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
async list(options: CloudListOptions = {}): Promise<CloudFileInfo[]> {
|
|
269
|
+
const qs = new URLSearchParams({ app_id: appId });
|
|
270
|
+
if (options.prefix) qs.set('prefix', options.prefix);
|
|
271
|
+
const data = await callApi<ListFilesData>(`/api/storage/files?${qs.toString()}`, {
|
|
272
|
+
method: 'GET',
|
|
273
|
+
});
|
|
274
|
+
return (data?.files ?? []).map((f) => ({
|
|
275
|
+
fileKey: f.file_key,
|
|
276
|
+
size: f.size,
|
|
277
|
+
lastModified: f.last_modified,
|
|
278
|
+
}));
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
async delete(fileKey: string): Promise<void> {
|
|
282
|
+
validateFileKey(fileKey);
|
|
283
|
+
const qs = new URLSearchParams({ app_id: appId, file_key: fileKey });
|
|
284
|
+
await callApi<unknown>(`/api/storage/files?${qs.toString()}`, { method: 'DELETE' });
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
async usage(): Promise<CloudUsage> {
|
|
288
|
+
const data = await callApi<UsageData>('/api/storage/usage', { method: 'GET' });
|
|
289
|
+
return {
|
|
290
|
+
usedBytes: data.used_bytes,
|
|
291
|
+
quotaBytes: data.quota_bytes,
|
|
292
|
+
fileCount: data.file_count,
|
|
293
|
+
reconciledAt: data.reconciled_at,
|
|
294
|
+
};
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
package/src/modules/tool.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { Bridge } from '../bridge';
|
|
2
2
|
import type { ToolInfo, ToolExecuteHandler, ChatableXToolModule } from '../types';
|
|
3
|
+
import type { AgentLockModule } from './agentLock';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Creates the `sdk.tool` module — the primary interface for LLM-driven
|
|
6
7
|
* tool execution in a ChatableX WebUI app.
|
|
7
8
|
*/
|
|
8
|
-
export function createToolModule(
|
|
9
|
+
export function createToolModule(
|
|
10
|
+
bridge: Bridge,
|
|
11
|
+
appId: string,
|
|
12
|
+
agentLock?: AgentLockModule,
|
|
13
|
+
): ChatableXToolModule & { _setInfo(info: Partial<ToolInfo>): void } {
|
|
9
14
|
let _info: ToolInfo = { id: appId, name: appId, version: '1.0.0', description: '' };
|
|
10
15
|
let _handler: ToolExecuteHandler | null = null;
|
|
11
16
|
|
|
@@ -30,13 +35,19 @@ export function createToolModule(bridge: Bridge, appId: string): ChatableXToolMo
|
|
|
30
35
|
bridge.addEventListener('toolExecution', async (data) => {
|
|
31
36
|
const params = data as Record<string, unknown>;
|
|
32
37
|
const requestId = params._requestId as string | undefined;
|
|
38
|
+
|
|
39
|
+
if (requestId && agentLock) agentLock._autoLock(requestId);
|
|
40
|
+
|
|
33
41
|
const result = await dispatch(params);
|
|
42
|
+
|
|
34
43
|
if (requestId && window.ChatableXBridge) {
|
|
35
44
|
window.ChatableXBridge.postMessage(JSON.stringify({
|
|
36
45
|
method: 'tool.executeResult',
|
|
37
46
|
params: { _requestId: requestId, ...result },
|
|
38
47
|
}));
|
|
39
48
|
}
|
|
49
|
+
|
|
50
|
+
if (requestId && agentLock) agentLock._autoUnlock(requestId);
|
|
40
51
|
});
|
|
41
52
|
|
|
42
53
|
return {
|