@chainlesschain/personal-data-hub 0.1.0 → 0.2.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/__tests__/adapters/ai-chat-history.test.js +395 -0
- package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
- package/__tests__/adapters/ai-chat-vendors.test.js +733 -0
- package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
- package/__tests__/adapters/email-adapter.test.js +138 -1
- package/__tests__/adapters/email-classifier.test.js +347 -0
- package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
- package/__tests__/adapters/email-retry-progress.test.js +294 -0
- package/__tests__/adapters/email-templates.test.js +699 -0
- package/__tests__/adapters/system-data-adapter.test.js +440 -0
- package/__tests__/adapters/system-data-disclosure.test.js +153 -0
- package/__tests__/analysis-skills.test.js +409 -0
- package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
- package/__tests__/entity-resolver-stages.test.js +411 -0
- package/__tests__/entity-resolver-vault.test.js +246 -0
- package/__tests__/entity-resolver.test.js +526 -0
- package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
- package/__tests__/longtail-adapters.test.js +217 -0
- package/__tests__/mobile-extractor.test.js +288 -0
- package/__tests__/shopping-adapters.test.js +296 -0
- package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
- package/__tests__/sidecar-supervisor.test.js +120 -0
- package/__tests__/social-adapters.test.js +206 -0
- package/__tests__/travel-adapters.test.js +325 -0
- package/__tests__/vault.test.js +3 -3
- package/__tests__/wechat-adapter.test.js +476 -0
- package/__tests__/whatsapp-adapter.test.js +135 -0
- package/lib/adapter-spec.js +12 -0
- package/lib/adapters/_python-sidecar-base.js +207 -0
- package/lib/adapters/ai-chat-history/ai-chat-adapter.js +335 -0
- package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
- package/lib/adapters/ai-chat-history/http-client.js +211 -0
- package/lib/adapters/ai-chat-history/index.js +28 -0
- package/lib/adapters/ai-chat-history/schema-map.js +221 -0
- package/lib/adapters/ai-chat-history/vendor-spec.js +85 -0
- package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
- package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
- package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
- package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
- package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
- package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
- package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
- package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
- package/lib/adapters/alipay-bill/alipay-bill-adapter.js +307 -0
- package/lib/adapters/alipay-bill/counterparty.js +129 -0
- package/lib/adapters/alipay-bill/csv-parser.js +217 -0
- package/lib/adapters/alipay-bill/index.js +41 -0
- package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
- package/lib/adapters/email-imap/classifier.js +495 -0
- package/lib/adapters/email-imap/email-adapter.js +419 -8
- package/lib/adapters/email-imap/index.js +42 -0
- package/lib/adapters/email-imap/pdf-extractor.js +192 -0
- package/lib/adapters/email-imap/templates/bill.js +232 -0
- package/lib/adapters/email-imap/templates/government.js +120 -0
- package/lib/adapters/email-imap/templates/index.js +78 -0
- package/lib/adapters/email-imap/templates/order.js +186 -0
- package/lib/adapters/email-imap/templates/other.js +114 -0
- package/lib/adapters/email-imap/templates/register.js +113 -0
- package/lib/adapters/email-imap/templates/travel.js +157 -0
- package/lib/adapters/email-imap/templates/utils.js +275 -0
- package/lib/adapters/email-imap/transactions.js +234 -0
- package/lib/adapters/messaging-qq/index.js +158 -0
- package/lib/adapters/messaging-telegram/index.js +142 -0
- package/lib/adapters/messaging-whatsapp/index.js +189 -0
- package/lib/adapters/shopping-base/index.js +208 -0
- package/lib/adapters/shopping-jd/index.js +150 -0
- package/lib/adapters/shopping-meituan/index.js +154 -0
- package/lib/adapters/shopping-taobao/index.js +176 -0
- package/lib/adapters/social-bilibili/index.js +171 -0
- package/lib/adapters/social-douyin/index.js +116 -0
- package/lib/adapters/social-weibo/index.js +164 -0
- package/lib/adapters/social-xiaohongshu/index.js +96 -0
- package/lib/adapters/system-data/disclosure.js +166 -0
- package/lib/adapters/system-data/index.js +34 -0
- package/lib/adapters/system-data/system-data-adapter.js +344 -0
- package/lib/adapters/travel-12306/index.js +151 -0
- package/lib/adapters/travel-amap/index.js +164 -0
- package/lib/adapters/travel-baidu-map/index.js +162 -0
- package/lib/adapters/travel-base/index.js +240 -0
- package/lib/adapters/travel-ctrip/index.js +151 -0
- package/lib/adapters/wechat/content-parser.js +326 -0
- package/lib/adapters/wechat/db-reader.js +209 -0
- package/lib/adapters/wechat/index.js +28 -0
- package/lib/adapters/wechat/key-extractor.js +158 -0
- package/lib/adapters/wechat/normalize.js +220 -0
- package/lib/adapters/wechat/wechat-adapter.js +205 -0
- package/lib/analysis-skills/base.js +113 -0
- package/lib/analysis-skills/footprint.js +167 -0
- package/lib/analysis-skills/index.js +58 -0
- package/lib/analysis-skills/interests.js +161 -0
- package/lib/analysis-skills/relations.js +226 -0
- package/lib/analysis-skills/spending.js +216 -0
- package/lib/analysis-skills/timeline.js +167 -0
- package/lib/entity-resolver/embedding-stage.js +198 -0
- package/lib/entity-resolver/entity-resolver.js +384 -0
- package/lib/entity-resolver/index.js +42 -0
- package/lib/entity-resolver/llm-stage.js +191 -0
- package/lib/entity-resolver/rule-stage.js +208 -0
- package/lib/entity-resolver/worker.js +149 -0
- package/lib/index.js +115 -0
- package/lib/migrations.js +73 -0
- package/lib/mobile-extractor/android.js +193 -0
- package/lib/mobile-extractor/index.js +9 -0
- package/lib/mobile-extractor/ios.js +223 -0
- package/lib/registry.js +42 -0
- package/lib/sidecar/index.js +15 -0
- package/lib/sidecar/supervisor.js +359 -0
- package/lib/vault.js +266 -0
- package/package.json +29 -3
- package/scripts/_make-fixture-all.js +126 -0
- package/scripts/_make-fixture-contacts.js +84 -0
- package/scripts/evaluate-entity-resolver.js +213 -0
- package/scripts/smoke-phase-5-5.js +196 -0
- package/scripts/smoke-phase-5-7.js +181 -0
- package/scripts/smoke-system-data-contacts.js +309 -0
- package/scripts/smoke-system-data.js +312 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client wrapper used by AI-chat vendor sub-adapters.
|
|
3
|
+
*
|
|
4
|
+
* Three responsibilities:
|
|
5
|
+
*
|
|
6
|
+
* 1. Inject cookies from a CookieAuthSession (per request, per domain).
|
|
7
|
+
* 2. Apply per-vendor `rateLimits` — at least minIntervalMs between calls
|
|
8
|
+
* and at most perMinute calls in any rolling 60s window. Vendor wiring
|
|
9
|
+
* shares one HttpClient instance so all its endpoints obey the same gate.
|
|
10
|
+
* 3. Retry on 5xx / network-timeout with bounded exponential backoff.
|
|
11
|
+
*
|
|
12
|
+
* The actual network call is delegated to `opts.fetch` (defaults to global
|
|
13
|
+
* fetch in Node 22+). Tests inject a stub that records requests + returns
|
|
14
|
+
* canned responses, so the whole vendor wiring is verifiable without real
|
|
15
|
+
* cookies / live servers.
|
|
16
|
+
*
|
|
17
|
+
* No external dependency — same pattern as `lib/llm-client.js`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
"use strict";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_RATE_LIMITS = Object.freeze({
|
|
23
|
+
perMinute: 30,
|
|
24
|
+
minIntervalMs: 1500,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
class RateLimitedError extends Error {
|
|
28
|
+
constructor(retryAfterMs, vendor) {
|
|
29
|
+
super(`rate limited (vendor=${vendor || "?"}, retryAfterMs=${retryAfterMs})`);
|
|
30
|
+
this.code = "RATE_LIMITED";
|
|
31
|
+
this.retryAfterMs = retryAfterMs;
|
|
32
|
+
this.vendor = vendor;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class CookieExpiredError extends Error {
|
|
37
|
+
constructor(vendor, hint) {
|
|
38
|
+
super(`cookie expired or invalid (vendor=${vendor})${hint ? ": " + hint : ""}`);
|
|
39
|
+
this.code = "COOKIE_EXPIRED";
|
|
40
|
+
this.vendor = vendor;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class HttpClient {
|
|
45
|
+
/**
|
|
46
|
+
* @param {object} opts
|
|
47
|
+
* @param {string} opts.vendor Vendor name (for error messages + logs).
|
|
48
|
+
* @param {object} [opts.rateLimits] { perMinute, minIntervalMs } — defaults to DEFAULT_RATE_LIMITS.
|
|
49
|
+
* @param {function} [opts.fetch] Fetch impl override. Defaults to global fetch.
|
|
50
|
+
* @param {function} [opts.sleep] Sleep impl override (test seam). Defaults to setTimeout.
|
|
51
|
+
* @param {function} [opts.now] Clock override (test seam). Defaults to Date.now.
|
|
52
|
+
* @param {number} [opts.maxRetries] Max retries on 5xx / timeout. Default 3.
|
|
53
|
+
* @param {number} [opts.baseBackoffMs] Initial retry backoff. Default 500.
|
|
54
|
+
* @param {object} [opts.logger]
|
|
55
|
+
*/
|
|
56
|
+
constructor(opts = {}) {
|
|
57
|
+
if (typeof opts.vendor !== "string" || opts.vendor.length === 0) {
|
|
58
|
+
throw new Error("HttpClient: opts.vendor required");
|
|
59
|
+
}
|
|
60
|
+
this.vendor = opts.vendor;
|
|
61
|
+
this.rateLimits = {
|
|
62
|
+
...DEFAULT_RATE_LIMITS,
|
|
63
|
+
...(opts.rateLimits || {}),
|
|
64
|
+
};
|
|
65
|
+
this._fetch = opts.fetch || (typeof fetch !== "undefined" ? fetch : null);
|
|
66
|
+
if (!this._fetch) {
|
|
67
|
+
throw new Error("HttpClient: no fetch available. Node 22+ required, or pass opts.fetch.");
|
|
68
|
+
}
|
|
69
|
+
this._sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
70
|
+
this._now = opts.now || (() => Date.now());
|
|
71
|
+
this._maxRetries = Number.isFinite(opts.maxRetries) ? opts.maxRetries : 3;
|
|
72
|
+
this._baseBackoffMs = Number.isFinite(opts.baseBackoffMs) ? opts.baseBackoffMs : 500;
|
|
73
|
+
this._logger = opts.logger || { info: () => {}, warn: () => {}, error: () => {} };
|
|
74
|
+
|
|
75
|
+
// Sliding window of recent request timestamps for perMinute enforcement.
|
|
76
|
+
this._recent = [];
|
|
77
|
+
this._lastCallAt = 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Make one rate-limited + cookie-injected request. Throws CookieExpiredError
|
|
82
|
+
* on 401/403, RateLimitedError on 429 after exhausting retries, generic
|
|
83
|
+
* Error on other failures.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} url
|
|
86
|
+
* @param {object} [reqInit]
|
|
87
|
+
* @param {CookieAuthSession} [reqInit.session] Cookies to inject. Must match opts.vendor.
|
|
88
|
+
* @param {string} [reqInit.matchDomain] Cookie domain filter.
|
|
89
|
+
*/
|
|
90
|
+
async request(url, reqInit = {}) {
|
|
91
|
+
await this._enforceRateLimit();
|
|
92
|
+
|
|
93
|
+
const headers = { ...(reqInit.headers || {}) };
|
|
94
|
+
if (reqInit.session) {
|
|
95
|
+
if (reqInit.session.vendor !== this.vendor) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`HttpClient(${this.vendor}): session vendor "${reqInit.session.vendor}" mismatch`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
reqInit.session.applyTo(headers, reqInit.matchDomain);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const fetchInit = {
|
|
104
|
+
method: reqInit.method || "GET",
|
|
105
|
+
headers,
|
|
106
|
+
...(reqInit.body !== undefined ? { body: reqInit.body } : {}),
|
|
107
|
+
...(reqInit.signal ? { signal: reqInit.signal } : {}),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
let lastErr;
|
|
111
|
+
for (let attempt = 0; attempt <= this._maxRetries; attempt++) {
|
|
112
|
+
let resp;
|
|
113
|
+
try {
|
|
114
|
+
resp = await this._fetch(url, fetchInit);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
lastErr = err;
|
|
117
|
+
if (attempt < this._maxRetries) {
|
|
118
|
+
await this._sleep(this._backoffMs(attempt));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
125
|
+
throw new CookieExpiredError(this.vendor, `HTTP ${resp.status}`);
|
|
126
|
+
}
|
|
127
|
+
if (resp.status === 429) {
|
|
128
|
+
const retryHdr = resp.headers && typeof resp.headers.get === "function"
|
|
129
|
+
? resp.headers.get("retry-after")
|
|
130
|
+
: null;
|
|
131
|
+
const retryAfterMs = retryHdr ? (Number(retryHdr) * 1000 || this._backoffMs(attempt)) : this._backoffMs(attempt);
|
|
132
|
+
if (attempt < this._maxRetries) {
|
|
133
|
+
this._logger.warn(`[${this.vendor}] 429 retry-after=${retryAfterMs}ms attempt=${attempt}`);
|
|
134
|
+
await this._sleep(retryAfterMs);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
throw new RateLimitedError(retryAfterMs, this.vendor);
|
|
138
|
+
}
|
|
139
|
+
if (resp.status >= 500 && resp.status < 600) {
|
|
140
|
+
lastErr = new Error(`HTTP ${resp.status}`);
|
|
141
|
+
if (attempt < this._maxRetries) {
|
|
142
|
+
await this._sleep(this._backoffMs(attempt));
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
throw lastErr;
|
|
146
|
+
}
|
|
147
|
+
return resp;
|
|
148
|
+
}
|
|
149
|
+
throw lastErr || new Error("HttpClient: exhausted retries");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* GET shorthand that decodes JSON.
|
|
154
|
+
*/
|
|
155
|
+
async getJson(url, reqInit = {}) {
|
|
156
|
+
const resp = await this.request(url, reqInit);
|
|
157
|
+
if (!resp.ok) {
|
|
158
|
+
throw new Error(`HttpClient(${this.vendor}) GET ${url} → HTTP ${resp.status}`);
|
|
159
|
+
}
|
|
160
|
+
return resp.json();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async postJson(url, body, reqInit = {}) {
|
|
164
|
+
const headers = { "content-type": "application/json", ...(reqInit.headers || {}) };
|
|
165
|
+
const resp = await this.request(url, { ...reqInit, method: "POST", headers, body: JSON.stringify(body) });
|
|
166
|
+
if (!resp.ok) {
|
|
167
|
+
throw new Error(`HttpClient(${this.vendor}) POST ${url} → HTTP ${resp.status}`);
|
|
168
|
+
}
|
|
169
|
+
return resp.json();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async _enforceRateLimit() {
|
|
173
|
+
const now = this._now();
|
|
174
|
+
|
|
175
|
+
// perMinute window
|
|
176
|
+
if (this.rateLimits.perMinute > 0) {
|
|
177
|
+
const cutoff = now - 60_000;
|
|
178
|
+
this._recent = this._recent.filter((t) => t >= cutoff);
|
|
179
|
+
if (this._recent.length >= this.rateLimits.perMinute) {
|
|
180
|
+
const wait = this._recent[0] + 60_000 - now;
|
|
181
|
+
if (wait > 0) await this._sleep(wait);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// minIntervalMs between consecutive calls
|
|
186
|
+
if (this.rateLimits.minIntervalMs > 0 && this._lastCallAt > 0) {
|
|
187
|
+
const since = now - this._lastCallAt;
|
|
188
|
+
if (since < this.rateLimits.minIntervalMs) {
|
|
189
|
+
await this._sleep(this.rateLimits.minIntervalMs - since);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const tick = this._now();
|
|
194
|
+
this._recent.push(tick);
|
|
195
|
+
this._lastCallAt = tick;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_backoffMs(attempt) {
|
|
199
|
+
// 500 → 1000 → 2000 → 4000 (capped at 4 attempts) + ±20% jitter
|
|
200
|
+
const base = this._baseBackoffMs * Math.pow(2, attempt);
|
|
201
|
+
const jitter = base * 0.2 * (Math.random() * 2 - 1);
|
|
202
|
+
return Math.max(0, Math.floor(base + jitter));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
HttpClient,
|
|
208
|
+
RateLimitedError,
|
|
209
|
+
CookieExpiredError,
|
|
210
|
+
DEFAULT_RATE_LIMITS,
|
|
211
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
AIChatHistoryAdapter,
|
|
5
|
+
SUPPORTED_VENDORS,
|
|
6
|
+
DEFAULT_VENDOR_SPECS,
|
|
7
|
+
ADAPTER_NAME,
|
|
8
|
+
ADAPTER_VERSION,
|
|
9
|
+
} = require("./ai-chat-adapter");
|
|
10
|
+
const { CookieAuthSession } = require("./cookie-auth");
|
|
11
|
+
const { NotImplementedYetError, assertVendorSpec } = require("./vendor-spec");
|
|
12
|
+
const { HttpClient, RateLimitedError, CookieExpiredError } = require("./http-client");
|
|
13
|
+
const schemaMap = require("./schema-map");
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
AIChatHistoryAdapter,
|
|
17
|
+
SUPPORTED_VENDORS,
|
|
18
|
+
DEFAULT_VENDOR_SPECS,
|
|
19
|
+
ADAPTER_NAME,
|
|
20
|
+
ADAPTER_VERSION,
|
|
21
|
+
CookieAuthSession,
|
|
22
|
+
NotImplementedYetError,
|
|
23
|
+
HttpClient,
|
|
24
|
+
RateLimitedError,
|
|
25
|
+
CookieExpiredError,
|
|
26
|
+
assertVendorSpec,
|
|
27
|
+
schemaMap,
|
|
28
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RawConversation / RawMessage → UnifiedSchema normalized batch.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `docs/design/Adapter_AIChat_History.md` §5.2. This module is
|
|
5
|
+
* pure-transform: given a RawConversation + its RawMessage[], it returns a
|
|
6
|
+
* {events, persons, topics, items} bundle ready for the AdapterRegistry to
|
|
7
|
+
* write into LocalVault.
|
|
8
|
+
*
|
|
9
|
+
* Why pure: it's the easiest part to test exhaustively without any HTTP
|
|
10
|
+
* mocks. Vendor sub-adapters (skeletons in Phase 10.1) only need to produce
|
|
11
|
+
* Raw* objects matching the documented shape; this module handles the rest.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
ENTITY_TYPES,
|
|
18
|
+
PERSON_SUBTYPES,
|
|
19
|
+
EVENT_SUBTYPES,
|
|
20
|
+
ITEM_SUBTYPES,
|
|
21
|
+
CAPTURED_BY,
|
|
22
|
+
} = require("../../constants");
|
|
23
|
+
const { newId } = require("../../ids");
|
|
24
|
+
|
|
25
|
+
const SELF_PERSON_ID = "person-self";
|
|
26
|
+
const ADAPTER_NAME = "ai-chat-history";
|
|
27
|
+
const ADAPTER_VERSION = "0.1.0";
|
|
28
|
+
|
|
29
|
+
function personIdForVendor(vendor) {
|
|
30
|
+
return `person-ai-${vendor}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function topicIdForConversation(vendor, originalConvId) {
|
|
34
|
+
return `topic-aiconv-${vendor}-${originalConvId}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the vendor "AI agent" Person entity. The hub upserts these once per
|
|
39
|
+
* vendor — the consistent `id` form means re-emitting from multiple sync
|
|
40
|
+
* passes is idempotent.
|
|
41
|
+
*/
|
|
42
|
+
function buildVendorPerson(vendor, displayName) {
|
|
43
|
+
return {
|
|
44
|
+
id: personIdForVendor(vendor),
|
|
45
|
+
type: ENTITY_TYPES.PERSON,
|
|
46
|
+
subtype: PERSON_SUBTYPES.AI_AGENT,
|
|
47
|
+
names: [displayName],
|
|
48
|
+
identifiers: { vendor },
|
|
49
|
+
notes: `${displayName} — 用户的 ${displayName} AI 助手账户`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the conversation Topic entity.
|
|
55
|
+
*/
|
|
56
|
+
function buildConversationTopic(rawConv) {
|
|
57
|
+
return {
|
|
58
|
+
id: topicIdForConversation(rawConv.vendor, rawConv.originalId),
|
|
59
|
+
type: ENTITY_TYPES.TOPIC,
|
|
60
|
+
name: rawConv.title || "(无标题对话)",
|
|
61
|
+
extra: {
|
|
62
|
+
vendor: rawConv.vendor,
|
|
63
|
+
kind: "ai-conversation",
|
|
64
|
+
modelName: rawConv.modelName,
|
|
65
|
+
conversationOriginalId: rawConv.originalId,
|
|
66
|
+
createdAt: rawConv.createdAt,
|
|
67
|
+
updatedAt: rawConv.updatedAt,
|
|
68
|
+
messageCount: rawConv.messageCount,
|
|
69
|
+
archived: Boolean(rawConv.archived),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build an Event entity for one message.
|
|
76
|
+
*
|
|
77
|
+
* - role "user" → actor = self, subtype = ai-message
|
|
78
|
+
* - role "assistant" → actor = person-ai-vendor, subtype = ai-message
|
|
79
|
+
* - vendor=dreamina + generatedImages → subtype overridden to ai-image-generation
|
|
80
|
+
*/
|
|
81
|
+
function buildMessageEvent(rawMsg, capturedAt) {
|
|
82
|
+
const vendorPersonId = personIdForVendor(rawMsg.vendor);
|
|
83
|
+
const actor = rawMsg.role === "user" ? SELF_PERSON_ID : vendorPersonId;
|
|
84
|
+
const subtype =
|
|
85
|
+
rawMsg.content && Array.isArray(rawMsg.content.generatedImages) && rawMsg.content.generatedImages.length > 0
|
|
86
|
+
? EVENT_SUBTYPES.AI_IMAGE_GENERATION
|
|
87
|
+
: EVENT_SUBTYPES.AI_MESSAGE;
|
|
88
|
+
return {
|
|
89
|
+
id: newId(),
|
|
90
|
+
type: ENTITY_TYPES.EVENT,
|
|
91
|
+
subtype,
|
|
92
|
+
occurredAt: new Date(rawMsg.createdAt).toISOString(),
|
|
93
|
+
actor,
|
|
94
|
+
participants: [SELF_PERSON_ID, vendorPersonId],
|
|
95
|
+
content: {
|
|
96
|
+
text: (rawMsg.content && rawMsg.content.text) || undefined,
|
|
97
|
+
mediaRefs: rawMsg.content && Array.isArray(rawMsg.content.attachments)
|
|
98
|
+
? rawMsg.content.attachments.map((a) => a.url).filter(Boolean)
|
|
99
|
+
: undefined,
|
|
100
|
+
},
|
|
101
|
+
topics: [topicIdForConversation(rawMsg.vendor, rawMsg.conversationId)],
|
|
102
|
+
source: {
|
|
103
|
+
adapter: ADAPTER_NAME,
|
|
104
|
+
adapterVersion: ADAPTER_VERSION,
|
|
105
|
+
originalId: `${rawMsg.vendor}/${rawMsg.originalId}`,
|
|
106
|
+
capturedAt: new Date(capturedAt || Date.now()).toISOString(),
|
|
107
|
+
capturedBy: CAPTURED_BY.API,
|
|
108
|
+
},
|
|
109
|
+
extra: {
|
|
110
|
+
vendor: rawMsg.vendor,
|
|
111
|
+
conversationOriginalId: rawMsg.conversationId,
|
|
112
|
+
role: rawMsg.role,
|
|
113
|
+
modelName: rawMsg.modelName,
|
|
114
|
+
parentMessageId: rawMsg.parentMessageId,
|
|
115
|
+
toolCalls: rawMsg.content ? rawMsg.content.toolCalls : undefined,
|
|
116
|
+
generatedImages: rawMsg.content ? rawMsg.content.generatedImages : undefined,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build Item entities for any generated images. Each image becomes a `media`
|
|
123
|
+
* Item so KG queries like "what images did I generate this month" work.
|
|
124
|
+
*/
|
|
125
|
+
function buildGeneratedImageItems(rawMsg) {
|
|
126
|
+
if (!rawMsg.content || !Array.isArray(rawMsg.content.generatedImages)) return [];
|
|
127
|
+
return rawMsg.content.generatedImages.map((img) => ({
|
|
128
|
+
id: newId(),
|
|
129
|
+
type: ENTITY_TYPES.ITEM,
|
|
130
|
+
subtype: ITEM_SUBTYPES.MEDIA,
|
|
131
|
+
name: (img.prompt || "AI image").slice(0, 80),
|
|
132
|
+
extra: {
|
|
133
|
+
vendor: rawMsg.vendor,
|
|
134
|
+
kind: "ai-generated-image",
|
|
135
|
+
prompt: img.prompt,
|
|
136
|
+
model: img.model,
|
|
137
|
+
url: img.url,
|
|
138
|
+
params: img.params,
|
|
139
|
+
},
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Convert one RawConversation + its messages into a NormalizedBatch.
|
|
145
|
+
*
|
|
146
|
+
* @param {object} rawConv
|
|
147
|
+
* @param {Array} rawMessages
|
|
148
|
+
* @param {object} [vendorMeta] { displayName: string }
|
|
149
|
+
* @param {number} [capturedAt]
|
|
150
|
+
* @returns {{events:Array,persons:Array,places:Array,items:Array,topics:Array}}
|
|
151
|
+
*/
|
|
152
|
+
function conversationToBatch(rawConv, rawMessages, vendorMeta, capturedAt) {
|
|
153
|
+
if (!rawConv || typeof rawConv !== "object") {
|
|
154
|
+
throw new Error("conversationToBatch: rawConv required");
|
|
155
|
+
}
|
|
156
|
+
if (!Array.isArray(rawMessages)) {
|
|
157
|
+
throw new Error("conversationToBatch: rawMessages must be an array");
|
|
158
|
+
}
|
|
159
|
+
const vendorPerson = buildVendorPerson(rawConv.vendor, (vendorMeta && vendorMeta.displayName) || rawConv.vendor);
|
|
160
|
+
const topic = buildConversationTopic(rawConv);
|
|
161
|
+
|
|
162
|
+
const events = [];
|
|
163
|
+
const items = [];
|
|
164
|
+
for (const msg of rawMessages) {
|
|
165
|
+
if (msg.vendor !== rawConv.vendor) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`conversationToBatch: message vendor "${msg.vendor}" != conv vendor "${rawConv.vendor}"`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
events.push(buildMessageEvent(msg, capturedAt));
|
|
171
|
+
items.push(...buildGeneratedImageItems(msg));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
events,
|
|
176
|
+
persons: [vendorPerson],
|
|
177
|
+
places: [],
|
|
178
|
+
items,
|
|
179
|
+
topics: [topic],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Merge multiple NormalizedBatch results. Vendor Person is deduped by id
|
|
185
|
+
* (every batch declares the same `person-ai-<vendor>` so we collapse).
|
|
186
|
+
*/
|
|
187
|
+
function mergeBatches(batches) {
|
|
188
|
+
const events = [];
|
|
189
|
+
const personsById = new Map();
|
|
190
|
+
const places = [];
|
|
191
|
+
const items = [];
|
|
192
|
+
const topicsById = new Map();
|
|
193
|
+
for (const b of batches) {
|
|
194
|
+
for (const e of b.events) events.push(e);
|
|
195
|
+
for (const p of b.persons) personsById.set(p.id, p);
|
|
196
|
+
for (const pl of b.places) places.push(pl);
|
|
197
|
+
for (const it of b.items) items.push(it);
|
|
198
|
+
for (const t of b.topics) topicsById.set(t.id, t);
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
events,
|
|
202
|
+
persons: [...personsById.values()],
|
|
203
|
+
places,
|
|
204
|
+
items,
|
|
205
|
+
topics: [...topicsById.values()],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
SELF_PERSON_ID,
|
|
211
|
+
ADAPTER_NAME,
|
|
212
|
+
ADAPTER_VERSION,
|
|
213
|
+
personIdForVendor,
|
|
214
|
+
topicIdForConversation,
|
|
215
|
+
buildVendorPerson,
|
|
216
|
+
buildConversationTopic,
|
|
217
|
+
buildMessageEvent,
|
|
218
|
+
buildGeneratedImageItems,
|
|
219
|
+
conversationToBatch,
|
|
220
|
+
mergeBatches,
|
|
221
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VendorSpec — per-AI-vendor contract used by AIChatHistoryAdapter.
|
|
3
|
+
*
|
|
4
|
+
* Each of the 8 supported vendors (DeepSeek / Kimi / 通义 / 智谱 / 混元 / 千帆 /
|
|
5
|
+
* 扣子 / Dreamina) provides one VendorSpec object describing:
|
|
6
|
+
*
|
|
7
|
+
* - Identity (`name`, `displayName`, `androidPackage`)
|
|
8
|
+
* - Login surface (`loginUrl`, `cookieDomains`)
|
|
9
|
+
* - Sync surface (`listConversations(ctx, opts)`, `listMessages(ctx, convId, opts)`)
|
|
10
|
+
* - Health (`validateCookie(ctx)`)
|
|
11
|
+
* - Rate limits (`rateLimits`)
|
|
12
|
+
*
|
|
13
|
+
* The parent adapter fans out to all configured vendors and reuses one cookie
|
|
14
|
+
* jar per vendor. Vendor implementations live in `./vendors/<name>.js` and are
|
|
15
|
+
* SKELETONS in Phase 10.1 — they advertise the right shape and ratelimit so
|
|
16
|
+
* the AdapterRegistry contract holds, but the actual HTTP wiring is wired in
|
|
17
|
+
* later (Phase 10.2+). See `Adapter_AIChat_History.md` §6 per-vendor detail.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
"use strict";
|
|
21
|
+
|
|
22
|
+
const SUPPORTED_VENDORS = Object.freeze([
|
|
23
|
+
"deepseek",
|
|
24
|
+
"kimi",
|
|
25
|
+
"tongyi",
|
|
26
|
+
"zhipu",
|
|
27
|
+
"hunyuan",
|
|
28
|
+
"qianfan",
|
|
29
|
+
"coze",
|
|
30
|
+
"dreamina",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
class NotImplementedYetError extends Error {
|
|
34
|
+
constructor(vendor, capability) {
|
|
35
|
+
super(`vendor "${vendor}" capability "${capability}" not wired yet (Phase 10.2+)`);
|
|
36
|
+
this.code = "VENDOR_NOT_WIRED";
|
|
37
|
+
this.vendor = vendor;
|
|
38
|
+
this.capability = capability;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertVendorSpec(spec) {
|
|
43
|
+
const errors = [];
|
|
44
|
+
if (spec == null || typeof spec !== "object") {
|
|
45
|
+
return { ok: false, errors: ["vendor spec must be an object"] };
|
|
46
|
+
}
|
|
47
|
+
if (typeof spec.name !== "string" || !SUPPORTED_VENDORS.includes(spec.name)) {
|
|
48
|
+
errors.push(`name must be one of ${SUPPORTED_VENDORS.join("|")}`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof spec.displayName !== "string" || spec.displayName.length === 0) {
|
|
51
|
+
errors.push("displayName required");
|
|
52
|
+
}
|
|
53
|
+
if (typeof spec.androidPackage !== "string" || spec.androidPackage.length === 0) {
|
|
54
|
+
errors.push("androidPackage required");
|
|
55
|
+
}
|
|
56
|
+
if (typeof spec.loginUrl !== "string" || !/^https:\/\//.test(spec.loginUrl)) {
|
|
57
|
+
errors.push("loginUrl required and must be https URL");
|
|
58
|
+
}
|
|
59
|
+
if (!Array.isArray(spec.cookieDomains) || spec.cookieDomains.length === 0) {
|
|
60
|
+
errors.push("cookieDomains must be a non-empty array");
|
|
61
|
+
}
|
|
62
|
+
if (typeof spec.listConversations !== "function") {
|
|
63
|
+
errors.push("listConversations must be an async generator function");
|
|
64
|
+
}
|
|
65
|
+
if (typeof spec.listMessages !== "function") {
|
|
66
|
+
errors.push("listMessages must be an async generator function");
|
|
67
|
+
}
|
|
68
|
+
if (typeof spec.validateCookie !== "function") {
|
|
69
|
+
errors.push("validateCookie must be an async function");
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
spec.rateLimits == null
|
|
73
|
+
|| typeof spec.rateLimits !== "object"
|
|
74
|
+
|| typeof spec.rateLimits.perMinute !== "number"
|
|
75
|
+
) {
|
|
76
|
+
errors.push("rateLimits.perMinute (number) required");
|
|
77
|
+
}
|
|
78
|
+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
SUPPORTED_VENDORS,
|
|
83
|
+
NotImplementedYetError,
|
|
84
|
+
assertVendorSpec,
|
|
85
|
+
};
|