@rndev77/suzu-ai 0.1.0 → 0.1.2
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/package.json +1 -4
- package/src/core.d.ts +31 -0
- package/src/core.js +272 -0
- package/src/index.d.ts +1 -1
- package/src/index.js +1 -1
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.2",
|
|
7
7
|
"description": "SUZU production chat panel as a vanilla embed",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"main": "./src/index.js",
|
|
@@ -22,9 +22,6 @@
|
|
|
22
22
|
"sideEffects": [
|
|
23
23
|
"./src/style.css"
|
|
24
24
|
],
|
|
25
|
-
"dependencies": {
|
|
26
|
-
"@rndev77/suzu-ai-core": "0.1.0"
|
|
27
|
-
},
|
|
28
25
|
"license": "MIT",
|
|
29
26
|
"keywords": [
|
|
30
27
|
"suzu",
|
package/src/core.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface SuzuChatHandlers {
|
|
2
|
+
[event: string]: (data?: any) => void;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface SuzuChatOptions {
|
|
6
|
+
apiBase: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
on?: SuzuChatHandlers;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SuzuChat {
|
|
12
|
+
ready(): boolean;
|
|
13
|
+
sessionId(): string | null;
|
|
14
|
+
busy(): boolean;
|
|
15
|
+
reset(): void;
|
|
16
|
+
setSession(id?: string | null): void;
|
|
17
|
+
welcome(): Promise<any>;
|
|
18
|
+
newChat(): Promise<any>;
|
|
19
|
+
messages(id: string): Promise<any[]>;
|
|
20
|
+
listing(pid: string): Promise<any>;
|
|
21
|
+
imageUrl(image: any): Promise<string>;
|
|
22
|
+
send(text: string, isMore?: boolean): Promise<void>;
|
|
23
|
+
sendLocation(location: any): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SuzuChatConstructor {
|
|
27
|
+
new (options: SuzuChatOptions): SuzuChat;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const SuzuChat: SuzuChatConstructor;
|
|
31
|
+
export default SuzuChat;
|
package/src/core.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
function trimSlash(url) {
|
|
2
|
+
return String(url || "").replace(/\/+$/, "");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function parseSseBlock(block) {
|
|
6
|
+
var eventName = "text";
|
|
7
|
+
var dataLines = [];
|
|
8
|
+
String(block).split(/\r?\n/).forEach(function (line) {
|
|
9
|
+
if (line.indexOf("event:") === 0) eventName = line.slice(6).trim();
|
|
10
|
+
else if (line.indexOf("data:") === 0) dataLines.push(line.slice(5).trim());
|
|
11
|
+
});
|
|
12
|
+
var payload = {};
|
|
13
|
+
if (dataLines.length) {
|
|
14
|
+
try { payload = JSON.parse(dataLines.join("\n")); } catch (err) {}
|
|
15
|
+
}
|
|
16
|
+
return { event: eventName, data: payload };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function storageKey(api, apiKey) {
|
|
20
|
+
return "suzu_partner_auth::" + api + "::" + apiKey;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readStored(api, apiKey) {
|
|
24
|
+
try { return localStorage.getItem(storageKey(api, apiKey)) || ""; }
|
|
25
|
+
catch (err) { return ""; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeStored(api, apiKey, token) {
|
|
29
|
+
try {
|
|
30
|
+
var key = storageKey(api, apiKey);
|
|
31
|
+
if (token) localStorage.setItem(key, token);
|
|
32
|
+
else localStorage.removeItem(key);
|
|
33
|
+
} catch (err) {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function SuzuChat(opts) {
|
|
37
|
+
opts = opts || {};
|
|
38
|
+
var api = trimSlash(opts.apiBase);
|
|
39
|
+
var apiKey = String(opts.apiKey || "");
|
|
40
|
+
var sessionId = null;
|
|
41
|
+
var sending = false;
|
|
42
|
+
var handlers = opts.on || {};
|
|
43
|
+
var credential = readStored(api, apiKey) || apiKey;
|
|
44
|
+
var imageCache = Object.create(null);
|
|
45
|
+
var imageWait = Object.create(null);
|
|
46
|
+
var imageQueue = [];
|
|
47
|
+
var imageInflight = 0;
|
|
48
|
+
var IMAGE_CONCURRENCY = 3;
|
|
49
|
+
|
|
50
|
+
function pumpImageQueue() {
|
|
51
|
+
while (imageInflight < IMAGE_CONCURRENCY && imageQueue.length) {
|
|
52
|
+
(function (job) {
|
|
53
|
+
imageInflight++;
|
|
54
|
+
Promise.resolve()
|
|
55
|
+
.then(job)
|
|
56
|
+
.then(function () {
|
|
57
|
+
imageInflight--;
|
|
58
|
+
pumpImageQueue();
|
|
59
|
+
}, function () {
|
|
60
|
+
imageInflight--;
|
|
61
|
+
pumpImageQueue();
|
|
62
|
+
});
|
|
63
|
+
})(imageQueue.shift());
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function enqueueImage(run) {
|
|
68
|
+
return new Promise(function (resolve) {
|
|
69
|
+
imageQueue.push(function () {
|
|
70
|
+
return Promise.resolve()
|
|
71
|
+
.then(run)
|
|
72
|
+
.then(function (url) {
|
|
73
|
+
resolve(url || "");
|
|
74
|
+
return url;
|
|
75
|
+
}, function () {
|
|
76
|
+
resolve("");
|
|
77
|
+
return "";
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
pumpImageQueue();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function emit(name, arg) {
|
|
85
|
+
if (typeof handlers[name] === "function") handlers[name](arg);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function consumeAuth(data) {
|
|
89
|
+
var token = data && data.access_token ? String(data.access_token) : "";
|
|
90
|
+
if (!token) return;
|
|
91
|
+
credential = token;
|
|
92
|
+
writeStored(api, apiKey, token);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function authHeaders(json, sse) {
|
|
96
|
+
var h = { "Accept-Language": "ru-RU,ru;q=0.9" };
|
|
97
|
+
if (credential) h.Authorization = "Bearer " + credential;
|
|
98
|
+
if (json) h["Content-Type"] = "application/json";
|
|
99
|
+
if (sse) h.Accept = "text/event-stream";
|
|
100
|
+
return h;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function clearVisitor() {
|
|
104
|
+
credential = apiKey;
|
|
105
|
+
writeStored(api, apiKey, "");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function authedFetch(path, init, allowRetry) {
|
|
109
|
+
var resp = await fetch(api + path, Object.assign({}, init || {}, {
|
|
110
|
+
headers: Object.assign({}, authHeaders(false, false), (init && init.headers) || {})
|
|
111
|
+
}));
|
|
112
|
+
if (resp.status !== 401 || !allowRetry) return resp;
|
|
113
|
+
if (credential && credential !== apiKey) {
|
|
114
|
+
clearVisitor();
|
|
115
|
+
await welcome();
|
|
116
|
+
return fetch(api + path, Object.assign({}, init || {}, {
|
|
117
|
+
headers: Object.assign({}, authHeaders(false, false), (init && init.headers) || {})
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
return resp;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function welcome() {
|
|
124
|
+
if (!api || !apiKey) return { suggestions: [] };
|
|
125
|
+
var resp = await fetch(api + "/api/chat/welcome", { headers: authHeaders(false, false) });
|
|
126
|
+
if (!resp.ok) throw new Error("welcome " + resp.status);
|
|
127
|
+
var data = await resp.json();
|
|
128
|
+
if (data && data.auth) consumeAuth(data.auth);
|
|
129
|
+
return data || { suggestions: [] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readSse(stream) {
|
|
133
|
+
var reader = stream.getReader();
|
|
134
|
+
var decoder = new TextDecoder();
|
|
135
|
+
var buffer = "";
|
|
136
|
+
var done = false;
|
|
137
|
+
while (!done) {
|
|
138
|
+
var chunk = await reader.read();
|
|
139
|
+
done = chunk.done;
|
|
140
|
+
buffer += decoder.decode(chunk.value || new Uint8Array(), { stream: !done });
|
|
141
|
+
var blocks = buffer.split(/\r?\n\r?\n/);
|
|
142
|
+
buffer = blocks.pop() || "";
|
|
143
|
+
blocks.forEach(function (block) {
|
|
144
|
+
if (!block.trim()) return;
|
|
145
|
+
var parsed = parseSseBlock(block);
|
|
146
|
+
if (parsed.event === "auth") consumeAuth(parsed.data);
|
|
147
|
+
if (parsed.event === "session" && parsed.data.session_id) {
|
|
148
|
+
sessionId = String(parsed.data.session_id);
|
|
149
|
+
}
|
|
150
|
+
emit(parsed.event, parsed.data);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function postChat(payload) {
|
|
156
|
+
if (!credential) await welcome();
|
|
157
|
+
var resp = await fetch(api + "/api/chat", {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: authHeaders(true, true),
|
|
160
|
+
body: JSON.stringify(payload)
|
|
161
|
+
});
|
|
162
|
+
if (!resp.ok) throw new Error("chat " + resp.status);
|
|
163
|
+
if (resp.body) await readSse(resp.body);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.ready = function () { return !!api && !!apiKey; };
|
|
167
|
+
this.sessionId = function () { return sessionId; };
|
|
168
|
+
this.busy = function () { return sending; };
|
|
169
|
+
this.reset = function () {
|
|
170
|
+
sessionId = null;
|
|
171
|
+
sending = false;
|
|
172
|
+
};
|
|
173
|
+
this.setSession = function (id) { sessionId = id || null; };
|
|
174
|
+
this.welcome = function () {
|
|
175
|
+
return welcome().catch(function () { return { suggestions: [] }; });
|
|
176
|
+
};
|
|
177
|
+
this.newChat = function () {
|
|
178
|
+
if (!api || !apiKey) return Promise.resolve(null);
|
|
179
|
+
return authedFetch("/api/chats/new", { method: "POST" }, true)
|
|
180
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
181
|
+
.then(function (data) {
|
|
182
|
+
if (data && data.auth) consumeAuth(data.auth);
|
|
183
|
+
sessionId = data && data.session_id ? String(data.session_id) : null;
|
|
184
|
+
return data;
|
|
185
|
+
})
|
|
186
|
+
.catch(function () { return null; });
|
|
187
|
+
};
|
|
188
|
+
this.messages = function (id) {
|
|
189
|
+
if (!api || !apiKey || !id) return Promise.resolve([]);
|
|
190
|
+
return authedFetch("/api/chats/" + encodeURIComponent(id) + "/messages", {}, true)
|
|
191
|
+
.then(function (r) { return r.ok ? r.json() : { messages: [] }; })
|
|
192
|
+
.then(function (data) {
|
|
193
|
+
if (data && data.auth) consumeAuth(data.auth);
|
|
194
|
+
return data.messages || [];
|
|
195
|
+
})
|
|
196
|
+
.catch(function () { return []; });
|
|
197
|
+
};
|
|
198
|
+
this.listing = function (pid) {
|
|
199
|
+
if (!api || !apiKey || !pid) return Promise.resolve(null);
|
|
200
|
+
return authedFetch("/api/listings/" + encodeURIComponent(pid) + "?lang=ru", {}, true)
|
|
201
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
202
|
+
.then(function (data) {
|
|
203
|
+
if (data && data.auth) consumeAuth(data.auth);
|
|
204
|
+
return data && data.listing ? data.listing : data;
|
|
205
|
+
})
|
|
206
|
+
.catch(function () { return null; });
|
|
207
|
+
};
|
|
208
|
+
this.imageUrl = function (image) {
|
|
209
|
+
if (!image || !api || !apiKey) return Promise.resolve("");
|
|
210
|
+
var path = typeof image.delivery_url === "string" && image.delivery_url
|
|
211
|
+
? image.delivery_url
|
|
212
|
+
: (image.id ? "/api/listings/images/" + image.id + "/file" : "");
|
|
213
|
+
if (!path) return Promise.resolve("");
|
|
214
|
+
var key = /^https?:\/\//i.test(path) ? path : (api + path);
|
|
215
|
+
if (imageCache[key]) return Promise.resolve(imageCache[key]);
|
|
216
|
+
if (imageWait[key]) return imageWait[key];
|
|
217
|
+
imageWait[key] = enqueueImage(function () {
|
|
218
|
+
if (imageCache[key]) return imageCache[key];
|
|
219
|
+
return authedFetch(key.indexOf(api) === 0 ? key.slice(api.length) : path, {}, true)
|
|
220
|
+
.then(function (r) { return r.ok ? r.blob() : null; })
|
|
221
|
+
.then(function (blob) {
|
|
222
|
+
if (!blob) return "";
|
|
223
|
+
var url = URL.createObjectURL(blob);
|
|
224
|
+
imageCache[key] = url;
|
|
225
|
+
return url;
|
|
226
|
+
})
|
|
227
|
+
.catch(function () { return ""; });
|
|
228
|
+
}).then(function (url) {
|
|
229
|
+
delete imageWait[key];
|
|
230
|
+
return url;
|
|
231
|
+
}, function () {
|
|
232
|
+
delete imageWait[key];
|
|
233
|
+
return "";
|
|
234
|
+
});
|
|
235
|
+
return imageWait[key];
|
|
236
|
+
};
|
|
237
|
+
this.send = async function (text, isMore) {
|
|
238
|
+
if (!api || !apiKey || sending) return;
|
|
239
|
+
var content = String(text || "").trim();
|
|
240
|
+
if (!isMore && !content) return;
|
|
241
|
+
if (isMore && !sessionId) {
|
|
242
|
+
emit("error", { detail: "Нет активной сессии для подгрузки." });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
sending = true;
|
|
246
|
+
emit("start", { text: content, more: !!isMore });
|
|
247
|
+
try {
|
|
248
|
+
var payload = {
|
|
249
|
+
content: isMore ? "" : content,
|
|
250
|
+
is_more: !!isMore
|
|
251
|
+
};
|
|
252
|
+
if (sessionId) payload.session_id = sessionId;
|
|
253
|
+
await postChat(payload);
|
|
254
|
+
emit("done", {});
|
|
255
|
+
} catch (err) {
|
|
256
|
+
emit("error", { detail: "Не удалось связаться с ассистентом. Попробуйте ещё раз." });
|
|
257
|
+
} finally {
|
|
258
|
+
sending = false;
|
|
259
|
+
emit("idle", {});
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
this.sendLocation = function (location) {
|
|
263
|
+
if (!api || !apiKey || !sessionId) return;
|
|
264
|
+
authedFetch("/api/chat", {
|
|
265
|
+
method: "POST",
|
|
266
|
+
headers: authHeaders(true, false),
|
|
267
|
+
body: JSON.stringify({ session_id: sessionId, location: location })
|
|
268
|
+
}, true).catch(function () {});
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export default SuzuChat;
|
package/src/index.d.ts
CHANGED
package/src/index.js
CHANGED