@whaletech/pet 0.2.0 → 0.2.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/README.md +1 -1
- package/dist/main.js +210 -82
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ pet
|
|
|
22
22
|
|
|
23
23
|
**Server** (self-hosted): Bun + SQLite backend handling state, AI chat, and payments. Deploy separately.
|
|
24
24
|
|
|
25
|
-
The client connects to `https://pet.whaletech.
|
|
25
|
+
The client connects to `https://api.pet.whaletech.app` by default, with a fallback to `https://pet-api.whaletech.app`. Override with:
|
|
26
26
|
|
|
27
27
|
```
|
|
28
28
|
WHALE_API_URL=http://localhost:3001 pet
|
package/dist/main.js
CHANGED
|
@@ -409,35 +409,142 @@ function getCompanionFromStored(userId, stored) {
|
|
|
409
409
|
}
|
|
410
410
|
|
|
411
411
|
// src/api.ts
|
|
412
|
-
|
|
412
|
+
import { spawn } from "node:child_process";
|
|
413
|
+
var PRIMARY_API_URL = "https://api.pet.whaletech.app";
|
|
414
|
+
var FALLBACK_API_URL = "https://pet-api.whaletech.app";
|
|
415
|
+
var API_URLS = [
|
|
416
|
+
process.env["WHALE_API_URL"],
|
|
417
|
+
PRIMARY_API_URL,
|
|
418
|
+
FALLBACK_API_URL
|
|
419
|
+
].filter((url, index, urls) => Boolean(url) && urls.indexOf(url) === index);
|
|
420
|
+
var CURL = process.platform === "win32" ? "curl.exe" : "curl";
|
|
413
421
|
var _token = null;
|
|
414
422
|
function setToken(token) {
|
|
415
423
|
_token = token;
|
|
416
424
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
+
function isRetryableNetworkError(err) {
|
|
426
|
+
if (!(err instanceof Error))
|
|
427
|
+
return false;
|
|
428
|
+
const text = [err.message, err.cause instanceof Error ? err.cause.message : "", String(err.cause?.code ?? "")].join(" ").toUpperCase();
|
|
429
|
+
return ["FETCH FAILED", "ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT"].some((token) => text.includes(token));
|
|
430
|
+
}
|
|
431
|
+
function runCurl(args, input) {
|
|
432
|
+
return new Promise((resolve, reject) => {
|
|
433
|
+
const child = spawn(CURL, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
434
|
+
let stdout = "";
|
|
435
|
+
let stderr = "";
|
|
436
|
+
child.stdout.on("data", (chunk) => {
|
|
437
|
+
stdout += String(chunk);
|
|
425
438
|
});
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
439
|
+
child.stderr.on("data", (chunk) => {
|
|
440
|
+
stderr += String(chunk);
|
|
441
|
+
});
|
|
442
|
+
child.on("error", reject);
|
|
443
|
+
child.on("close", (code) => {
|
|
444
|
+
if (code !== 0) {
|
|
445
|
+
reject(new Error(stderr.trim() || `${CURL} exited with code ${code}`));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const marker = `
|
|
449
|
+
__CURL_STATUS__:`;
|
|
450
|
+
const index = stdout.lastIndexOf(marker);
|
|
451
|
+
if (index === -1) {
|
|
452
|
+
reject(new Error("curl response missing status marker"));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const body = stdout.slice(0, index);
|
|
456
|
+
const status = Number(stdout.slice(index + marker.length).trim());
|
|
457
|
+
resolve({ status, body });
|
|
458
|
+
});
|
|
459
|
+
child.stdin.end(input);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
async function postWithCurl(apiUrl, path2, body) {
|
|
463
|
+
const requestBody = _token ? { ...body, token: _token } : body;
|
|
464
|
+
const response = await runCurl(["-sS", "-X", "POST", `${apiUrl}${path2}`, "-H", "Content-Type: application/json", "--data-binary", "@-", "-w", `
|
|
465
|
+
__CURL_STATUS__:%{http_code}`], JSON.stringify(requestBody));
|
|
466
|
+
const data = JSON.parse(response.body);
|
|
467
|
+
if (response.status < 200 || response.status >= 300) {
|
|
468
|
+
const isQuotaExceeded = data.error === "quota_exceeded";
|
|
469
|
+
return {
|
|
470
|
+
ok: false,
|
|
471
|
+
error: String(data.error ?? `HTTP ${response.status}`),
|
|
472
|
+
quotaExceeded: isQuotaExceeded,
|
|
473
|
+
paymentType: isQuotaExceeded ? String(data.paymentType) : undefined
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
return { ok: true, data };
|
|
477
|
+
}
|
|
478
|
+
async function sendChatStreamWithCurl(apiUrl, body, onToken) {
|
|
479
|
+
const requestBody = _token ? { ...body, token: _token } : body;
|
|
480
|
+
const response = await runCurl(["-sS", "-N", "-X", "POST", `${apiUrl}/api/chat`, "-H", "Content-Type: application/json", "--data-binary", "@-", "-w", `
|
|
481
|
+
__CURL_STATUS__:%{http_code}`], JSON.stringify(requestBody));
|
|
482
|
+
if (response.status < 200 || response.status >= 300) {
|
|
483
|
+
const data = JSON.parse(response.body);
|
|
484
|
+
const isQuotaExceeded = data.error === "quota_exceeded";
|
|
485
|
+
return {
|
|
486
|
+
ok: false,
|
|
487
|
+
error: String(data.error ?? `HTTP ${response.status}`),
|
|
488
|
+
quotaExceeded: isQuotaExceeded,
|
|
489
|
+
paymentType: isQuotaExceeded ? String(data.paymentType) : undefined
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
let finalState = null;
|
|
493
|
+
let finalQuota = null;
|
|
494
|
+
for (const line of response.body.split(`
|
|
495
|
+
`)) {
|
|
496
|
+
const trimmed = line.trim();
|
|
497
|
+
if (!trimmed || !trimmed.startsWith("data: "))
|
|
498
|
+
continue;
|
|
499
|
+
try {
|
|
500
|
+
const data = JSON.parse(trimmed.slice(6));
|
|
501
|
+
if (data.token)
|
|
502
|
+
onToken(data.token);
|
|
503
|
+
if (data.done && data.state && data.quota) {
|
|
504
|
+
finalState = data.state;
|
|
505
|
+
finalQuota = data.quota;
|
|
506
|
+
}
|
|
507
|
+
} catch {}
|
|
508
|
+
}
|
|
509
|
+
if (finalState && finalQuota) {
|
|
510
|
+
return { ok: true, state: finalState, quota: finalQuota };
|
|
511
|
+
}
|
|
512
|
+
return { ok: false, error: "stream ended without state" };
|
|
513
|
+
}
|
|
514
|
+
async function post(path2, body) {
|
|
515
|
+
let lastError = "unknown error";
|
|
516
|
+
for (const apiUrl of API_URLS) {
|
|
517
|
+
try {
|
|
518
|
+
const requestBody = _token ? { ...body, token: _token } : body;
|
|
519
|
+
const res = await fetch(`${apiUrl}${path2}`, {
|
|
520
|
+
method: "POST",
|
|
521
|
+
headers: { "Content-Type": "application/json" },
|
|
522
|
+
body: JSON.stringify(requestBody)
|
|
523
|
+
});
|
|
524
|
+
const data = await res.json();
|
|
525
|
+
if (!res.ok) {
|
|
526
|
+
const isQuotaExceeded = data.error === "quota_exceeded";
|
|
527
|
+
return {
|
|
528
|
+
ok: false,
|
|
529
|
+
error: String(data.error ?? `HTTP ${res.status}`),
|
|
530
|
+
quotaExceeded: isQuotaExceeded,
|
|
531
|
+
paymentType: isQuotaExceeded ? String(data.paymentType) : undefined
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
return { ok: true, data };
|
|
535
|
+
} catch (err) {
|
|
536
|
+
if (isRetryableNetworkError(err)) {
|
|
537
|
+
try {
|
|
538
|
+
return await postWithCurl(apiUrl, path2, body);
|
|
539
|
+
} catch (curlErr) {
|
|
540
|
+
lastError = curlErr instanceof Error ? curlErr.message : "unknown error";
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
lastError = err instanceof Error ? err.message : "unknown error";
|
|
435
545
|
}
|
|
436
|
-
return { ok: true, data };
|
|
437
|
-
} catch (err) {
|
|
438
|
-
const msg = err instanceof Error ? err.message : "unknown error";
|
|
439
|
-
return { ok: false, error: msg };
|
|
440
546
|
}
|
|
547
|
+
return { ok: false, error: lastError };
|
|
441
548
|
}
|
|
442
549
|
function syncState(userId, localState) {
|
|
443
550
|
return post("/api/sync", { userId, localState });
|
|
@@ -452,69 +559,88 @@ async function persistState(userId, state, quota) {
|
|
|
452
559
|
return result;
|
|
453
560
|
}
|
|
454
561
|
async function sendChatStream(userId, companion, state, message, onToken) {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
body.token = _token;
|
|
467
|
-
const res = await fetch(`${API_URL}/api/chat`, {
|
|
468
|
-
method: "POST",
|
|
469
|
-
headers: { "Content-Type": "application/json" },
|
|
470
|
-
body: JSON.stringify(body)
|
|
471
|
-
});
|
|
472
|
-
if (!res.ok) {
|
|
473
|
-
const data = await res.json();
|
|
474
|
-
const isQuotaExceeded = data.error === "quota_exceeded";
|
|
475
|
-
return {
|
|
476
|
-
ok: false,
|
|
477
|
-
error: String(data.error ?? `HTTP ${res.status}`),
|
|
478
|
-
quotaExceeded: isQuotaExceeded,
|
|
479
|
-
paymentType: isQuotaExceeded ? String(data.paymentType) : undefined
|
|
562
|
+
let lastError = "unknown error";
|
|
563
|
+
for (const apiUrl of API_URLS) {
|
|
564
|
+
try {
|
|
565
|
+
const body = {
|
|
566
|
+
userId,
|
|
567
|
+
name: companion.name,
|
|
568
|
+
personality: companion.personality,
|
|
569
|
+
species: companion.species,
|
|
570
|
+
mood: deriveMood(state),
|
|
571
|
+
needs: state.needs,
|
|
572
|
+
message
|
|
480
573
|
};
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
574
|
+
if (_token)
|
|
575
|
+
body.token = _token;
|
|
576
|
+
const res = await fetch(`${apiUrl}/api/chat`, {
|
|
577
|
+
method: "POST",
|
|
578
|
+
headers: { "Content-Type": "application/json" },
|
|
579
|
+
body: JSON.stringify(body)
|
|
580
|
+
});
|
|
581
|
+
if (!res.ok) {
|
|
582
|
+
const data = await res.json();
|
|
583
|
+
const isQuotaExceeded = data.error === "quota_exceeded";
|
|
584
|
+
return {
|
|
585
|
+
ok: false,
|
|
586
|
+
error: String(data.error ?? `HTTP ${res.status}`),
|
|
587
|
+
quotaExceeded: isQuotaExceeded,
|
|
588
|
+
paymentType: isQuotaExceeded ? String(data.paymentType) : undefined
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
const reader = res.body.getReader();
|
|
592
|
+
const decoder = new TextDecoder;
|
|
593
|
+
let buffer = "";
|
|
594
|
+
let finalState = null;
|
|
595
|
+
let finalQuota = null;
|
|
596
|
+
while (true) {
|
|
597
|
+
const { done, value } = await reader.read();
|
|
598
|
+
if (done)
|
|
599
|
+
break;
|
|
600
|
+
buffer += decoder.decode(value, { stream: true });
|
|
601
|
+
const lines = buffer.split(`
|
|
493
602
|
`);
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
603
|
+
buffer = lines.pop();
|
|
604
|
+
for (const line of lines) {
|
|
605
|
+
const trimmed = line.trim();
|
|
606
|
+
if (!trimmed || !trimmed.startsWith("data: "))
|
|
607
|
+
continue;
|
|
608
|
+
try {
|
|
609
|
+
const data = JSON.parse(trimmed.slice(6));
|
|
610
|
+
if (data.token)
|
|
611
|
+
onToken(data.token);
|
|
612
|
+
if (data.done && data.state && data.quota) {
|
|
613
|
+
finalState = data.state;
|
|
614
|
+
finalQuota = data.quota;
|
|
615
|
+
}
|
|
616
|
+
} catch {}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (finalState && finalQuota) {
|
|
620
|
+
return { ok: true, state: finalState, quota: finalQuota };
|
|
621
|
+
}
|
|
622
|
+
return { ok: false, error: "stream ended without state" };
|
|
623
|
+
} catch (err) {
|
|
624
|
+
if (isRetryableNetworkError(err)) {
|
|
499
625
|
try {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
626
|
+
return await sendChatStreamWithCurl(apiUrl, {
|
|
627
|
+
userId,
|
|
628
|
+
name: companion.name,
|
|
629
|
+
personality: companion.personality,
|
|
630
|
+
species: companion.species,
|
|
631
|
+
mood: deriveMood(state),
|
|
632
|
+
needs: state.needs,
|
|
633
|
+
message
|
|
634
|
+
}, onToken);
|
|
635
|
+
} catch (curlErr) {
|
|
636
|
+
lastError = curlErr instanceof Error ? curlErr.message : "unknown error";
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
508
639
|
}
|
|
640
|
+
lastError = err instanceof Error ? err.message : "unknown error";
|
|
509
641
|
}
|
|
510
|
-
if (finalState && finalQuota) {
|
|
511
|
-
return { ok: true, state: finalState, quota: finalQuota };
|
|
512
|
-
}
|
|
513
|
-
return { ok: false, error: "stream ended without state" };
|
|
514
|
-
} catch (err) {
|
|
515
|
-
const msg = err instanceof Error ? err.message : "unknown error";
|
|
516
|
-
return { ok: false, error: msg };
|
|
517
642
|
}
|
|
643
|
+
return { ok: false, error: lastError };
|
|
518
644
|
}
|
|
519
645
|
function sendInteraction(userId, type) {
|
|
520
646
|
return post("/api/interaction", { userId, type });
|
|
@@ -1797,8 +1923,8 @@ import { jsxDEV as jsxDEV3, Fragment as Fragment3 } from "react/jsx-dev-runtime"
|
|
|
1797
1923
|
var POLL_INTERVAL3 = 3000;
|
|
1798
1924
|
var MAX_POLL_TIME3 = 5 * 60000;
|
|
1799
1925
|
var LABELS = {
|
|
1800
|
-
conversation: { title: "购买对话次数", desc: "20次对话 ¥0.50", amount: "¥0.50" },
|
|
1801
|
-
interaction: { title: "购买互动次数", desc: "10次互动 ¥0.50", amount: "¥0.50" }
|
|
1926
|
+
conversation: { title: "购买对话次数", count: "20次", desc: "20次对话 ¥0.50", amount: "¥0.50" },
|
|
1927
|
+
interaction: { title: "购买互动次数", count: "10次", desc: "10次互动 ¥0.50", amount: "¥0.50" }
|
|
1802
1928
|
};
|
|
1803
1929
|
function QuotaPayment({ userId, paymentType, onComplete, onCancel }) {
|
|
1804
1930
|
const [phase, setPhase] = useState3("qr");
|
|
@@ -1907,7 +2033,9 @@ function QuotaPayment({ userId, paymentType, onComplete, onCancel }) {
|
|
|
1907
2033
|
color: "yellow",
|
|
1908
2034
|
children: [
|
|
1909
2035
|
labels.title,
|
|
1910
|
-
"
|
|
2036
|
+
"(",
|
|
2037
|
+
labels.count,
|
|
2038
|
+
") — 微信支付 ",
|
|
1911
2039
|
labels.amount
|
|
1912
2040
|
]
|
|
1913
2041
|
}, undefined, true, undefined, this),
|