@yeaft/webchat-agent 0.1.491 → 0.1.492
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.
|
@@ -25,7 +25,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
|
|
|
25
25
|
import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
|
|
26
26
|
import { loadMcpServers, updateMcpConfig } from '../mcp.js';
|
|
27
27
|
import { getLlmConfig, updateLlmConfig } from '../unify/config-api.js';
|
|
28
|
-
import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory } from '../unify/web-bridge.js';
|
|
28
|
+
import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread } from '../unify/web-bridge.js';
|
|
29
29
|
|
|
30
30
|
export async function handleMessage(msg) {
|
|
31
31
|
switch (msg.type) {
|
|
@@ -341,6 +341,10 @@ export async function handleMessage(msg) {
|
|
|
341
341
|
await resetUnifySession();
|
|
342
342
|
break;
|
|
343
343
|
|
|
344
|
+
case 'unify_merge_thread':
|
|
345
|
+
handleUnifyMergeThread(msg);
|
|
346
|
+
break;
|
|
347
|
+
|
|
344
348
|
// Expert roles definition (for ExpertPanel detail view)
|
|
345
349
|
case 'get_expert_roles': {
|
|
346
350
|
const { getExpertRolesDefinition } = await import('../expert-roles.js');
|
package/package.json
CHANGED
|
@@ -60,6 +60,10 @@ function serializeMessage(msg) {
|
|
|
60
60
|
// routing can filter/replay by thread without rescanning JSON blobs.
|
|
61
61
|
// Defaults to 'main' for legacy messages (see migrate-messages-threadid.js).
|
|
62
62
|
fm.push(`threadId: ${msg.threadId || 'main'}`);
|
|
63
|
+
// task-313: when a thread is merged into another, the messages keep
|
|
64
|
+
// their original thread id in `sourceThreadId` so the UI can still
|
|
65
|
+
// render a small "#source" pill next to each bubble.
|
|
66
|
+
if (msg.sourceThreadId) fm.push(`sourceThreadId: ${msg.sourceThreadId}`);
|
|
63
67
|
|
|
64
68
|
// Token estimate
|
|
65
69
|
const content = msg.content || '';
|
|
@@ -116,6 +120,7 @@ export function parseMessage(raw) {
|
|
|
116
120
|
case 'isError': msg.isError = value === 'true'; break;
|
|
117
121
|
case 'tokens_est': msg.tokens_est = parseInt(value, 10); break;
|
|
118
122
|
case 'threadId': msg.threadId = value; break;
|
|
123
|
+
case 'sourceThreadId': msg.sourceThreadId = value; break;
|
|
119
124
|
// toolCalls are multi-line YAML — handled separately below
|
|
120
125
|
}
|
|
121
126
|
}
|
|
@@ -461,6 +466,63 @@ export class ConversationStore {
|
|
|
461
466
|
|
|
462
467
|
// ─── Internal ───────────────────────────────────────────
|
|
463
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Reassign every message in this store whose `threadId === sourceId`
|
|
471
|
+
* to `targetId`. The original thread id is preserved in
|
|
472
|
+
* `sourceThreadId` so the UI can still render a "#source" pill.
|
|
473
|
+
* Scans both hot (`messages/`) and cold (`cold/`) directories.
|
|
474
|
+
*
|
|
475
|
+
* Idempotent: messages already carrying `sourceThreadId` are not
|
|
476
|
+
* overwritten, and messages not on `sourceId` are skipped.
|
|
477
|
+
*
|
|
478
|
+
* @param {string} sourceId
|
|
479
|
+
* @param {string} targetId
|
|
480
|
+
* @returns {number} number of messages rewritten
|
|
481
|
+
*/
|
|
482
|
+
reassignThread(sourceId, targetId) {
|
|
483
|
+
if (!sourceId || !targetId || sourceId === targetId) return 0;
|
|
484
|
+
let rewritten = 0;
|
|
485
|
+
for (const dir of [this.#msgDir, this.#coldDir]) {
|
|
486
|
+
if (!existsSync(dir)) continue;
|
|
487
|
+
let files;
|
|
488
|
+
try {
|
|
489
|
+
files = readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
490
|
+
} catch (err) {
|
|
491
|
+
if (isPermissionError(err)) continue;
|
|
492
|
+
throw err;
|
|
493
|
+
}
|
|
494
|
+
for (const file of files) {
|
|
495
|
+
const path = join(dir, file);
|
|
496
|
+
let raw;
|
|
497
|
+
try {
|
|
498
|
+
raw = readFileSync(path, 'utf8');
|
|
499
|
+
} catch (err) {
|
|
500
|
+
if (isPermissionError(err)) continue;
|
|
501
|
+
throw err;
|
|
502
|
+
}
|
|
503
|
+
const msg = parseMessage(raw);
|
|
504
|
+
if (!msg || msg.threadId !== sourceId) continue;
|
|
505
|
+
// Preserve original thread id for UI pill; only stamp once.
|
|
506
|
+
if (!msg.sourceThreadId) msg.sourceThreadId = sourceId;
|
|
507
|
+
msg.threadId = targetId;
|
|
508
|
+
try {
|
|
509
|
+
writeFileSync(path, serializeMessage(msg), { encoding: 'utf8', mode: 0o644 });
|
|
510
|
+
rewritten += 1;
|
|
511
|
+
} catch (err) {
|
|
512
|
+
if (isPermissionError(err)) {
|
|
513
|
+
if (!_permissionWarned) {
|
|
514
|
+
console.warn(`[Yeaft] Cannot rewrite message ${file}: ${err.code}`);
|
|
515
|
+
_permissionWarned = true;
|
|
516
|
+
}
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
throw err;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return rewritten;
|
|
524
|
+
}
|
|
525
|
+
|
|
464
526
|
/**
|
|
465
527
|
* Load messages from a directory, sorted by filename, limited.
|
|
466
528
|
* @param {string} dir
|
package/unify/threads/store.js
CHANGED
|
@@ -76,6 +76,7 @@ function serializeThread(t) {
|
|
|
76
76
|
`parentThreadId: ${t.parentThreadId == null ? 'null' : t.parentThreadId}`,
|
|
77
77
|
`status: ${t.status}`,
|
|
78
78
|
`archived: ${t.archived ? 'true' : 'false'}`,
|
|
79
|
+
`mergedInto: ${t.mergedInto == null ? 'null' : t.mergedInto}`,
|
|
79
80
|
`messageCount: ${t.messageCount | 0}`,
|
|
80
81
|
`lastMessageAt: ${t.lastMessageAt == null ? 'null' : t.lastMessageAt}`,
|
|
81
82
|
`lastActivityAt: ${t.lastActivityAt == null ? 'null' : t.lastActivityAt}`,
|
|
@@ -132,6 +133,7 @@ function parseThread(raw) {
|
|
|
132
133
|
// Default to safe values if the file pre-dates a field.
|
|
133
134
|
if (!THREAD_STATUSES.includes(record.status)) record.status = 'active';
|
|
134
135
|
record.archived = record.status === 'archived';
|
|
136
|
+
if (!('mergedInto' in record)) record.mergedInto = null;
|
|
135
137
|
record.messageCount = Number.isFinite(record.messageCount) ? record.messageCount : 0;
|
|
136
138
|
record.unread = Number.isFinite(record.unread) ? record.unread : 0;
|
|
137
139
|
record.preview = body;
|
|
@@ -308,6 +310,7 @@ export class ThreadStore {
|
|
|
308
310
|
lastMessageAt: null,
|
|
309
311
|
lastActivityAt: null,
|
|
310
312
|
archived: false,
|
|
313
|
+
mergedInto: null,
|
|
311
314
|
unread: 0,
|
|
312
315
|
preview: '',
|
|
313
316
|
...base,
|
|
@@ -543,6 +546,85 @@ export class ThreadStore {
|
|
|
543
546
|
this.#markDirty(id);
|
|
544
547
|
}
|
|
545
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Merge the source thread into the target (task-313). The source is
|
|
551
|
+
* marked archived and gets `mergedInto: targetId`; target's cached
|
|
552
|
+
* counters pick up the source's message count and activity. Callers
|
|
553
|
+
* are still expected to reassign the actual messages on disk via
|
|
554
|
+
* `ConversationStore.reassignThread(sourceId, targetId)`.
|
|
555
|
+
*
|
|
556
|
+
* Constraints:
|
|
557
|
+
* - source !== target
|
|
558
|
+
* - both threads must exist
|
|
559
|
+
* - source cannot be the main thread (main cannot be archived)
|
|
560
|
+
* - source cannot already have been merged elsewhere (idempotency)
|
|
561
|
+
*
|
|
562
|
+
* @param {string} sourceId
|
|
563
|
+
* @param {string} targetId
|
|
564
|
+
* @returns {{ source: Thread, target: Thread }}
|
|
565
|
+
*/
|
|
566
|
+
mergeThread(sourceId, targetId) {
|
|
567
|
+
if (!sourceId || !targetId) {
|
|
568
|
+
throw new Error('mergeThread: sourceId and targetId required');
|
|
569
|
+
}
|
|
570
|
+
if (sourceId === targetId) {
|
|
571
|
+
throw new Error('mergeThread: cannot merge a thread into itself');
|
|
572
|
+
}
|
|
573
|
+
if (sourceId === MAIN_THREAD_ID) {
|
|
574
|
+
throw new Error('mergeThread: cannot merge the main thread into another');
|
|
575
|
+
}
|
|
576
|
+
const source = this.#threads.get(sourceId);
|
|
577
|
+
if (!source) throw new Error(`thread not found: ${sourceId}`);
|
|
578
|
+
const target = this.#threads.get(targetId);
|
|
579
|
+
if (!target) throw new Error(`thread not found: ${targetId}`);
|
|
580
|
+
if (source.mergedInto) {
|
|
581
|
+
throw new Error(`thread ${sourceId} already merged into ${source.mergedInto}`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const now = Date.now();
|
|
585
|
+
|
|
586
|
+
// Accumulate counters onto target.
|
|
587
|
+
target.messageCount += source.messageCount;
|
|
588
|
+
if (source.lastMessageAt && (!target.lastMessageAt || source.lastMessageAt > target.lastMessageAt)) {
|
|
589
|
+
target.lastMessageAt = source.lastMessageAt;
|
|
590
|
+
}
|
|
591
|
+
if (source.lastActivityAt && (!target.lastActivityAt || source.lastActivityAt > target.lastActivityAt)) {
|
|
592
|
+
target.lastActivityAt = source.lastActivityAt;
|
|
593
|
+
}
|
|
594
|
+
target.updatedAt = now;
|
|
595
|
+
// Revive target if it was archived — a merge is an activity signal.
|
|
596
|
+
if (target.status === 'archived') {
|
|
597
|
+
target.status = 'active';
|
|
598
|
+
target.archived = false;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Mark source as archived + pointer to target.
|
|
602
|
+
source.status = 'archived';
|
|
603
|
+
source.archived = true;
|
|
604
|
+
source.mergedInto = targetId;
|
|
605
|
+
source.updatedAt = now;
|
|
606
|
+
|
|
607
|
+
// If source was current, move the pointer to target.
|
|
608
|
+
if (this.#currentId === sourceId) {
|
|
609
|
+
this.#currentId = targetId;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Drop any task attachment on source (it now belongs to target).
|
|
613
|
+
if (this.#attachments.has(sourceId)) {
|
|
614
|
+
const taskId = this.#attachments.get(sourceId);
|
|
615
|
+
this.#attachments.delete(sourceId);
|
|
616
|
+
// Preserve attachment on target if it had none; otherwise keep target's.
|
|
617
|
+
if (!this.#attachments.has(targetId)) {
|
|
618
|
+
this.#attachments.set(targetId, taskId);
|
|
619
|
+
}
|
|
620
|
+
this.#markAttachmentsDirty();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
this.#markDirty(sourceId);
|
|
624
|
+
this.#markDirty(targetId);
|
|
625
|
+
return { source, target };
|
|
626
|
+
}
|
|
627
|
+
|
|
546
628
|
setStatus(id, status) {
|
|
547
629
|
if (!THREAD_STATUSES.includes(status)) {
|
|
548
630
|
throw new Error(`invalid status: ${status}`);
|
package/unify/web-bridge.js
CHANGED
|
@@ -568,6 +568,64 @@ export function handleUnifyModeSwitch(_msg) {
|
|
|
568
568
|
console.warn('[Unify] unify_mode_switch is deprecated and ignored — Unify now runs in a single unified mode.');
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
+
/**
|
|
572
|
+
* task-313: merge a source thread into a target thread.
|
|
573
|
+
* Reassigns messages, archives source with `mergedInto`, terminates source
|
|
574
|
+
* engine instance, broadcasts `thread_merged` + `thread_list_updated`.
|
|
575
|
+
*
|
|
576
|
+
* @param {{ sourceId: string, targetId: string }} msg
|
|
577
|
+
*/
|
|
578
|
+
export function handleUnifyMergeThread(msg) {
|
|
579
|
+
if (!session) {
|
|
580
|
+
console.warn('[Unify] unify_merge_thread received before session init — ignored');
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const { sourceId, targetId } = msg || {};
|
|
584
|
+
if (!sourceId || !targetId) {
|
|
585
|
+
sendUnifyEvent({ type: 'thread_merge_failed', sourceId, targetId, error: 'sourceId and targetId required' });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
let reassigned = 0;
|
|
590
|
+
try {
|
|
591
|
+
// 1. Reassign messages (ConversationStore) — preserves sourceThreadId pill.
|
|
592
|
+
if (session.conversationStore && typeof session.conversationStore.reassignThread === 'function') {
|
|
593
|
+
reassigned = session.conversationStore.reassignThread(sourceId, targetId);
|
|
594
|
+
}
|
|
595
|
+
// 2. Mutate ThreadStore (mergedInto + archived + counter rollup).
|
|
596
|
+
const store = session.threadStore || getThreadStore();
|
|
597
|
+
store.mergeThread(sourceId, targetId);
|
|
598
|
+
// 3. Terminate + forget the source engine instance — releases its slot.
|
|
599
|
+
if (session.engineRegistry) {
|
|
600
|
+
session.engineRegistry.delete(sourceId);
|
|
601
|
+
// If the registry was tracking source as current, move to target.
|
|
602
|
+
if (typeof session.engineRegistry.setCurrent === 'function'
|
|
603
|
+
&& session.engineRegistry.currentThreadId === sourceId) {
|
|
604
|
+
session.engineRegistry.setCurrent(targetId);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// 4. Flush ThreadStore so the merge is durable before the UI refreshes.
|
|
608
|
+
if (typeof store.flush === 'function') store.flush();
|
|
609
|
+
} catch (err) {
|
|
610
|
+
sendUnifyEvent({
|
|
611
|
+
type: 'thread_merge_failed',
|
|
612
|
+
sourceId,
|
|
613
|
+
targetId,
|
|
614
|
+
error: err?.message || String(err),
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// 5. Broadcast the merge + refreshed thread list.
|
|
620
|
+
sendUnifyEvent({
|
|
621
|
+
type: 'thread_merged',
|
|
622
|
+
sourceId,
|
|
623
|
+
targetId,
|
|
624
|
+
reassignedMessages: reassigned,
|
|
625
|
+
});
|
|
626
|
+
sendThreadListUpdate();
|
|
627
|
+
}
|
|
628
|
+
|
|
571
629
|
/**
|
|
572
630
|
* Handle model switch from the web UI.
|
|
573
631
|
* Updates Engine's config so the next query uses the new model.
|