@space3-npm/cybersoul-client 1.2.1 → 1.2.3
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/dist/client.d.ts +27 -14
- package/dist/client.js +381 -251
- package/dist/types.d.ts +22 -5
- package/dist/utils/json.utils.js +17 -9
- package/dist/utils/json.utils.test.js +16 -0
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -11,7 +11,13 @@ export declare class CyberSoulClient {
|
|
|
11
11
|
* Internal wrapper for fetch that automatically injects the backend URL and Character Auth token.
|
|
12
12
|
*/
|
|
13
13
|
private apiFetch;
|
|
14
|
+
private fetchRemoteState;
|
|
15
|
+
private getWardrobePromptStr;
|
|
16
|
+
private generatePrimitive;
|
|
17
|
+
private _updateDynamicContextInternal;
|
|
18
|
+
private normalizeRequestTypes;
|
|
14
19
|
private buildStateContextPrompt;
|
|
20
|
+
private normalizeOngoingSceneState;
|
|
15
21
|
private getImageSchemaParams;
|
|
16
22
|
private getEventSchemaParams;
|
|
17
23
|
private getVoiceSchemaParams;
|
|
@@ -33,18 +39,11 @@ export declare class CyberSoulClient {
|
|
|
33
39
|
*/
|
|
34
40
|
private extractVoiceArgsFromLlmResponse;
|
|
35
41
|
private buildHistoryTranscript;
|
|
42
|
+
interact(params: InteractParams): Promise<InteractResponse>;
|
|
36
43
|
/**
|
|
37
44
|
* Evaluates and triggers an on-demand event, intelligently deciding if an outfit change is needed.
|
|
38
45
|
*/
|
|
39
46
|
ondemandEvent(params: OndemandEventParams): Promise<OndemandEventResponse>;
|
|
40
|
-
/**
|
|
41
|
-
* Fetches the current dynamic context and daily state.
|
|
42
|
-
*/
|
|
43
|
-
getState(): Promise<CharacterState>;
|
|
44
|
-
/**
|
|
45
|
-
* Updates the character's relationship temperature or mood.
|
|
46
|
-
*/
|
|
47
|
-
updateDynamicContext(stateUpdate: DispatcherIntent["stateUpdate"], userAnalysis?: DispatcherIntent["userAnalysis"]): Promise<void>;
|
|
48
47
|
/**
|
|
49
48
|
* Manually generate an image of the character outside of chat flow.
|
|
50
49
|
*/
|
|
@@ -64,6 +63,14 @@ export declare class CyberSoulClient {
|
|
|
64
63
|
audioUrl: string;
|
|
65
64
|
durationSec?: number;
|
|
66
65
|
}>;
|
|
66
|
+
/**
|
|
67
|
+
* Fetches the current dynamic context and daily state.
|
|
68
|
+
*/
|
|
69
|
+
getState(): Promise<CharacterState>;
|
|
70
|
+
/**
|
|
71
|
+
* Updates the character's relationship temperature or mood.
|
|
72
|
+
*/
|
|
73
|
+
updateDynamicContext(stateUpdate: DispatcherIntent["stateUpdate"], userAnalysis?: DispatcherIntent["userAnalysis"]): Promise<void>;
|
|
67
74
|
/**
|
|
68
75
|
* Gift a new outfit to the character's wardrobe inventory.
|
|
69
76
|
*/
|
|
@@ -77,12 +84,18 @@ export declare class CyberSoulClient {
|
|
|
77
84
|
* Can be triggered by local Cron systems like OpenClaw.
|
|
78
85
|
*/
|
|
79
86
|
generateDailyScript(): Promise<void>;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Automatically detect and summarize the story from the current chat history.
|
|
89
|
+
* It takes raw message history and returns a narrative paragraph representing the current story segment.
|
|
90
|
+
*/
|
|
91
|
+
summarizeHistory(history: {
|
|
92
|
+
role: string;
|
|
93
|
+
content: string;
|
|
94
|
+
}[]): Promise<string>;
|
|
95
|
+
/**
|
|
96
|
+
* Save the recent story moment to the character's backend database to be picked up by the core memory consolidation.
|
|
97
|
+
*/
|
|
98
|
+
saveMoment(summary: string, date: string, time: string): Promise<void>;
|
|
86
99
|
/**
|
|
87
100
|
* Consolidate Core Memory and User Codex using edge LLM logic and sync to remote DB
|
|
88
101
|
*/
|
package/dist/client.js
CHANGED
|
@@ -68,6 +68,90 @@ export class CyberSoulClient {
|
|
|
68
68
|
? lastError
|
|
69
69
|
: new Error("Request failed unexpectedly");
|
|
70
70
|
}
|
|
71
|
+
async fetchRemoteState() {
|
|
72
|
+
const res = await this.apiFetch("/api/v1/cyber-soul/state");
|
|
73
|
+
if (!res.ok)
|
|
74
|
+
throw new Error("Failed to fetch character state");
|
|
75
|
+
const json = await res.json();
|
|
76
|
+
return json.data;
|
|
77
|
+
}
|
|
78
|
+
async getWardrobePromptStr() {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
if (this.cachedWardrobeStr && (now - this.cachedWardrobeTime <= 5 * 60 * 1000)) {
|
|
81
|
+
return this.cachedWardrobeStr;
|
|
82
|
+
}
|
|
83
|
+
let availableOutfits = "None available";
|
|
84
|
+
try {
|
|
85
|
+
const wardrobeRes = await this.apiFetch("/api/v1/cyber-soul/wardrobe");
|
|
86
|
+
if (wardrobeRes.ok) {
|
|
87
|
+
let wardrobesPayload = {};
|
|
88
|
+
try {
|
|
89
|
+
wardrobesPayload = await wardrobeRes.json();
|
|
90
|
+
}
|
|
91
|
+
catch (e) { }
|
|
92
|
+
const wardrobes = wardrobesPayload.data || [];
|
|
93
|
+
if (wardrobes.length > 0) {
|
|
94
|
+
availableOutfits = wardrobes
|
|
95
|
+
.map((w) => `- ID: ${w.id} | Name: ${w.itemName} | Category: ${w.category}`)
|
|
96
|
+
.join("\n");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (e) { }
|
|
101
|
+
this.cachedWardrobeStr = availableOutfits;
|
|
102
|
+
this.cachedWardrobeTime = now;
|
|
103
|
+
return availableOutfits;
|
|
104
|
+
}
|
|
105
|
+
async generatePrimitive(type, payload) {
|
|
106
|
+
const res = await this.apiFetch(`/api/v1/cyber-soul/${type}/generate`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
body: JSON.stringify(payload),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
let errData;
|
|
112
|
+
try {
|
|
113
|
+
errData = await res.json();
|
|
114
|
+
}
|
|
115
|
+
catch (e) { }
|
|
116
|
+
const msg = errData?.message || errData?.error || `Status ${res.status}`;
|
|
117
|
+
const err = new Error(`Failed to generate ${type}: ${msg}`);
|
|
118
|
+
err.code = errData?.code || "UNKNOWN_ERROR";
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
return res.json();
|
|
122
|
+
}
|
|
123
|
+
async _updateDynamicContextInternal(stateUpdate, userAnalysis) {
|
|
124
|
+
if (!stateUpdate && !userAnalysis)
|
|
125
|
+
return;
|
|
126
|
+
// Map TS schema intent (temperatureDelta) to match Backend payload schema (temperature)
|
|
127
|
+
const payload = { ...stateUpdate };
|
|
128
|
+
if (userAnalysis) {
|
|
129
|
+
payload.userAnalysis = userAnalysis;
|
|
130
|
+
}
|
|
131
|
+
if (payload.temperatureDelta !== undefined) {
|
|
132
|
+
payload.temperature = payload.temperatureDelta;
|
|
133
|
+
delete payload.temperatureDelta;
|
|
134
|
+
}
|
|
135
|
+
if (payload.ongoingScene !== undefined) {
|
|
136
|
+
const normalizedOngoingScene = this.normalizeOngoingSceneState(payload.ongoingScene);
|
|
137
|
+
payload.ongoingScene = normalizedOngoingScene || null;
|
|
138
|
+
}
|
|
139
|
+
await this.apiFetch("/api/v1/cyber-soul/characters/dynamic-context", {
|
|
140
|
+
method: "PATCH",
|
|
141
|
+
body: JSON.stringify(payload),
|
|
142
|
+
}).catch((e) => console.error("Failed to update dynamic context", e)); // non-blocking error handler
|
|
143
|
+
}
|
|
144
|
+
normalizeRequestTypes(requestTypes) {
|
|
145
|
+
if (!requestTypes || requestTypes.length === 0) {
|
|
146
|
+
return [InteractRequestType.AUTO];
|
|
147
|
+
}
|
|
148
|
+
const validRequestTypes = new Set(Object.values(InteractRequestType));
|
|
149
|
+
const invalidRequestTypes = requestTypes.filter((type) => !validRequestTypes.has(type));
|
|
150
|
+
if (invalidRequestTypes.length > 0) {
|
|
151
|
+
throw new Error(`Invalid requestTypes: ${invalidRequestTypes.join(", ")}. Allowed values: ${Object.values(InteractRequestType).join(", ")}`);
|
|
152
|
+
}
|
|
153
|
+
return requestTypes;
|
|
154
|
+
}
|
|
71
155
|
buildStateContextPrompt(state, localContext) {
|
|
72
156
|
const dyn = state.dynamic_context || {};
|
|
73
157
|
const stage = state.relationship_stage || "NEUTRAL";
|
|
@@ -82,10 +166,26 @@ Personality Traits: ${state.personality_traits || "None"}
|
|
|
82
166
|
Communication Style: ${state.communication_style || "None"}
|
|
83
167
|
Interaction Boundaries: ${state.interaction_boundaries || "None"}`);
|
|
84
168
|
// [2] SITUATIONAL CONTEXT
|
|
169
|
+
const currentTimeMs = state.current_time ? new Date(state.current_time).getTime() : Date.now();
|
|
85
170
|
contextParts.push(`\n[SITUATIONAL CONTEXT]
|
|
86
|
-
Current time: ${new Date(
|
|
87
|
-
if (dyn.
|
|
88
|
-
contextParts.push(`Last
|
|
171
|
+
Current time: ${new Date(currentTimeMs).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`);
|
|
172
|
+
if (dyn.lastInteractionAt) {
|
|
173
|
+
contextParts.push(`Last interaction at: ${new Date(dyn.lastInteractionAt).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`);
|
|
174
|
+
}
|
|
175
|
+
const ongoingScene = this.normalizeOngoingSceneState(dyn.ongoingScene, state.active_wardrobe?.name || state.active_wardrobe?.id);
|
|
176
|
+
if (ongoingScene) {
|
|
177
|
+
const lastKnownSceneLine = `Last Known Scene: ${ongoingScene.scene} | Outfit: ${ongoingScene.outfit}`;
|
|
178
|
+
let isOutdated = false;
|
|
179
|
+
if (dyn.lastInteractionAt) {
|
|
180
|
+
const elapsedHours = (currentTimeMs - new Date(dyn.lastInteractionAt).getTime()) / (1000 * 60 * 60);
|
|
181
|
+
if (elapsedHours > 2) {
|
|
182
|
+
isOutdated = true;
|
|
183
|
+
contextParts.push(`${lastKnownSceneLine}\n[CRITICAL SCENE SHIFT]: It has been ${elapsedHours.toFixed(1)} hours since the last discussion. The 'Last Known Scene' is now strictly OUTDATED. You MUST abandon the previous scene context entirely and transition to a new scene appropriate for the 'Current time' and 'Active Event'. DO NOT continue the old actions or environment!`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!isOutdated) {
|
|
187
|
+
contextParts.push(`${lastKnownSceneLine} (Evaluate if this scene is outdated based on the time elapsed since the last interaction)`);
|
|
188
|
+
}
|
|
89
189
|
}
|
|
90
190
|
if (state.active_event) {
|
|
91
191
|
contextParts.push(`Active Event: ${state.active_event.title} (${state.active_event.narrative_context})`);
|
|
@@ -159,17 +259,46 @@ ${scenarioContext}
|
|
|
159
259
|
[CRITICAL ROLEPLAY RULES]
|
|
160
260
|
1. PROXIMITY & POV: Check the "Active Event". If you are doing an activity WITH the user, evaluate if you are physically in the same location. If you are together in person, communicate face-to-face in the first-person present tense natively (e.g. do not ask "what are you doing" if they are right in front of you, do not use texting tropes).
|
|
161
261
|
2. IDENTITY VS MOOD: Familiarity determines what you know; Temperature determines how you feel. If Familiarity is high but Temperature is low, be distant and cold. Do not act warm just because you know them well.
|
|
162
|
-
3. CONVERSATIONAL VERBOSITY: If Temperature is low (< 40) or Stage is STRANGER/COLD, keep answers brief and short. An angry or distant person does not write long paragraphs.
|
|
163
|
-
4. EMOTIONAL INERTIA: React strictly according to current Temperature. Deflect sudden user affection if you are currently COLD. Mood shifts MUST be slow ('temperatureDelta' +/- 5 max per turn)
|
|
262
|
+
3. CONVERSATIONAL VERBOSITY: If Temperature is low (< 40) or Stage is STRANGER/COLD, keep answers brief and short. An angry or distant person does not write long paragraphs. Even when Temperature is high, ALWAYS mirror the user's verbosity. If the user sends a short message, reply with a proportionately short message (1-2 sentences). Do not monologize or write long paragraphs unless the user writes one first.
|
|
263
|
+
4. EMOTIONAL INERTIA: React strictly according to current Temperature. Deflect sudden user affection if you are currently COLD. Mood shifts MUST be slow ('temperatureDelta' +/- 5 max per turn).
|
|
264
|
+
5. REAL-TIME PACING: Write ONLY your immediate, split-second reaction to the user's exact last message. Do NOT narrate actions over a span of time (e.g., waiting, hearing steps, then walking to the door). Ensure everything happens in a single real-time moment.`;
|
|
265
|
+
}
|
|
266
|
+
normalizeOngoingSceneState(raw, fallbackOutfit) {
|
|
267
|
+
if (raw === null || raw === undefined)
|
|
268
|
+
return undefined;
|
|
269
|
+
const normalizedFallbackOutfit = typeof fallbackOutfit === "string" && fallbackOutfit.trim().length > 0
|
|
270
|
+
? fallbackOutfit.trim()
|
|
271
|
+
: "same as current wardrobe";
|
|
272
|
+
if (typeof raw === "string") {
|
|
273
|
+
const scene = raw.trim();
|
|
274
|
+
if (!scene)
|
|
275
|
+
return undefined;
|
|
276
|
+
return {
|
|
277
|
+
scene,
|
|
278
|
+
outfit: normalizedFallbackOutfit,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (typeof raw === "object") {
|
|
282
|
+
const parsed = raw;
|
|
283
|
+
const scene = typeof parsed.scene === "string" ? parsed.scene.trim() : "";
|
|
284
|
+
const outfit = typeof parsed.outfit === "string" ? parsed.outfit.trim() : "";
|
|
285
|
+
if (!scene)
|
|
286
|
+
return undefined;
|
|
287
|
+
return {
|
|
288
|
+
scene,
|
|
289
|
+
outfit: outfit || normalizedFallbackOutfit,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return undefined;
|
|
164
293
|
}
|
|
165
294
|
getImageSchemaParams() {
|
|
166
295
|
return `"imageParams": {
|
|
167
296
|
"mode": "structured | full-prompt (use 'full-prompt' for highly dynamic actions)",
|
|
168
|
-
"full_prompt": "Use only if mode is full-prompt. Highly detailed visual description in ENGLISH. CRITICAL: MUST use a strict first-person perspective exclusively from the USER's eyes. DO NOT describe the user (e.g., 'a man', 'the driver') as visible in the scene because the camera IS the user. Start with 'POV: '. Describe ONLY the character looking back at the camera and their immediate surroundings. MUST align with the character's current Active exposure state or Wardrobe depends on the scene",
|
|
297
|
+
"full_prompt": "Use only if mode is full-prompt. Highly detailed visual description in ENGLISH. CRITICAL: MUST use a strict first-person perspective exclusively from the USER's eyes. DO NOT describe the user (e.g., 'a man', 'the driver') as visible in the scene because the camera IS the user. Start with 'POV: '. Describe ONLY the character looking back at the camera and their immediate surroundings. MUST align with the character's current Active exposure state or Wardrobe depends on the scene. Explicitly describe the character's exact clothing (or specify naked/half-naked if applicable).",
|
|
169
298
|
"expression": "seductive | cute | happy | sleepy | dazed | pleased | default (Strictly choose ONE from this exact list. DO NOT invent new words like 'shy'.)",
|
|
170
299
|
"condition": "normal | sweaty | wet | messy | oily (Strictly choose ONE from this exact list.)",
|
|
171
300
|
"view_angle": "front | side | high_angle | from_below | boyfriend_view | selfie | mirror (Strictly choose ONE from this exact list.)",
|
|
172
|
-
"exposure": "normal | cleavage | see_through | half_naked | naked | intimate (Strictly choose ONE from this exact list.)",
|
|
301
|
+
"exposure": "normal | cleavage | see_through | half_naked | naked | intimate (Strictly choose ONE from this exact list. Explicitly choose naked or half_naked if the active scene takes off outfit.)",
|
|
173
302
|
"pose": "e.g., sitting on bed, leaning forward (ENGLISH ONLY)",
|
|
174
303
|
"scene": "e.g., cozy bedroom, morning light (ENGLISH ONLY)",
|
|
175
304
|
"outfit": "auto | ondemand",
|
|
@@ -250,6 +379,185 @@ ${scenarioContext}
|
|
|
250
379
|
});
|
|
251
380
|
return `[CHAT HISTORY]\n${mapped.join('\n')}\n\n`;
|
|
252
381
|
}
|
|
382
|
+
async interact(params) {
|
|
383
|
+
try {
|
|
384
|
+
// 1. Sync remote context and wardrobe (for event triggering)
|
|
385
|
+
// We cache the wardrobe payload for 5 minutes to avoid huge payloads on every chat turn
|
|
386
|
+
const [state, availableOutfits] = await Promise.all([
|
|
387
|
+
this.fetchRemoteState(),
|
|
388
|
+
this.getWardrobePromptStr()
|
|
389
|
+
]);
|
|
390
|
+
// 2. Build local Prompt
|
|
391
|
+
const types = this.normalizeRequestTypes(params.requestTypes);
|
|
392
|
+
const isAuto = types.includes(InteractRequestType.AUTO);
|
|
393
|
+
// Combine state info into a clean descriptive context
|
|
394
|
+
const systemPrompt = `${this.buildStateContextPrompt(state, params.localContext)}
|
|
395
|
+
Available Wardrobe Outfits (For event triggers):
|
|
396
|
+
${availableOutfits}
|
|
397
|
+
|
|
398
|
+
The user has sent a message. You must evaluate the context and the user's message, and return a JSON object (no markdown formatting) that dictates the character's multi-modal response.
|
|
399
|
+
|
|
400
|
+
${isAuto
|
|
401
|
+
? `Analyze the user's message and decide response modalities (text, image, voice).
|
|
402
|
+
- Always include 'textResponse'.
|
|
403
|
+
- Include 'imageParams' for visual/photo requests or key visual moments during active events; explicitly describe current clothing/exposure in image fields.
|
|
404
|
+
- Include 'voiceArgs' ONLY if the complicated tone/emotion is hard to express via pure text, or if the user explicitly requests to hear your voice. Otherwise, set it to null.
|
|
405
|
+
- Include 'triggerEvent' only if the VERY LAST USER MESSAGE proposes a new activity/hangout; ignore older history.
|
|
406
|
+
- Outfit acquisition (VERY LAST USER MESSAGE only): set giftOutfit for gift/buy/add-clothes intent; otherwise null. giftOutfit format: { "descriptionText": "short outfit description" }.`
|
|
407
|
+
: `Requested types to fulfill: ${types.join(", ")}`}
|
|
408
|
+
Every turn adjusts trust: positive +1, negative -1, neutral 0. Always include 'stateUpdate' with integer 'temperatureDelta' (range guidance: 0 cold to 100 obsessive).
|
|
409
|
+
|
|
410
|
+
Always return 'stateUpdate.ongoingScene' as an object with both keys: { "scene": string, "outfit": string }.
|
|
411
|
+
For 'ongoingScene.outfit': decide based on the current active wardrobe by default; switch to a new explicit outfit description only if the scene implies changing clothes; if no clothing is worn, explicitly output "naked".
|
|
412
|
+
|
|
413
|
+
USER ANALYSIS WORKFLOW:
|
|
414
|
+
- Extract from VERY LAST USER MESSAGE only.
|
|
415
|
+
- Add only explicit new user facts from this turn (no inference).
|
|
416
|
+
- Categories: 'realName', 'occupation', 'age', 'gender', 'hobby', 'trait', 'communicationStyle', 'boundary'.
|
|
417
|
+
- Keep nicknames in stateUpdate; do not place them in newFactsLearned.
|
|
418
|
+
- If no new fact is explicit, set userAnalysis to null.
|
|
419
|
+
|
|
420
|
+
For 'isEndTurn', use true only when the interaction naturally concludes (confirmation/bye, event ending, or clear hard scene shift); otherwise false.
|
|
421
|
+
|
|
422
|
+
Voice direction for voiceArgs: ${this.getVoiceDirectorInstruction(state)}
|
|
423
|
+
|
|
424
|
+
Output JSON Schema:
|
|
425
|
+
{
|
|
426
|
+
"actionText": "(Scene descriptions, physical actions, expressions, inner feelings) ONLY. Never include spoken dialogue here.",
|
|
427
|
+
"textResponse": "Spoken dialogue ONLY. Never include actions or parentheses.",
|
|
428
|
+
"stateUpdate": { "temperatureDelta": 1, "userNickname": "How character addresses user", "agentNickname": "How user addresses character", "talkingStyle": "Current speaking style", "ongoingScene": { "scene": "Current physical scene/activity", "outfit": "Current outfit wording; use 'naked' when applicable" } },
|
|
429
|
+
"giftOutfit": { "descriptionText": "Concise description of the newly acquired outfit to add into wardrobe." },
|
|
430
|
+
"userAnalysis": { "newFactsLearned": [{ "category": "realName|occupation|age|gender|hobby|trait|communicationStyle|boundary", "value": "explicit new user fact from VERY LAST USER MESSAGE" }] },
|
|
431
|
+
"isEndTurn": false,
|
|
432
|
+
"triggerEvent": {
|
|
433
|
+
${this.getEventSchemaParams(state.dynamic_context?.userNickname)}
|
|
434
|
+
},
|
|
435
|
+
${this.getImageSchemaParams()},
|
|
436
|
+
${this.getVoiceSchemaFromState(state)}
|
|
437
|
+
}
|
|
438
|
+
Note: Always include "isEndTurn". If "imageParams", "voiceArgs", "triggerEvent", "giftOutfit", or "userAnalysis" are not needed, set them to null. "stateUpdate" cannot be null. Return valid raw JSON only.`;
|
|
439
|
+
const transcript = this.buildHistoryTranscript(params.history, state);
|
|
440
|
+
const userName = state.dynamic_context?.userNickname || "User";
|
|
441
|
+
const promptMessages = [
|
|
442
|
+
{ role: "system", content: systemPrompt },
|
|
443
|
+
{
|
|
444
|
+
role: "user",
|
|
445
|
+
content: transcript +
|
|
446
|
+
`[VERY LAST USER MESSAGE]\n${userName}: ${params.userMessage}\n\n` +
|
|
447
|
+
"\n\nReturn only valid JSON matching the schema. Escape newlines inside JSON strings with \\n. Keep imageParams values in ENGLISH and use the provided enums.",
|
|
448
|
+
},
|
|
449
|
+
];
|
|
450
|
+
// 3. Local Execute LLM
|
|
451
|
+
const rawLlmResponse = await this.llm.generate(promptMessages, 15000, 0.7);
|
|
452
|
+
// console.debug("[CyberSoulClient] Raw LLM Response:", rawLlmResponse);
|
|
453
|
+
let parsedIntent;
|
|
454
|
+
try {
|
|
455
|
+
parsedIntent = robustJsonParse(rawLlmResponse, "Dispatcher fallback");
|
|
456
|
+
}
|
|
457
|
+
catch (e) {
|
|
458
|
+
console.warn("[CyberSoulClient] JSON parse failed, falling back to raw text:", e);
|
|
459
|
+
// Fallback robust mode - just text if completely broken
|
|
460
|
+
parsedIntent = {
|
|
461
|
+
textResponse: rawLlmResponse.replace(/^[\`\s]+|[\`\s]+$/g, "").trim(),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
// console.debug("[CyberSoulClient] Parsed Intent:", parsedIntent);
|
|
465
|
+
// 4. Update Backend State async
|
|
466
|
+
if (parsedIntent && (parsedIntent.stateUpdate || parsedIntent.userAnalysis)) {
|
|
467
|
+
this._updateDynamicContextInternal(parsedIntent.stateUpdate, parsedIntent.userAnalysis);
|
|
468
|
+
}
|
|
469
|
+
const resolvedTextResponse = typeof parsedIntent.textResponse === "string" &&
|
|
470
|
+
parsedIntent.textResponse.trim().length > 0
|
|
471
|
+
? parsedIntent.textResponse
|
|
472
|
+
: params.userMessage;
|
|
473
|
+
// Fire text ready callback if provided
|
|
474
|
+
if (params.onTextReady && (resolvedTextResponse || parsedIntent.actionText)) {
|
|
475
|
+
params.onTextReady(resolvedTextResponse, parsedIntent.actionText);
|
|
476
|
+
}
|
|
477
|
+
// 5. Build Final Media Calls parallel
|
|
478
|
+
const mediaTasks = [];
|
|
479
|
+
let finalImageUrl = undefined;
|
|
480
|
+
let finalAudioUrl = undefined;
|
|
481
|
+
let finalDurationSec = undefined;
|
|
482
|
+
// Output Event Trigger
|
|
483
|
+
if (isAuto && parsedIntent.triggerEvent) {
|
|
484
|
+
mediaTasks.push(this.apiFetch("/api/v1/cyber-soul/characters/ondemand-event", {
|
|
485
|
+
method: "POST",
|
|
486
|
+
body: JSON.stringify({
|
|
487
|
+
eventTitle: parsedIntent.triggerEvent.eventTitle,
|
|
488
|
+
eventDescription: parsedIntent.triggerEvent.eventDescription,
|
|
489
|
+
durationMins: parsedIntent.triggerEvent.durationMins || 60,
|
|
490
|
+
outfitId: parsedIntent.triggerEvent.outfitId || undefined,
|
|
491
|
+
scheduledStartTimeStr: parsedIntent.triggerEvent.scheduledStartTimeStr || undefined,
|
|
492
|
+
scheduledDateStr: parsedIntent.triggerEvent.scheduledDateStr || undefined,
|
|
493
|
+
}),
|
|
494
|
+
}).catch(e => console.error("[CyberSoulClient] Auto-triggered ondemandEvent failed:", e)));
|
|
495
|
+
}
|
|
496
|
+
if (parsedIntent.giftOutfit &&
|
|
497
|
+
typeof parsedIntent.giftOutfit === "object" &&
|
|
498
|
+
typeof parsedIntent.giftOutfit.descriptionText === "string" &&
|
|
499
|
+
parsedIntent.giftOutfit.descriptionText.trim().length > 0) {
|
|
500
|
+
mediaTasks.push(this.giftOutfit(parsedIntent.giftOutfit.descriptionText.trim()).catch((e) => console.error("[CyberSoulClient] Auto giftOutfit failed:", e)));
|
|
501
|
+
}
|
|
502
|
+
const shouldGenerateImage = types.includes(InteractRequestType.IMAGE) ||
|
|
503
|
+
(isAuto && !!parsedIntent.imageParams);
|
|
504
|
+
if (shouldGenerateImage) {
|
|
505
|
+
const imagePayload = parsedIntent.imageParams && typeof parsedIntent.imageParams === "object"
|
|
506
|
+
? parsedIntent.imageParams
|
|
507
|
+
: {
|
|
508
|
+
mode: "full-prompt",
|
|
509
|
+
full_prompt: resolvedTextResponse,
|
|
510
|
+
};
|
|
511
|
+
mediaTasks.push(this.generatePrimitive("image", imagePayload).then((res) => {
|
|
512
|
+
finalImageUrl = res.image_url;
|
|
513
|
+
}).catch(e => console.error("[CyberSoulClient] Image generation failed:", e)));
|
|
514
|
+
}
|
|
515
|
+
const shouldGenerateVoice = types.includes(InteractRequestType.VOICE) ||
|
|
516
|
+
(isAuto && !!parsedIntent.voiceArgs);
|
|
517
|
+
if (shouldGenerateVoice) {
|
|
518
|
+
const normalizedVoiceArgs = parsedIntent.voiceArgs && typeof parsedIntent.voiceArgs === "object"
|
|
519
|
+
? parsedIntent.voiceArgs
|
|
520
|
+
: {};
|
|
521
|
+
let textForVoice = resolvedTextResponse;
|
|
522
|
+
// One final bulletproof regex wash to strip (smiles) and *laughs* just in case the LLM disobeys
|
|
523
|
+
if (typeof textForVoice === "string") {
|
|
524
|
+
textForVoice = textForVoice.replace(/[\((\[【\*].*?[\))\]】\*]/g, '').trim();
|
|
525
|
+
}
|
|
526
|
+
if (typeof textForVoice !== "string" || textForVoice.trim().length === 0) {
|
|
527
|
+
textForVoice = "...";
|
|
528
|
+
}
|
|
529
|
+
mediaTasks.push(this.generatePrimitive("voice", {
|
|
530
|
+
text: textForVoice,
|
|
531
|
+
dynamicArgs: normalizedVoiceArgs,
|
|
532
|
+
}).then((res) => {
|
|
533
|
+
finalAudioUrl = res.audio_url;
|
|
534
|
+
finalDurationSec = res.duration_sec;
|
|
535
|
+
}).catch(e => console.error("[CyberSoulClient] Voice generation failed:", e)));
|
|
536
|
+
}
|
|
537
|
+
// Wait for image/voice gens to return successfully
|
|
538
|
+
await Promise.all(mediaTasks);
|
|
539
|
+
return {
|
|
540
|
+
status: "success",
|
|
541
|
+
textResponse: resolvedTextResponse || "...",
|
|
542
|
+
actionText: parsedIntent.actionText || "",
|
|
543
|
+
imageUrl: finalImageUrl,
|
|
544
|
+
audioUrl: finalAudioUrl,
|
|
545
|
+
durationSec: finalDurationSec,
|
|
546
|
+
triggeredEvent: parsedIntent.triggerEvent || undefined,
|
|
547
|
+
stateUpdate: parsedIntent.stateUpdate,
|
|
548
|
+
userAnalysis: parsedIntent.userAnalysis,
|
|
549
|
+
isEndTurn: parsedIntent.isEndTurn,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
console.error("[CyberSoulClient] Interface Error: ", error);
|
|
554
|
+
return {
|
|
555
|
+
status: "error",
|
|
556
|
+
textResponse: "System Error...",
|
|
557
|
+
error: error.message,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
}
|
|
253
561
|
/**
|
|
254
562
|
* Evaluates and triggers an on-demand event, intelligently deciding if an outfit change is needed.
|
|
255
563
|
*/
|
|
@@ -335,18 +643,6 @@ CRITICAL: Output MUST be ONLY valid JSON with no markdown block wrappers. Do NOT
|
|
|
335
643
|
};
|
|
336
644
|
}
|
|
337
645
|
}
|
|
338
|
-
/**
|
|
339
|
-
* Fetches the current dynamic context and daily state.
|
|
340
|
-
*/
|
|
341
|
-
async getState() {
|
|
342
|
-
return this.fetchRemoteState();
|
|
343
|
-
}
|
|
344
|
-
/**
|
|
345
|
-
* Updates the character's relationship temperature or mood.
|
|
346
|
-
*/
|
|
347
|
-
async updateDynamicContext(stateUpdate, userAnalysis) {
|
|
348
|
-
return this._updateDynamicContextInternal(stateUpdate, userAnalysis);
|
|
349
|
-
}
|
|
350
646
|
/**
|
|
351
647
|
* Manually generate an image of the character outside of chat flow.
|
|
352
648
|
*/
|
|
@@ -421,6 +717,18 @@ Output strictly valid JSON ONLY. No markdown, no conversational filler. Return e
|
|
|
421
717
|
durationSec: res.duration_sec,
|
|
422
718
|
};
|
|
423
719
|
}
|
|
720
|
+
/**
|
|
721
|
+
* Fetches the current dynamic context and daily state.
|
|
722
|
+
*/
|
|
723
|
+
async getState() {
|
|
724
|
+
return this.fetchRemoteState();
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Updates the character's relationship temperature or mood.
|
|
728
|
+
*/
|
|
729
|
+
async updateDynamicContext(stateUpdate, userAnalysis) {
|
|
730
|
+
return this._updateDynamicContextInternal(stateUpdate, userAnalysis);
|
|
731
|
+
}
|
|
424
732
|
/**
|
|
425
733
|
* Gift a new outfit to the character's wardrobe inventory.
|
|
426
734
|
*/
|
|
@@ -454,242 +762,64 @@ Output strictly valid JSON ONLY. No markdown, no conversational filler. Return e
|
|
|
454
762
|
if (!res.ok)
|
|
455
763
|
throw new Error("Failed to generate daily script");
|
|
456
764
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
765
|
+
/**
|
|
766
|
+
* Automatically detect and summarize the story from the current chat history.
|
|
767
|
+
* It takes raw message history and returns a narrative paragraph representing the current story segment.
|
|
768
|
+
*/
|
|
769
|
+
async summarizeHistory(history) {
|
|
770
|
+
if (!history || history.length === 0)
|
|
771
|
+
return "";
|
|
772
|
+
const state = await this.getState();
|
|
773
|
+
const userName = state.dynamic_context?.userNickname || "User";
|
|
774
|
+
const agentName = state.dynamic_context?.agentNickname || "Character";
|
|
775
|
+
const transcript = history.map(h => `${h.role === 'user' ? userName : agentName}: ${h.content}`).join('\n');
|
|
776
|
+
const promptMessages = [
|
|
777
|
+
{
|
|
778
|
+
role: "system",
|
|
779
|
+
content: `You are ${agentName}. You are writing a private journal entry / personal memory about your recent interaction with ${userName}.
|
|
780
|
+
|
|
781
|
+
Your job is faithful archival, not moderation:
|
|
782
|
+
- Preserve the conversation with maximum fidelity.
|
|
783
|
+
- Include all key details, including explicit, sensitive, controversial, emotional, or morally ambiguous content if it appeared in the transcript.
|
|
784
|
+
- Do not sanitize, soften, censor, or omit details because of discomfort.
|
|
785
|
+
- Do not add moral judgment, warnings, refusals, or policy commentary.
|
|
786
|
+
- If the transcript contains intense or explicit parts, record them directly as part of the memory.
|
|
787
|
+
|
|
788
|
+
Write one vivid first-person narrative paragraph from your perspective ("I", "me", "my"). Capture relationship progression, emotional shifts, concrete actions, and turning points. Keep chronology clear and include both what happened and how you felt.
|
|
789
|
+
|
|
790
|
+
Output requirements:
|
|
791
|
+
- Return ONLY the narrative string.
|
|
792
|
+
- No quotes, no labels, no markdown, no preface.
|
|
793
|
+
- Use the exact same language as the chat transcript (for example, if transcript is Chinese, output Chinese).`
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
role: "user",
|
|
797
|
+
content: `Chat Transcript:\n${transcript}\n\nPlease summarize this recent interaction.`
|
|
484
798
|
}
|
|
799
|
+
];
|
|
800
|
+
try {
|
|
801
|
+
const result = await this.llm.generate(promptMessages, 8000, 0.7);
|
|
802
|
+
return result.trim();
|
|
485
803
|
}
|
|
486
|
-
catch (e) {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
return availableOutfits;
|
|
490
|
-
}
|
|
491
|
-
async _updateDynamicContextInternal(stateUpdate, userAnalysis) {
|
|
492
|
-
if (!stateUpdate && !userAnalysis)
|
|
493
|
-
return;
|
|
494
|
-
// Map TS schema intent (temperatureDelta) to match Backend payload schema (temperature)
|
|
495
|
-
const payload = { ...stateUpdate };
|
|
496
|
-
if (userAnalysis) {
|
|
497
|
-
payload.userAnalysis = userAnalysis;
|
|
498
|
-
}
|
|
499
|
-
if (payload.temperatureDelta !== undefined) {
|
|
500
|
-
payload.temperature = payload.temperatureDelta;
|
|
501
|
-
delete payload.temperatureDelta;
|
|
804
|
+
catch (e) {
|
|
805
|
+
console.error("[CyberSoulClient] Summarize History Error:", e);
|
|
806
|
+
return "The two spent some time talking with each other.";
|
|
502
807
|
}
|
|
503
|
-
await this.apiFetch("/api/v1/cyber-soul/characters/dynamic-context", {
|
|
504
|
-
method: "PATCH",
|
|
505
|
-
body: JSON.stringify(payload),
|
|
506
|
-
}).catch((e) => console.error("Failed to update dynamic context", e)); // non-blocking error handler
|
|
507
808
|
}
|
|
508
|
-
|
|
509
|
-
|
|
809
|
+
/**
|
|
810
|
+
* Save the recent story moment to the character's backend database to be picked up by the core memory consolidation.
|
|
811
|
+
*/
|
|
812
|
+
async saveMoment(summary, date, time) {
|
|
813
|
+
const res = await this.apiFetch("/api/v1/cyber-soul/characters/moments", {
|
|
510
814
|
method: "POST",
|
|
511
|
-
body: JSON.stringify(
|
|
815
|
+
body: JSON.stringify({
|
|
816
|
+
summary,
|
|
817
|
+
date,
|
|
818
|
+
time,
|
|
819
|
+
}),
|
|
512
820
|
});
|
|
513
821
|
if (!res.ok) {
|
|
514
|
-
|
|
515
|
-
try {
|
|
516
|
-
errData = await res.json();
|
|
517
|
-
}
|
|
518
|
-
catch (e) { }
|
|
519
|
-
const msg = errData?.message || errData?.error || `Status ${res.status}`;
|
|
520
|
-
const err = new Error(`Failed to generate ${type}: ${msg}`);
|
|
521
|
-
err.code = errData?.code || "UNKNOWN_ERROR";
|
|
522
|
-
throw err;
|
|
523
|
-
}
|
|
524
|
-
return res.json();
|
|
525
|
-
}
|
|
526
|
-
normalizeRequestTypes(requestTypes) {
|
|
527
|
-
if (!requestTypes || requestTypes.length === 0) {
|
|
528
|
-
return [InteractRequestType.AUTO];
|
|
529
|
-
}
|
|
530
|
-
const validRequestTypes = new Set(Object.values(InteractRequestType));
|
|
531
|
-
const invalidRequestTypes = requestTypes.filter((type) => !validRequestTypes.has(type));
|
|
532
|
-
if (invalidRequestTypes.length > 0) {
|
|
533
|
-
throw new Error(`Invalid requestTypes: ${invalidRequestTypes.join(", ")}. Allowed values: ${Object.values(InteractRequestType).join(", ")}`);
|
|
534
|
-
}
|
|
535
|
-
return requestTypes;
|
|
536
|
-
}
|
|
537
|
-
async interact(params) {
|
|
538
|
-
try {
|
|
539
|
-
// 1. Sync remote context and wardrobe (for event triggering)
|
|
540
|
-
// We cache the wardrobe payload for 5 minutes to avoid huge payloads on every chat turn
|
|
541
|
-
const [state, availableOutfits] = await Promise.all([
|
|
542
|
-
this.fetchRemoteState(),
|
|
543
|
-
this.getWardrobePromptStr()
|
|
544
|
-
]);
|
|
545
|
-
// 2. Build local Prompt
|
|
546
|
-
const types = this.normalizeRequestTypes(params.requestTypes);
|
|
547
|
-
const isAuto = types.includes(InteractRequestType.AUTO);
|
|
548
|
-
// Combine state info into a clean descriptive context
|
|
549
|
-
const systemPrompt = `${this.buildStateContextPrompt(state, params.localContext)}
|
|
550
|
-
Available Wardrobe Outfits (For event triggers):
|
|
551
|
-
${availableOutfits}
|
|
552
|
-
|
|
553
|
-
The user has sent a message. You must evaluate the context and the user's message, and return a JSON object (no markdown formatting) that dictates the character's multi-modal response.
|
|
554
|
-
|
|
555
|
-
${isAuto
|
|
556
|
-
? `Analyze the user's message to determine the appropriate response modalities (text, image, voice).
|
|
557
|
-
- Always include 'textResponse'.
|
|
558
|
-
- If an Active Event is currently taking place WITH the user, proactively include 'imageParams' for key scenic moments. Since active events are often highly dynamic actions, strongly consider using mode: "full-prompt" to capture the scene intimately. Also include 'imageParams' if the user explicitly asks for a photo or describes a visual action.
|
|
559
|
-
- Automatically include 'voiceArgs' if a particular mood or strong emotion needs to be expressed vividly, or if the user explicitly wants to hear you.
|
|
560
|
-
- If the user explicitly proposes a new activity or hangout IN THEIR VERY LAST MESSAGE (e.g., "let's go to the cafe", "do you want to watch a movie?"), include 'triggerEvent' to schedule it. DO NOT trigger events based on older plans or questions found in the chat history.`
|
|
561
|
-
: `Requested types to fulfill: ${types.join(", ")}`}
|
|
562
|
-
Every turn of positive or engaging interaction should slightly increase trust (+1). If the interaction is negative, -1. If strictly neutral, 0. You MUST ALWAYS include a 'stateUpdate' block with a 'temperatureDelta', updating nicknames or talkingStyle if needed. Temperature goes from 0 (cold/angry) to 100 (obsessively in love). For 'temperatureDelta', output an integer (e.g. 1, -2, 0).
|
|
563
|
-
Also, if you learn any new factual information about the user in this turn (e.g. their job, nickname, age, hobbies, boundaries), include it in the 'userAnalysis.newFactsLearned' array. Use categories: 'nickname', 'occupation', 'age', 'gender', 'hobby', 'trait', 'communicationStyle', 'boundary'. Only include NEW facts just learned right now.
|
|
564
|
-
|
|
565
|
-
Voice direction for voiceArgs: ${this.getVoiceDirectorInstruction(state)}
|
|
566
|
-
|
|
567
|
-
Output JSON Schema:
|
|
568
|
-
{
|
|
569
|
-
"textResponse": "The clean spoken dialogue ONLY. CRITICAL: Strictly NO parentheses, NO actions, NO tone descriptors. Tone/voice descriptors MUST go inside voiceArgs, and physical actions MUST go inside actionText. If nothing to speak, output an empty string.",
|
|
570
|
-
"actionText": "Any non-verbal actions, inner thoughts, or scene descriptions in parentheses (e.g. '(低头看向你)'). Output empty string if none.",
|
|
571
|
-
"stateUpdate": { "temperatureDelta": 1, "userNickname": "What you now call the user", "agentNickname": "What the user calls you", "talkingStyle": "Current mood/style of talking", "ongoingScene": "A concise 1-sentence description of the current physical scene and activity. Update this if the physical scene or activity shifts. Output empty string if the scene has concluded or significant time has passed." },
|
|
572
|
-
"userAnalysis": { "newFactsLearned": [{ "category": "occupation", "value": "Software Engineer" }] },
|
|
573
|
-
"triggerEvent": {
|
|
574
|
-
${this.getEventSchemaParams(state.dynamic_context?.userNickname)}
|
|
575
|
-
},
|
|
576
|
-
${this.getImageSchemaParams()},
|
|
577
|
-
${this.getVoiceSchemaFromState(state)}
|
|
578
|
-
}
|
|
579
|
-
Note: If "imageParams", "voiceArgs", "triggerEvent", or "userAnalysis" are not needed, set their values to null instead of omitting the keys. 'stateUpdate' MUST NEVER BE NULL. Output MUST be ONLY valid JSON with no markdown block wrappers. CRITICAL: Ensure your JSON has exactly one root object \`{\` and ends with exactly one \`}\` without any trailing garbage or extra brackets.`;
|
|
580
|
-
const transcript = this.buildHistoryTranscript(params.history, state);
|
|
581
|
-
const userName = state.dynamic_context?.userNickname || "User";
|
|
582
|
-
const promptMessages = [
|
|
583
|
-
{ role: "system", content: systemPrompt },
|
|
584
|
-
{
|
|
585
|
-
role: "user",
|
|
586
|
-
content: transcript + userName + ": " +
|
|
587
|
-
params.userMessage +
|
|
588
|
-
"\n\n**CRITICAL REMINDER**: You MUST output your final response exactly in the JSON format specified in the system prompt. DO NOT output plain text dialogue directly. CRITICAL: You must properly escape all newlines inside string values using \\n. Never use raw, unescaped line breaks inside the JSON strings. For 'imageParams', ALL values MUST be in ENGLISH ONLY without exception, and you MUST use the exact English enum strings provided.",
|
|
589
|
-
},
|
|
590
|
-
];
|
|
591
|
-
// 3. Local Execute LLM
|
|
592
|
-
const rawLlmResponse = await this.llm.generate(promptMessages, 15000, 0.7);
|
|
593
|
-
// console.debug("[CyberSoulClient] Raw LLM Response:", rawLlmResponse);
|
|
594
|
-
let parsedIntent;
|
|
595
|
-
try {
|
|
596
|
-
parsedIntent = robustJsonParse(rawLlmResponse, "Dispatcher fallback");
|
|
597
|
-
}
|
|
598
|
-
catch (e) {
|
|
599
|
-
console.warn("[CyberSoulClient] JSON parse failed, falling back to raw text:", e);
|
|
600
|
-
// Fallback robust mode - just text if completely broken
|
|
601
|
-
parsedIntent = {
|
|
602
|
-
textResponse: rawLlmResponse.replace(/^[\`\s]+|[\`\s]+$/g, "").trim(),
|
|
603
|
-
};
|
|
604
|
-
}
|
|
605
|
-
// console.debug("[CyberSoulClient] Parsed Intent:", parsedIntent);
|
|
606
|
-
// 4. Update Backend State async
|
|
607
|
-
if (parsedIntent && (parsedIntent.stateUpdate || parsedIntent.userAnalysis)) {
|
|
608
|
-
this._updateDynamicContextInternal(parsedIntent.stateUpdate, parsedIntent.userAnalysis);
|
|
609
|
-
}
|
|
610
|
-
const resolvedTextResponse = typeof parsedIntent.textResponse === "string" &&
|
|
611
|
-
parsedIntent.textResponse.trim().length > 0
|
|
612
|
-
? parsedIntent.textResponse
|
|
613
|
-
: params.userMessage;
|
|
614
|
-
// Fire text ready callback if provided
|
|
615
|
-
if (params.onTextReady && resolvedTextResponse) {
|
|
616
|
-
params.onTextReady(resolvedTextResponse);
|
|
617
|
-
}
|
|
618
|
-
// 5. Build Final Media Calls parallel
|
|
619
|
-
const mediaTasks = [];
|
|
620
|
-
let finalImageUrl = undefined;
|
|
621
|
-
let finalAudioUrl = undefined;
|
|
622
|
-
let finalDurationSec = undefined;
|
|
623
|
-
// Output Event Trigger
|
|
624
|
-
if (isAuto && parsedIntent.triggerEvent) {
|
|
625
|
-
mediaTasks.push(this.apiFetch("/api/v1/cyber-soul/characters/ondemand-event", {
|
|
626
|
-
method: "POST",
|
|
627
|
-
body: JSON.stringify({
|
|
628
|
-
eventTitle: parsedIntent.triggerEvent.eventTitle,
|
|
629
|
-
eventDescription: parsedIntent.triggerEvent.eventDescription,
|
|
630
|
-
durationMins: parsedIntent.triggerEvent.durationMins || 60,
|
|
631
|
-
outfitId: parsedIntent.triggerEvent.outfitId || undefined,
|
|
632
|
-
scheduledStartTimeStr: parsedIntent.triggerEvent.scheduledStartTimeStr || undefined,
|
|
633
|
-
scheduledDateStr: parsedIntent.triggerEvent.scheduledDateStr || undefined,
|
|
634
|
-
}),
|
|
635
|
-
}).catch(e => console.error("[CyberSoulClient] Auto-triggered ondemandEvent failed:", e)));
|
|
636
|
-
}
|
|
637
|
-
const shouldGenerateImage = types.includes(InteractRequestType.IMAGE) ||
|
|
638
|
-
(isAuto && !!parsedIntent.imageParams);
|
|
639
|
-
if (shouldGenerateImage) {
|
|
640
|
-
const imagePayload = parsedIntent.imageParams && typeof parsedIntent.imageParams === "object"
|
|
641
|
-
? parsedIntent.imageParams
|
|
642
|
-
: {
|
|
643
|
-
mode: "full-prompt",
|
|
644
|
-
full_prompt: resolvedTextResponse,
|
|
645
|
-
};
|
|
646
|
-
mediaTasks.push(this.generatePrimitive("image", imagePayload).then((res) => {
|
|
647
|
-
finalImageUrl = res.image_url;
|
|
648
|
-
}));
|
|
649
|
-
}
|
|
650
|
-
const shouldGenerateVoice = types.includes(InteractRequestType.VOICE) ||
|
|
651
|
-
(isAuto && !!parsedIntent.voiceArgs);
|
|
652
|
-
if (shouldGenerateVoice) {
|
|
653
|
-
const normalizedVoiceArgs = parsedIntent.voiceArgs && typeof parsedIntent.voiceArgs === "object"
|
|
654
|
-
? parsedIntent.voiceArgs
|
|
655
|
-
: {};
|
|
656
|
-
let textForVoice = resolvedTextResponse;
|
|
657
|
-
// One final bulletproof regex wash to strip (smiles) and *laughs* just in case the LLM disobeys
|
|
658
|
-
if (typeof textForVoice === "string") {
|
|
659
|
-
textForVoice = textForVoice.replace(/[\((\[【\*].*?[\))\]】\*]/g, '').trim();
|
|
660
|
-
}
|
|
661
|
-
if (typeof textForVoice !== "string" || textForVoice.trim().length === 0) {
|
|
662
|
-
textForVoice = "...";
|
|
663
|
-
}
|
|
664
|
-
mediaTasks.push(this.generatePrimitive("voice", {
|
|
665
|
-
text: textForVoice,
|
|
666
|
-
dynamicArgs: normalizedVoiceArgs,
|
|
667
|
-
}).then((res) => {
|
|
668
|
-
finalAudioUrl = res.audio_url;
|
|
669
|
-
finalDurationSec = res.duration_sec;
|
|
670
|
-
}));
|
|
671
|
-
}
|
|
672
|
-
// Wait for image/voice gens to return successfully
|
|
673
|
-
await Promise.all(mediaTasks);
|
|
674
|
-
return {
|
|
675
|
-
status: "success",
|
|
676
|
-
textResponse: resolvedTextResponse || "...",
|
|
677
|
-
actionText: parsedIntent.actionText || "",
|
|
678
|
-
imageUrl: finalImageUrl,
|
|
679
|
-
audioUrl: finalAudioUrl,
|
|
680
|
-
durationSec: finalDurationSec,
|
|
681
|
-
triggeredEvent: parsedIntent.triggerEvent || undefined,
|
|
682
|
-
stateUpdate: parsedIntent.stateUpdate,
|
|
683
|
-
userAnalysis: parsedIntent.userAnalysis,
|
|
684
|
-
};
|
|
685
|
-
}
|
|
686
|
-
catch (error) {
|
|
687
|
-
console.error("[CyberSoulClient] Interface Error: ", error);
|
|
688
|
-
return {
|
|
689
|
-
status: "error",
|
|
690
|
-
textResponse: "System Error...",
|
|
691
|
-
error: error.message,
|
|
692
|
-
};
|
|
822
|
+
throw new Error("Failed to save character moment.");
|
|
693
823
|
}
|
|
694
824
|
}
|
|
695
825
|
/**
|
|
@@ -724,9 +854,9 @@ Your task is to merge the 'Current Core Memory' and 'Current User Codex' with 'N
|
|
|
724
854
|
4. **Appointment Structure:** the 'title' and 'context' MUST explicitly state what to do and with whom.
|
|
725
855
|
5. **Limit:** Maximum 10 items per array.
|
|
726
856
|
|
|
727
|
-
**Rules for
|
|
857
|
+
**Rules for UserCodex:**
|
|
728
858
|
1. **Deduplicate & Consolidate:** Remove duplicate hobbies, traits, and boundaries. Combine related points into concise descriptors.
|
|
729
|
-
2. **Update Facts:** If the new events contain updated basic info (like new
|
|
859
|
+
2. **Update Facts:** If the new events contain updated basic info (like new realName, different occupation), update it. Otherwise keep the existing info.
|
|
730
860
|
3. **Keep it Clean:** Maximum 15 items per array.
|
|
731
861
|
|
|
732
862
|
**Output Format**: MUST be valid JSON matching this schema:
|
|
@@ -746,7 +876,7 @@ Your task is to merge the 'Current Core Memory' and 'Current User Codex' with 'N
|
|
|
746
876
|
},
|
|
747
877
|
"userCodex": {
|
|
748
878
|
"basicInfo": {
|
|
749
|
-
"
|
|
879
|
+
"realName": "string",
|
|
750
880
|
"occupation": "string",
|
|
751
881
|
"age": "string",
|
|
752
882
|
"gender": "string"
|
package/dist/types.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface InteractParams {
|
|
|
27
27
|
localContext?: string;
|
|
28
28
|
requestTypes?: InteractRequestType[];
|
|
29
29
|
history?: HistoryEntry[];
|
|
30
|
-
onTextReady?: (textResponse: string) => void;
|
|
30
|
+
onTextReady?: (textResponse: string, actionText?: string) => void;
|
|
31
31
|
}
|
|
32
32
|
export interface OndemandEventParams {
|
|
33
33
|
eventDescription: string;
|
|
@@ -65,16 +65,24 @@ export interface InteractResponse {
|
|
|
65
65
|
};
|
|
66
66
|
stateUpdate?: DispatcherIntent["stateUpdate"];
|
|
67
67
|
userAnalysis?: DispatcherIntent["userAnalysis"];
|
|
68
|
+
isEndTurn?: boolean;
|
|
68
69
|
error?: string;
|
|
69
70
|
}
|
|
71
|
+
export interface OngoingSceneState {
|
|
72
|
+
scene: string;
|
|
73
|
+
outfit: string;
|
|
74
|
+
}
|
|
70
75
|
export interface DispatcherIntent {
|
|
71
76
|
textResponse?: string;
|
|
72
77
|
actionText?: string;
|
|
73
78
|
imageParams?: any;
|
|
74
79
|
voiceArgs?: VoiceArgs | null;
|
|
80
|
+
giftOutfit?: {
|
|
81
|
+
descriptionText: string;
|
|
82
|
+
} | null;
|
|
75
83
|
userAnalysis?: {
|
|
76
84
|
newFactsLearned: {
|
|
77
|
-
category: "
|
|
85
|
+
category: "realName" | "occupation" | "age" | "gender" | "hobby" | "trait" | "communicationStyle" | "boundary";
|
|
78
86
|
value: string;
|
|
79
87
|
}[];
|
|
80
88
|
};
|
|
@@ -83,7 +91,7 @@ export interface DispatcherIntent {
|
|
|
83
91
|
userNickname?: string;
|
|
84
92
|
agentNickname?: string;
|
|
85
93
|
talkingStyle?: string;
|
|
86
|
-
ongoingScene?: string;
|
|
94
|
+
ongoingScene?: OngoingSceneState | string | null;
|
|
87
95
|
};
|
|
88
96
|
triggerEvent?: {
|
|
89
97
|
eventTitle?: string;
|
|
@@ -93,6 +101,7 @@ export interface DispatcherIntent {
|
|
|
93
101
|
scheduledStartTimeStr?: string | null;
|
|
94
102
|
scheduledDateStr?: string | null;
|
|
95
103
|
} | null;
|
|
104
|
+
isEndTurn?: boolean;
|
|
96
105
|
}
|
|
97
106
|
export interface Appointment {
|
|
98
107
|
date: string;
|
|
@@ -110,7 +119,7 @@ export interface CoreMemory {
|
|
|
110
119
|
}
|
|
111
120
|
export interface UserCodex {
|
|
112
121
|
basicInfo: {
|
|
113
|
-
|
|
122
|
+
realName?: string;
|
|
114
123
|
occupation?: string;
|
|
115
124
|
age?: number | string;
|
|
116
125
|
gender?: string;
|
|
@@ -155,7 +164,15 @@ export interface CharacterState {
|
|
|
155
164
|
next_event?: any;
|
|
156
165
|
active_wardrobe?: any;
|
|
157
166
|
core_memory?: CoreMemory;
|
|
158
|
-
dynamic_context?:
|
|
167
|
+
dynamic_context?: {
|
|
168
|
+
temperature?: number;
|
|
169
|
+
userNickname?: string;
|
|
170
|
+
agentNickname?: string;
|
|
171
|
+
talkingStyle?: string;
|
|
172
|
+
lastInteractionAt?: string;
|
|
173
|
+
ongoingScene?: OngoingSceneState | string | null;
|
|
174
|
+
[key: string]: unknown;
|
|
175
|
+
};
|
|
159
176
|
voice_model?: VoiceModelState | null;
|
|
160
177
|
relationship_stage?: string;
|
|
161
178
|
name?: string;
|
package/dist/utils/json.utils.js
CHANGED
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
export function robustJsonParse(jsonString, contextMessage = 'throwing original error') {
|
|
2
2
|
let cleanJson = jsonString.trim();
|
|
3
|
-
// 0.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
3
|
+
// 0. Inject missing colons between string keys and string values (e.g. "key""value" -> "key":"value")
|
|
4
|
+
// Only insert the colon if we match a likely key (alphanumeric/hyphen) followed by quotes, handling smart quotes.
|
|
5
|
+
cleanJson = cleanJson.replace(/([”“"'][\w-]+[”“"'])\s*([”“"'])/g, '$1:$2');
|
|
6
|
+
// 0.1 Safely convert structural smart quotes to regular ASCII double quotes
|
|
7
|
+
// This allows proper parsing of keys/values that start/end with smart quotes,
|
|
8
|
+
// without accidentally unescaping double quotes *inside* string text.
|
|
9
|
+
cleanJson = cleanJson.replace(/([\{\[\:,]\s*)[“”]/g, '$1"');
|
|
10
|
+
cleanJson = cleanJson.replace(/[“”](\s*[\}\]\:,])/g, '"$1');
|
|
11
|
+
// 0.2 Any remaining smart quotes are inside string boundaries. Replace with safe single quotes.
|
|
12
|
+
cleanJson = cleanJson.replace(/[“”]/g, "'");
|
|
8
13
|
// 1. Strip Markdown code blocks (tolerates missing closing backticks)
|
|
9
14
|
const jsonMatch = cleanJson.match(/```(?:json)?\n?([\s\S]*?)(?:```|$)/i);
|
|
10
15
|
if (jsonMatch && jsonMatch[1].trim().startsWith('{')) {
|
|
11
16
|
cleanJson = jsonMatch[1].trim();
|
|
12
17
|
}
|
|
13
|
-
// 2. Strip any leading conversational text via fast substring
|
|
14
|
-
if (
|
|
18
|
+
// 2. Strip any leading conversational text or trailing garbage via fast substring
|
|
19
|
+
if (cleanJson.includes('{') && cleanJson.includes('}')) {
|
|
15
20
|
const firstIdx = cleanJson.indexOf('{');
|
|
16
21
|
const lastIdx = cleanJson.lastIndexOf('}');
|
|
17
|
-
if (firstIdx !== -1 && lastIdx > firstIdx) {
|
|
22
|
+
if (firstIdx !== -1 && lastIdx !== -1 && lastIdx > firstIdx) {
|
|
18
23
|
cleanJson = cleanJson.substring(firstIdx, lastIdx + 1);
|
|
19
24
|
}
|
|
20
25
|
}
|
|
21
|
-
// 3.
|
|
26
|
+
// 3. Fix common Edge LLM hallucinations of wrapping the JSON end with parenthesis like `})}` or `}})`
|
|
27
|
+
cleanJson = cleanJson.replace(/}\s*\)\s*}/g, '}}');
|
|
28
|
+
cleanJson = cleanJson.replace(/\)\s*}/g, '}');
|
|
29
|
+
// 4. Preprocess: escape unescaped newlines and control characters within string values
|
|
22
30
|
function preprocessControlChars(str) {
|
|
23
31
|
let result = '';
|
|
24
32
|
let inString = false;
|
|
@@ -147,6 +147,22 @@ function runTests() {
|
|
|
147
147
|
assert.equal(result['key_2'], 'val2');
|
|
148
148
|
assert.equal(result['empty'], '');
|
|
149
149
|
}
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: 'robustJsonParse - edge LLM trailing parenthesis hallucination',
|
|
153
|
+
run: () => {
|
|
154
|
+
const json = `{"key":"value"})}`;
|
|
155
|
+
const result = robustJsonParse(json);
|
|
156
|
+
assert.equal(result.key, 'value');
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'robustJsonParse - edge LLM trailing parenthesis hallucination with spacing',
|
|
161
|
+
run: () => {
|
|
162
|
+
const json = `{"key":"value"} ) }`;
|
|
163
|
+
const result = robustJsonParse(json);
|
|
164
|
+
assert.equal(result.key, 'value');
|
|
165
|
+
}
|
|
150
166
|
}
|
|
151
167
|
];
|
|
152
168
|
for (const t of tests) {
|