@yeaft/webchat-agent 0.1.62 → 0.1.63
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/claude.js +153 -1
- package/conversation.js +38 -5
- package/package.json +1 -1
- package/roleplay.js +226 -15
package/claude.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { query, Stream } from './sdk/index.js';
|
|
2
2
|
import ctx from './context.js';
|
|
3
3
|
import { sendConversationList, sendOutput, sendError, handleAskUserQuestion } from './conversation.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildRolePlaySystemPrompt,
|
|
6
|
+
rolePlaySessions,
|
|
7
|
+
initRolePlayRouteState,
|
|
8
|
+
detectRoleSignal,
|
|
9
|
+
processRolePlayRoutes,
|
|
10
|
+
buildRouteEventMessage,
|
|
11
|
+
getRolePlayRouteState
|
|
12
|
+
} from './roleplay.js';
|
|
5
13
|
|
|
6
14
|
/**
|
|
7
15
|
* Start a Claude SDK query for a conversation
|
|
@@ -89,6 +97,12 @@ export async function startClaudeQuery(conversationId, workDir, resumeSessionId)
|
|
|
89
97
|
if (savedRolePlayConfig) {
|
|
90
98
|
options.appendSystemPrompt = buildRolePlaySystemPrompt(savedRolePlayConfig);
|
|
91
99
|
console.log(`[SDK] RolePlay appendSystemPrompt injected (teamType: ${savedRolePlayConfig.teamType})`);
|
|
100
|
+
|
|
101
|
+
// Initialize RolePlay route state if session exists
|
|
102
|
+
const rpSession = rolePlaySessions.get(conversationId);
|
|
103
|
+
if (rpSession) {
|
|
104
|
+
initRolePlayRouteState(rpSession, state);
|
|
105
|
+
}
|
|
92
106
|
}
|
|
93
107
|
|
|
94
108
|
// Validate session ID is a valid UUID before using it
|
|
@@ -414,6 +428,51 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
|
|
|
414
428
|
continue;
|
|
415
429
|
}
|
|
416
430
|
|
|
431
|
+
// ★ RolePlay ROUTE detection: check accumulated text for ROUTE blocks
|
|
432
|
+
const rpSession = state.rolePlayConfig ? rolePlaySessions.get(conversationId) : null;
|
|
433
|
+
let roleplayAutoContinue = false;
|
|
434
|
+
let roleplayContinueRoles = [];
|
|
435
|
+
|
|
436
|
+
if (rpSession && rpSession._routeInitialized && state._roleplayAccumulated) {
|
|
437
|
+
const { routes, hasHumanRoute, continueRoles } = processRolePlayRoutes(
|
|
438
|
+
state._roleplayAccumulated, rpSession
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
if (routes.length > 0) {
|
|
442
|
+
// Send route events to frontend
|
|
443
|
+
for (const route of routes) {
|
|
444
|
+
const routeEvent = buildRouteEventMessage(
|
|
445
|
+
conversationId, rpSession.currentRole || 'unknown', route
|
|
446
|
+
);
|
|
447
|
+
ctx.sendToServer(routeEvent);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Send route state update
|
|
451
|
+
const routeState = getRolePlayRouteState(conversationId);
|
|
452
|
+
if (routeState) {
|
|
453
|
+
ctx.sendToServer({
|
|
454
|
+
type: 'roleplay_status',
|
|
455
|
+
conversationId,
|
|
456
|
+
...routeState
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (hasHumanRoute) {
|
|
461
|
+
// Stop auto-continue, wait for user input
|
|
462
|
+
ctx.sendToServer({
|
|
463
|
+
type: 'roleplay_waiting_human',
|
|
464
|
+
conversationId,
|
|
465
|
+
fromRole: rpSession.currentRole,
|
|
466
|
+
message: rpSession.waitingHumanContext?.message || ''
|
|
467
|
+
});
|
|
468
|
+
} else if (continueRoles.length > 0) {
|
|
469
|
+
// Auto-continue: pick the first route target and send the prompt
|
|
470
|
+
roleplayAutoContinue = true;
|
|
471
|
+
roleplayContinueRoles = continueRoles;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
417
476
|
// ★ Turn 完成:发送 turn_completed,进程继续运行等待下一条消息
|
|
418
477
|
// stream-json 模式下 Claude 进程是持久运行的,for-await 在 result 后继续等待
|
|
419
478
|
// 不清空 state.query 和 state.inputStream,下次用户消息直接通过同一个 inputStream 发送
|
|
@@ -424,6 +483,47 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
|
|
|
424
483
|
// ★ await 确保 result 和 turn_completed 消息确实发送成功
|
|
425
484
|
// 不 await 会导致 encrypt 失败时消息静默丢失,前端卡在"思考中"
|
|
426
485
|
await sendOutput(conversationId, message);
|
|
486
|
+
|
|
487
|
+
// ★ RolePlay auto-continue: inject next role's prompt into the same conversation
|
|
488
|
+
if (roleplayAutoContinue && rpSession && state.inputStream) {
|
|
489
|
+
// Reset accumulated text for next turn
|
|
490
|
+
state._roleplayAccumulated = '';
|
|
491
|
+
|
|
492
|
+
for (const { to, prompt, taskId, taskTitle } of roleplayContinueRoles) {
|
|
493
|
+
rpSession.currentRole = to;
|
|
494
|
+
if (rpSession.roleStates[to]) {
|
|
495
|
+
rpSession.roleStates[to].status = 'active';
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
console.log(`[RolePlay] Auto-continuing to role: ${to}`);
|
|
499
|
+
|
|
500
|
+
// Re-activate the turn
|
|
501
|
+
state.turnActive = true;
|
|
502
|
+
state.turnResultReceived = false;
|
|
503
|
+
|
|
504
|
+
// Send the continuation prompt through the same input stream
|
|
505
|
+
const userMessage = {
|
|
506
|
+
type: 'user',
|
|
507
|
+
message: { role: 'user', content: prompt }
|
|
508
|
+
};
|
|
509
|
+
sendOutput(conversationId, userMessage);
|
|
510
|
+
state.inputStream.enqueue(userMessage);
|
|
511
|
+
|
|
512
|
+
// RolePlay uses a single conversation — only one target can be active
|
|
513
|
+
// at a time. Additional route targets are ignored.
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Send status update (don't send turn_completed yet since we're continuing)
|
|
518
|
+
sendConversationList();
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Reset accumulated text
|
|
523
|
+
if (state._roleplayAccumulated !== undefined) {
|
|
524
|
+
state._roleplayAccumulated = '';
|
|
525
|
+
}
|
|
526
|
+
|
|
427
527
|
await ctx.sendToServer({
|
|
428
528
|
type: 'turn_completed',
|
|
429
529
|
conversationId,
|
|
@@ -434,9 +534,61 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
|
|
|
434
534
|
continue;
|
|
435
535
|
}
|
|
436
536
|
|
|
537
|
+
// ★ RolePlay: accumulate assistant text and detect ROLE signals
|
|
538
|
+
if (state.rolePlayConfig && message.type === 'assistant' && message.message?.content) {
|
|
539
|
+
const content = message.message.content;
|
|
540
|
+
let textChunk = '';
|
|
541
|
+
if (typeof content === 'string') {
|
|
542
|
+
textChunk = content;
|
|
543
|
+
} else if (Array.isArray(content)) {
|
|
544
|
+
textChunk = content.filter(b => b.type === 'text').map(b => b.text).join('');
|
|
545
|
+
}
|
|
546
|
+
if (textChunk) {
|
|
547
|
+
if (state._roleplayAccumulated === undefined) {
|
|
548
|
+
state._roleplayAccumulated = '';
|
|
549
|
+
}
|
|
550
|
+
state._roleplayAccumulated += textChunk;
|
|
551
|
+
|
|
552
|
+
// Detect ROLE signal for current role tracking
|
|
553
|
+
const rpSession = rolePlaySessions.get(conversationId);
|
|
554
|
+
if (rpSession && rpSession._routeInitialized) {
|
|
555
|
+
const detectedRole = detectRoleSignal(textChunk);
|
|
556
|
+
if (detectedRole) {
|
|
557
|
+
const prevRole = rpSession.currentRole;
|
|
558
|
+
rpSession.currentRole = detectedRole;
|
|
559
|
+
if (rpSession.roleStates[detectedRole]) {
|
|
560
|
+
rpSession.roleStates[detectedRole].status = 'active';
|
|
561
|
+
}
|
|
562
|
+
if (prevRole && prevRole !== detectedRole && rpSession.roleStates[prevRole]) {
|
|
563
|
+
rpSession.roleStates[prevRole].status = 'idle';
|
|
564
|
+
}
|
|
565
|
+
console.log(`[RolePlay] Role switched: ${prevRole || 'none'} -> ${detectedRole}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
437
571
|
// 检测后台任务
|
|
438
572
|
detectAndTrackBackgroundTask(conversationId, state, message);
|
|
439
573
|
|
|
574
|
+
// ★ RolePlay: attach role metadata to messages sent to frontend
|
|
575
|
+
if (state.rolePlayConfig) {
|
|
576
|
+
const rpSession = rolePlaySessions.get(conversationId);
|
|
577
|
+
if (rpSession && rpSession._routeInitialized) {
|
|
578
|
+
// Attach current role info to the message as metadata
|
|
579
|
+
const enrichedMessage = {
|
|
580
|
+
...message,
|
|
581
|
+
_roleplay: {
|
|
582
|
+
role: rpSession.currentRole,
|
|
583
|
+
features: rpSession.features ? Array.from(rpSession.features.values()) : [],
|
|
584
|
+
round: rpSession.round
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
sendOutput(conversationId, enrichedMessage);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
440
592
|
sendOutput(conversationId, message);
|
|
441
593
|
}
|
|
442
594
|
} catch (error) {
|
package/conversation.js
CHANGED
|
@@ -2,7 +2,7 @@ import ctx from './context.js';
|
|
|
2
2
|
import { loadSessionHistory } from './history.js';
|
|
3
3
|
import { startClaudeQuery } from './claude.js';
|
|
4
4
|
import { crewSessions, loadCrewIndex } from './crew.js';
|
|
5
|
-
import { rolePlaySessions, saveRolePlayIndex, removeRolePlaySession, loadRolePlayIndex, validateRolePlayConfig } from './roleplay.js';
|
|
5
|
+
import { rolePlaySessions, saveRolePlayIndex, removeRolePlaySession, loadRolePlayIndex, validateRolePlayConfig, initRolePlayRouteState } from './roleplay.js';
|
|
6
6
|
|
|
7
7
|
// Restore persisted roleplay sessions on module load (agent startup)
|
|
8
8
|
loadRolePlayIndex();
|
|
@@ -54,7 +54,17 @@ export async function sendConversationList() {
|
|
|
54
54
|
// roleplay conversations are stored in ctx.conversations but also tracked in rolePlaySessions
|
|
55
55
|
if (rolePlaySessions.has(id)) {
|
|
56
56
|
entry.type = 'rolePlay';
|
|
57
|
-
|
|
57
|
+
const rpSession = rolePlaySessions.get(id);
|
|
58
|
+
entry.rolePlayRoles = rpSession.roles;
|
|
59
|
+
// Include route state if initialized
|
|
60
|
+
if (rpSession._routeInitialized) {
|
|
61
|
+
entry.rolePlayState = {
|
|
62
|
+
currentRole: rpSession.currentRole,
|
|
63
|
+
round: rpSession.round,
|
|
64
|
+
features: rpSession.features ? Array.from(rpSession.features.values()) : [],
|
|
65
|
+
waitingHuman: rpSession.waitingHuman || false
|
|
66
|
+
};
|
|
67
|
+
}
|
|
58
68
|
}
|
|
59
69
|
list.push(entry);
|
|
60
70
|
}
|
|
@@ -161,7 +171,7 @@ export async function createConversation(msg) {
|
|
|
161
171
|
|
|
162
172
|
// Register in rolePlaySessions for type inference in sendConversationList
|
|
163
173
|
if (rolePlayConfig) {
|
|
164
|
-
|
|
174
|
+
const rpSession = {
|
|
165
175
|
roles: rolePlayConfig.roles,
|
|
166
176
|
teamType: rolePlayConfig.teamType,
|
|
167
177
|
language: rolePlayConfig.language,
|
|
@@ -169,7 +179,10 @@ export async function createConversation(msg) {
|
|
|
169
179
|
createdAt: Date.now(),
|
|
170
180
|
userId,
|
|
171
181
|
username,
|
|
172
|
-
}
|
|
182
|
+
};
|
|
183
|
+
rolePlaySessions.set(conversationId, rpSession);
|
|
184
|
+
// Initialize route state eagerly so it's ready when Claude starts
|
|
185
|
+
initRolePlayRouteState(rpSession, ctx.conversations.get(conversationId));
|
|
173
186
|
saveRolePlayIndex();
|
|
174
187
|
}
|
|
175
188
|
|
|
@@ -467,9 +480,29 @@ export async function handleUserInput(msg) {
|
|
|
467
480
|
|
|
468
481
|
// 发送用户消息到输入流
|
|
469
482
|
// Claude stream-json 模式支持在回复过程中接收新消息(写入 stdin)
|
|
483
|
+
let effectivePrompt = prompt;
|
|
484
|
+
|
|
485
|
+
// ★ RolePlay: if session was waiting for human input, clear the flag and
|
|
486
|
+
// wrap the user message with context about which role was asking
|
|
487
|
+
const rpSession = rolePlaySessions.get(conversationId);
|
|
488
|
+
if (rpSession && rpSession.waitingHuman && rpSession.waitingHumanContext) {
|
|
489
|
+
const { fromRole, message: requestMessage } = rpSession.waitingHumanContext;
|
|
490
|
+
const fromRoleConfig = rpSession.roles.find?.(r => r.name === fromRole) ||
|
|
491
|
+
(Array.isArray(rpSession.roles) ? rpSession.roles.find(r => r.name === fromRole) : null);
|
|
492
|
+
const fromLabel = fromRoleConfig
|
|
493
|
+
? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
|
|
494
|
+
: fromRole;
|
|
495
|
+
|
|
496
|
+
effectivePrompt = `人工回复(回应 ${fromLabel} 的请求: "${requestMessage}"):\n\n${prompt}`;
|
|
497
|
+
|
|
498
|
+
rpSession.waitingHuman = false;
|
|
499
|
+
rpSession.waitingHumanContext = null;
|
|
500
|
+
console.log(`[RolePlay] Human responded, resuming from ${fromRole}'s request`);
|
|
501
|
+
}
|
|
502
|
+
|
|
470
503
|
const userMessage = {
|
|
471
504
|
type: 'user',
|
|
472
|
-
message: { role: 'user', content:
|
|
505
|
+
message: { role: 'user', content: effectivePrompt }
|
|
473
506
|
};
|
|
474
507
|
|
|
475
508
|
console.log(`[${conversationId}] Sending: ${prompt.substring(0, 100)}...`);
|
package/package.json
CHANGED
package/roleplay.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Role Play — lightweight multi-role collaboration within a single conversation.
|
|
3
3
|
*
|
|
4
|
-
* Manages rolePlaySessions (in-memory + persisted to disk)
|
|
5
|
-
* appendSystemPrompt that instructs Claude to role-play multiple characters
|
|
4
|
+
* Manages rolePlaySessions (in-memory + persisted to disk), builds the
|
|
5
|
+
* appendSystemPrompt that instructs Claude to role-play multiple characters,
|
|
6
|
+
* and handles ROUTE protocol for Crew-style role switching within a single
|
|
7
|
+
* Claude conversation.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import { join } from 'path';
|
|
9
11
|
import { homedir } from 'os';
|
|
10
12
|
import { readFileSync, writeFileSync, existsSync, renameSync } from 'fs';
|
|
13
|
+
import { parseRoutes } from './crew/routing.js';
|
|
11
14
|
|
|
12
15
|
const ROLEPLAY_INDEX_PATH = join(homedir(), '.claude', 'roleplay-sessions.json');
|
|
13
16
|
// ★ backward compat: old filename before rename
|
|
@@ -23,7 +26,9 @@ export const rolePlaySessions = new Map();
|
|
|
23
26
|
export function saveRolePlayIndex() {
|
|
24
27
|
const data = [];
|
|
25
28
|
for (const [id, session] of rolePlaySessions) {
|
|
26
|
-
|
|
29
|
+
// Only persist core fields, skip runtime route state
|
|
30
|
+
const { _routeInitialized, currentRole, features, round, roleStates, waitingHuman, waitingHumanContext, ...core } = session;
|
|
31
|
+
data.push({ id, ...core });
|
|
27
32
|
}
|
|
28
33
|
try {
|
|
29
34
|
writeFileSync(ROLEPLAY_INDEX_PATH, JSON.stringify(data, null, 2));
|
|
@@ -196,17 +201,37 @@ ${roleList}
|
|
|
196
201
|
|
|
197
202
|
## 角色切换规则
|
|
198
203
|
|
|
199
|
-
|
|
204
|
+
### 方式一:ROUTE 协议(推荐)
|
|
205
|
+
|
|
206
|
+
当一个角色完成工作需要交给另一个角色时,使用 ROUTE 块:
|
|
207
|
+
|
|
208
|
+
\`\`\`
|
|
209
|
+
---ROUTE---
|
|
210
|
+
to: {目标角色name}
|
|
211
|
+
summary: {交接内容摘要}
|
|
212
|
+
task: {任务ID,如 task-1}(可选)
|
|
213
|
+
taskTitle: {任务标题}(可选)
|
|
214
|
+
---END_ROUTE---
|
|
215
|
+
\`\`\`
|
|
216
|
+
|
|
217
|
+
ROUTE 规则:
|
|
218
|
+
- 一次可以输出多个 ROUTE 块(例如同时发给 reviewer 和 tester)
|
|
219
|
+
- \`to\` 必须是有效的角色 name,或 \`human\` 表示需要用户输入
|
|
220
|
+
- \`summary\` 是交给目标角色的具体任务和上下文
|
|
221
|
+
- \`task\` / \`taskTitle\` 用于追踪 feature/任务(PM 分配任务时应填写)
|
|
222
|
+
- ROUTE 块必须在角色输出的末尾
|
|
223
|
+
|
|
224
|
+
### 方式二:ROLE 信号(简单切换)
|
|
200
225
|
|
|
201
226
|
---ROLE: {角色name}---
|
|
202
227
|
|
|
203
|
-
|
|
228
|
+
直接切换到目标角色继续工作,适用于简单的角色轮转。
|
|
204
229
|
|
|
205
|
-
|
|
230
|
+
### 通用规则
|
|
231
|
+
|
|
232
|
+
- 切换后,你必须完全以该角色的视角和人格思考、说话和行动
|
|
206
233
|
- 第一条输出必须先切换到起始角色(通常是 PM)
|
|
207
|
-
-
|
|
208
|
-
- 切换后立即以新角色身份开始工作
|
|
209
|
-
- 信号格式必须严格匹配:三个短横线 + ROLE: + 空格 + 角色name + 三个短横线
|
|
234
|
+
- 每次切换前留下交接信息(完成了什么、对下一角色的要求)
|
|
210
235
|
|
|
211
236
|
## 工作流程
|
|
212
237
|
|
|
@@ -243,17 +268,37 @@ ${roleList}
|
|
|
243
268
|
|
|
244
269
|
## Role Switching Rules
|
|
245
270
|
|
|
246
|
-
|
|
271
|
+
### Method 1: ROUTE Protocol (Recommended)
|
|
272
|
+
|
|
273
|
+
When a role finishes work and needs to hand off to another role, use a ROUTE block:
|
|
274
|
+
|
|
275
|
+
\`\`\`
|
|
276
|
+
---ROUTE---
|
|
277
|
+
to: {target_role_name}
|
|
278
|
+
summary: {handoff content summary}
|
|
279
|
+
task: {task ID, e.g. task-1} (optional)
|
|
280
|
+
taskTitle: {task title} (optional)
|
|
281
|
+
---END_ROUTE---
|
|
282
|
+
\`\`\`
|
|
283
|
+
|
|
284
|
+
ROUTE rules:
|
|
285
|
+
- You can output multiple ROUTE blocks at once (e.g., send to both reviewer and tester)
|
|
286
|
+
- \`to\` must be a valid role name, or \`human\` to request user input
|
|
287
|
+
- \`summary\` is the specific task and context for the target role
|
|
288
|
+
- \`task\` / \`taskTitle\` are for tracking features/tasks (PM should fill these when assigning)
|
|
289
|
+
- ROUTE blocks must be at the end of the role's output
|
|
290
|
+
|
|
291
|
+
### Method 2: ROLE Signal (Simple Switch)
|
|
247
292
|
|
|
248
293
|
---ROLE: {role_name}---
|
|
249
294
|
|
|
250
|
-
|
|
295
|
+
Directly switch to the target role to continue working. Suitable for simple role rotation.
|
|
296
|
+
|
|
297
|
+
### General Rules
|
|
251
298
|
|
|
252
|
-
|
|
299
|
+
- After switching, you must fully think, speak, and act from that role's perspective
|
|
253
300
|
- Your first output must switch to the starting role (usually PM)
|
|
254
|
-
- Before each switch,
|
|
255
|
-
- After switching, immediately begin working as the new role
|
|
256
|
-
- Signal format must strictly match: three hyphens + ROLE: + space + role_name + three hyphens
|
|
301
|
+
- Before each switch, leave handoff information (what was done, requirements for next role)
|
|
257
302
|
|
|
258
303
|
## Workflow
|
|
259
304
|
|
|
@@ -320,3 +365,169 @@ function buildDevWorkflow(roleNames, isZh) {
|
|
|
320
365
|
|
|
321
366
|
return steps.join('\n');
|
|
322
367
|
}
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// RolePlay ROUTE protocol support
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
// Re-export parseRoutes for use by claude.js and tests
|
|
374
|
+
export { parseRoutes } from './crew/routing.js';
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Initialize RolePlay route state on a session.
|
|
378
|
+
* Called when a roleplay conversation is first created or resumed.
|
|
379
|
+
*
|
|
380
|
+
* @param {object} session - rolePlaySessions entry
|
|
381
|
+
* @param {object} convState - ctx.conversations entry
|
|
382
|
+
*/
|
|
383
|
+
export function initRolePlayRouteState(session, convState) {
|
|
384
|
+
if (!session._routeInitialized) {
|
|
385
|
+
session.currentRole = null;
|
|
386
|
+
session.features = new Map();
|
|
387
|
+
session.round = 0;
|
|
388
|
+
session.roleStates = {};
|
|
389
|
+
session.waitingHuman = false;
|
|
390
|
+
session.waitingHumanContext = null;
|
|
391
|
+
|
|
392
|
+
// Initialize per-role states
|
|
393
|
+
for (const role of session.roles) {
|
|
394
|
+
session.roleStates[role.name] = {
|
|
395
|
+
currentTask: null,
|
|
396
|
+
status: 'idle'
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
session._routeInitialized = true;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Also store accumulated text on convState for ROUTE detection during streaming
|
|
403
|
+
if (!convState._roleplayAccumulated) {
|
|
404
|
+
convState._roleplayAccumulated = '';
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Detect a ROLE signal in text: ---ROLE: xxx---
|
|
410
|
+
* Returns the role name if found at the end of accumulated text, null otherwise.
|
|
411
|
+
*/
|
|
412
|
+
export function detectRoleSignal(text) {
|
|
413
|
+
const match = text.match(/---ROLE:\s*([a-zA-Z0-9_-]+)\s*---/);
|
|
414
|
+
return match ? match[1].toLowerCase() : null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Process ROUTE blocks detected in a completed turn's output.
|
|
419
|
+
* Called from claude.js when a result message is received.
|
|
420
|
+
*
|
|
421
|
+
* Returns { routes, hasHumanRoute, continueRoles } for the caller to act on.
|
|
422
|
+
*
|
|
423
|
+
* @param {string} accumulatedText - full text output from the current turn
|
|
424
|
+
* @param {object} session - rolePlaySessions entry
|
|
425
|
+
* @returns {{ routes: Array, hasHumanRoute: boolean, continueRoles: Array<{to, prompt}> }}
|
|
426
|
+
*/
|
|
427
|
+
export function processRolePlayRoutes(accumulatedText, session) {
|
|
428
|
+
const routes = parseRoutes(accumulatedText);
|
|
429
|
+
if (routes.length === 0) {
|
|
430
|
+
return { routes: [], hasHumanRoute: false, continueRoles: [] };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const roleNames = new Set(session.roles.map(r => r.name));
|
|
434
|
+
let hasHumanRoute = false;
|
|
435
|
+
const continueRoles = [];
|
|
436
|
+
|
|
437
|
+
for (const route of routes) {
|
|
438
|
+
const { to, summary, taskId, taskTitle } = route;
|
|
439
|
+
|
|
440
|
+
// Track features
|
|
441
|
+
if (taskId && taskTitle && !session.features.has(taskId)) {
|
|
442
|
+
session.features.set(taskId, { taskId, taskTitle, createdAt: Date.now() });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Update source role state
|
|
446
|
+
if (session.currentRole && session.roleStates[session.currentRole]) {
|
|
447
|
+
session.roleStates[session.currentRole].status = 'idle';
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (to === 'human') {
|
|
451
|
+
hasHumanRoute = true;
|
|
452
|
+
session.waitingHuman = true;
|
|
453
|
+
session.waitingHumanContext = {
|
|
454
|
+
fromRole: session.currentRole,
|
|
455
|
+
reason: 'requested',
|
|
456
|
+
message: summary
|
|
457
|
+
};
|
|
458
|
+
} else if (roleNames.has(to)) {
|
|
459
|
+
// Update target role state
|
|
460
|
+
if (session.roleStates[to]) {
|
|
461
|
+
session.roleStates[to].status = 'active';
|
|
462
|
+
if (taskId) {
|
|
463
|
+
session.roleStates[to].currentTask = { taskId, taskTitle };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Build prompt for the target role
|
|
468
|
+
const fromRole = session.currentRole || 'unknown';
|
|
469
|
+
const fromRoleConfig = session.roles.find(r => r.name === fromRole);
|
|
470
|
+
const fromLabel = fromRoleConfig
|
|
471
|
+
? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
|
|
472
|
+
: fromRole;
|
|
473
|
+
|
|
474
|
+
const targetRoleConfig = session.roles.find(r => r.name === to);
|
|
475
|
+
const targetClaudeMd = targetRoleConfig?.claudeMd || '';
|
|
476
|
+
|
|
477
|
+
let prompt = `来自 ${fromLabel} 的消息:\n${summary}\n\n`;
|
|
478
|
+
if (targetClaudeMd) {
|
|
479
|
+
prompt += `---\n<role-context>\n${targetClaudeMd}\n</role-context>\n\n`;
|
|
480
|
+
}
|
|
481
|
+
prompt += `你现在是 ${targetRoleConfig?.displayName || to}。请开始你的工作。完成后通过 ROUTE 块传递给下一个角色。`;
|
|
482
|
+
|
|
483
|
+
continueRoles.push({ to, prompt, taskId, taskTitle });
|
|
484
|
+
} else {
|
|
485
|
+
console.warn(`[RolePlay] Unknown route target: ${to}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Increment round
|
|
490
|
+
session.round++;
|
|
491
|
+
|
|
492
|
+
return { routes, hasHumanRoute, continueRoles };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Build the route event message to send to the frontend via WebSocket.
|
|
497
|
+
*
|
|
498
|
+
* @param {string} conversationId
|
|
499
|
+
* @param {string} fromRole
|
|
500
|
+
* @param {{ to, summary, taskId, taskTitle }} route
|
|
501
|
+
* @returns {object} WebSocket message
|
|
502
|
+
*/
|
|
503
|
+
export function buildRouteEventMessage(conversationId, fromRole, route) {
|
|
504
|
+
return {
|
|
505
|
+
type: 'roleplay_route',
|
|
506
|
+
conversationId,
|
|
507
|
+
from: fromRole,
|
|
508
|
+
to: route.to,
|
|
509
|
+
taskId: route.taskId || null,
|
|
510
|
+
taskTitle: route.taskTitle || null,
|
|
511
|
+
summary: route.summary || ''
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Get the current RolePlay route state summary for frontend status updates.
|
|
517
|
+
*
|
|
518
|
+
* @param {string} conversationId
|
|
519
|
+
* @returns {object|null} Route state summary or null if not a roleplay session
|
|
520
|
+
*/
|
|
521
|
+
export function getRolePlayRouteState(conversationId) {
|
|
522
|
+
const session = rolePlaySessions.get(conversationId);
|
|
523
|
+
if (!session || !session._routeInitialized) return null;
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
currentRole: session.currentRole,
|
|
527
|
+
round: session.round,
|
|
528
|
+
features: session.features ? Array.from(session.features.values()) : [],
|
|
529
|
+
roleStates: session.roleStates || {},
|
|
530
|
+
waitingHuman: session.waitingHuman || false,
|
|
531
|
+
waitingHumanContext: session.waitingHumanContext || null
|
|
532
|
+
};
|
|
533
|
+
}
|