@rubixkube/rubix 0.0.5 → 0.0.7
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/CHANGELOG.md +15 -0
- package/dist/core/device-auth.js +33 -6
- package/dist/core/rubix-api.js +145 -58
- package/dist/core/segments.js +93 -0
- package/dist/ui/App.js +231 -57
- package/dist/ui/components/AnimatedGlyph.js +47 -0
- package/dist/ui/components/ChatTranscript.js +221 -50
- package/dist/ui/components/Composer.js +20 -5
- package/dist/ui/components/DashboardPanel.js +1 -1
- package/dist/ui/sprite-frames.js +28 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org).
|
|
7
7
|
|
|
8
|
+
## [0.0.7] - 2025-03-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Bugfixes and performance improvements
|
|
13
|
+
- Introducing Sarvam 105B support
|
|
14
|
+
|
|
15
|
+
## [0.0.6] - 2025-03-08
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Workflow event timeline
|
|
20
|
+
- Added Personality
|
|
21
|
+
- Bugfixes and performance improvements
|
|
22
|
+
|
|
8
23
|
## [0.0.5] - 2025-03-06
|
|
9
24
|
|
|
10
25
|
### Added
|
package/dist/core/device-auth.js
CHANGED
|
@@ -130,24 +130,51 @@ async function getUserProfile(idToken) {
|
|
|
130
130
|
}
|
|
131
131
|
return (await response.json());
|
|
132
132
|
}
|
|
133
|
-
|
|
133
|
+
/** Detect headless environment (SSH, CI, no display server) */
|
|
134
|
+
export function isHeadlessEnvironment() {
|
|
135
|
+
if (process.env.CI === "true" || process.env.CI === "1")
|
|
136
|
+
return true;
|
|
137
|
+
if (process.platform === "linux") {
|
|
138
|
+
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Open URL in browser. No-op in headless environments.
|
|
145
|
+
* Handles spawn errors (e.g. ENOENT for missing xdg-open) without crashing.
|
|
146
|
+
*/
|
|
147
|
+
export function openUrlSafely(url) {
|
|
134
148
|
const urlStr = String(url).trim();
|
|
135
149
|
if (!urlStr.startsWith("http://") && !urlStr.startsWith("https://"))
|
|
136
150
|
return;
|
|
151
|
+
if (isHeadlessEnvironment())
|
|
152
|
+
return;
|
|
153
|
+
let proc;
|
|
137
154
|
if (process.platform === "darwin") {
|
|
138
|
-
spawn("open", [urlStr], { stdio: "ignore" });
|
|
155
|
+
proc = spawn("open", [urlStr], { stdio: "ignore" });
|
|
139
156
|
}
|
|
140
157
|
else if (process.platform === "win32") {
|
|
141
|
-
spawn("cmd", ["/c", "start", "", urlStr], { stdio: "ignore" });
|
|
158
|
+
proc = spawn("cmd", ["/c", "start", "", urlStr], { stdio: "ignore" });
|
|
142
159
|
}
|
|
143
160
|
else {
|
|
144
|
-
spawn("xdg-open", [urlStr], { stdio: "ignore" });
|
|
161
|
+
proc = spawn("xdg-open", [urlStr], { stdio: "ignore" });
|
|
145
162
|
}
|
|
163
|
+
proc.on("error", () => {
|
|
164
|
+
/* Swallow ENOENT and other spawn errors to avoid process crash */
|
|
165
|
+
});
|
|
146
166
|
}
|
|
147
167
|
export async function authenticateWithDeviceFlow(log) {
|
|
148
168
|
const start = await startDeviceAuth();
|
|
149
|
-
|
|
150
|
-
|
|
169
|
+
const headless = isHeadlessEnvironment();
|
|
170
|
+
openUrlSafely(start.verificationUrl);
|
|
171
|
+
if (headless) {
|
|
172
|
+
log?.("No display detected (SSH/headless). Open this URL in a browser on another device:");
|
|
173
|
+
log?.(start.verificationUrl);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
log?.(`Opening ${start.verificationUrl} in browser`);
|
|
177
|
+
}
|
|
151
178
|
log?.(`Enter code: ${start.userCode}`);
|
|
152
179
|
const tokens = await pollForTokens(start, log);
|
|
153
180
|
const validation = await validateAuth0Token(tokens.id_token);
|
package/dist/core/rubix-api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getConfig } from "../config/env.js";
|
|
2
2
|
import { refreshAccessToken } from "./device-auth.js";
|
|
3
3
|
import { saveAuthConfig } from "./auth-store.js";
|
|
4
|
+
import { pushTextSegment, pushWorkflowSegment } from "./segments.js";
|
|
4
5
|
const DEFAULT_APP_NAME = "Rubix";
|
|
5
6
|
export class StreamError extends Error {
|
|
6
7
|
reason;
|
|
@@ -130,21 +131,29 @@ function mergeChunks(existing, add) {
|
|
|
130
131
|
if (add === existing)
|
|
131
132
|
return existing;
|
|
132
133
|
if (add.startsWith(existing))
|
|
133
|
-
return add;
|
|
134
|
+
return add; // cumulative resend
|
|
134
135
|
if (existing.startsWith(add))
|
|
135
|
-
return existing;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
136
|
+
return existing; // duplicate smaller resend
|
|
137
|
+
// Never use includes() — e.g. "I'" matches inside "I'm", "|" matches in markdown; would silently eat content.
|
|
138
|
+
// Find maximal overlap where existing ends with the prefix of add.
|
|
139
|
+
// Use trimEnd when checking so "Investigate " + "igate..." merges correctly
|
|
140
|
+
// (trailing space would otherwise block suffix overlap).
|
|
141
|
+
const existingTrimmed = existing.replace(/\s+$/, "");
|
|
142
|
+
const maxK = Math.min(existingTrimmed.length, add.length);
|
|
141
143
|
for (let k = maxK; k > 0; k -= 1) {
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
+
if (existingTrimmed.endsWith(add.slice(0, k))) {
|
|
145
|
+
const newPart = add.slice(k);
|
|
146
|
+
if (!newPart)
|
|
147
|
+
return existing;
|
|
148
|
+
return existingTrimmed + newPart;
|
|
144
149
|
}
|
|
145
150
|
}
|
|
146
151
|
return existing + add;
|
|
147
152
|
}
|
|
153
|
+
/** Exported for thought accumulation in UI (overlap-aware merge). */
|
|
154
|
+
export function mergeTextChunks(existing, add) {
|
|
155
|
+
return mergeChunks(existing, add);
|
|
156
|
+
}
|
|
148
157
|
function asText(value) {
|
|
149
158
|
if (typeof value === "string")
|
|
150
159
|
return value;
|
|
@@ -471,9 +480,16 @@ function parseParts(content) {
|
|
|
471
480
|
}
|
|
472
481
|
return content.parts ?? [];
|
|
473
482
|
}
|
|
474
|
-
|
|
483
|
+
function parseContentRole(content) {
|
|
484
|
+
if (!content || typeof content === "string")
|
|
485
|
+
return "";
|
|
486
|
+
return content.role ?? "";
|
|
487
|
+
}
|
|
488
|
+
export async function fetchChatHistory(auth, sessionId, pageSize = 20, appName = DEFAULT_APP_NAME, offset = 0) {
|
|
475
489
|
const { userId } = ensureAuth(auth);
|
|
476
|
-
|
|
490
|
+
// Fetch newest-first so we always get the most recent N messages.
|
|
491
|
+
// offset > 0 means "load older": skip the N most-recent events we already have.
|
|
492
|
+
const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}/chat-history?user_id=${encodeURIComponent(userId)}&limit=${pageSize}&offset=${offset}&format=detailed&order_desc=true&app_name=${encodeURIComponent(appName)}`;
|
|
477
493
|
const response = await fetchWithAutoRefresh(auth, url, {
|
|
478
494
|
method: "GET",
|
|
479
495
|
headers: headers(auth),
|
|
@@ -483,65 +499,121 @@ export async function fetchChatHistory(auth, sessionId, limit = 50) {
|
|
|
483
499
|
throw new Error(`Failed to load chat history (${response.status}): ${text}`);
|
|
484
500
|
}
|
|
485
501
|
const payload = (await response.json());
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
502
|
+
const raw = payload.chat_history ?? [];
|
|
503
|
+
// If we got a full page there are likely more older events beyond this batch.
|
|
504
|
+
const hasMore = raw.length === pageSize;
|
|
505
|
+
// Events come newest-first from the API — reverse to get chronological order.
|
|
506
|
+
const events = raw.reverse();
|
|
507
|
+
const turns = new Map();
|
|
508
|
+
const turnOrder = [];
|
|
509
|
+
for (const [eIdx, event] of events.entries()) {
|
|
510
|
+
const invId = event.invocation_id ?? `anon-${eIdx}`;
|
|
511
|
+
const ts = event.timestamp ? new Date(event.timestamp).getTime() : Date.now();
|
|
512
|
+
if (!turns.has(invId)) {
|
|
513
|
+
turns.set(invId, { userText: "", assistantText: "", workflow: [], segments: [], ts, assistantTs: ts });
|
|
514
|
+
turnOrder.push(invId);
|
|
515
|
+
}
|
|
516
|
+
const turn = turns.get(invId);
|
|
517
|
+
const parts = parseParts(event.content);
|
|
518
|
+
let thoughtBuffer = ""; // Accumulate consecutive thought parts (raw concat) for token-based storage
|
|
493
519
|
for (const part of parts) {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
content: part.text.trim(),
|
|
500
|
-
ts,
|
|
501
|
-
});
|
|
502
|
-
}
|
|
520
|
+
const partText = typeof part.text === "string" ? part.text : "";
|
|
521
|
+
const isThoughtPart = part.thought === true || part.type === "thought";
|
|
522
|
+
// Thought: accumulate consecutive parts into one entry (LiteLLM stores 100 tokens = 100 parts)
|
|
523
|
+
if (isThoughtPart) {
|
|
524
|
+
thoughtBuffer += partText;
|
|
503
525
|
continue;
|
|
504
526
|
}
|
|
527
|
+
// Flush accumulated thoughts before processing non-thought part
|
|
528
|
+
if (thoughtBuffer.length > 0) {
|
|
529
|
+
const wev = {
|
|
530
|
+
id: `th-${invId}-${eIdx}-${turn.workflow.length}`,
|
|
531
|
+
type: "thought",
|
|
532
|
+
content: thoughtBuffer,
|
|
533
|
+
ts,
|
|
534
|
+
};
|
|
535
|
+
turn.workflow.push(wev);
|
|
536
|
+
pushWorkflowSegment(turn.segments, wev);
|
|
537
|
+
thoughtBuffer = "";
|
|
538
|
+
}
|
|
539
|
+
// Tool call
|
|
505
540
|
const fc = part.functionCall ?? part.function_call;
|
|
506
541
|
if (fc) {
|
|
507
542
|
const name = typeof fc.name === "string" ? fc.name : "tool";
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
id: `hist-fc-${idx}-${name}`,
|
|
543
|
+
const wev = {
|
|
544
|
+
id: `fc-${invId}-${eIdx}-${name}`,
|
|
511
545
|
type: "function_call",
|
|
512
|
-
content:
|
|
546
|
+
content: fc.args && Object.keys(fc.args).length > 0 ? JSON.stringify(fc.args) : "",
|
|
513
547
|
ts,
|
|
514
548
|
details: { name, id: fc.id },
|
|
515
|
-
}
|
|
549
|
+
};
|
|
550
|
+
turn.workflow.push(wev);
|
|
551
|
+
pushWorkflowSegment(turn.segments, wev);
|
|
516
552
|
continue;
|
|
517
553
|
}
|
|
554
|
+
// Tool response (ADK sends these as author="user" — they belong to the assistant bubble)
|
|
518
555
|
const fr = part.functionResponse ?? part.function_response;
|
|
519
556
|
if (fr) {
|
|
520
557
|
const name = typeof fr.name === "string" ? fr.name : "tool";
|
|
521
|
-
|
|
522
|
-
id: `
|
|
558
|
+
const wev = {
|
|
559
|
+
id: `fr-${invId}-${eIdx}-${name}`,
|
|
523
560
|
type: "function_response",
|
|
524
|
-
content:
|
|
561
|
+
content: extractFunctionResult(fr.response) || (fr.response ? JSON.stringify(fr.response) : `[${name}]`),
|
|
525
562
|
ts,
|
|
526
563
|
details: { name, id: fr.id },
|
|
527
|
-
}
|
|
564
|
+
};
|
|
565
|
+
turn.workflow.push(wev);
|
|
566
|
+
pushWorkflowSegment(turn.segments, wev);
|
|
528
567
|
continue;
|
|
529
568
|
}
|
|
530
|
-
|
|
531
|
-
|
|
569
|
+
// Plain text — user bubble if author === "user", otherwise assistant bubble.
|
|
570
|
+
// Skip whitespace-only parts (separators in stored data).
|
|
571
|
+
if (typeof part.text === "string" && part.text.trim().length > 0) {
|
|
572
|
+
if (event.author === "user") {
|
|
573
|
+
turn.userText = turn.userText ? `${turn.userText}\n${part.text}` : part.text;
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
turn.assistantText = turn.assistantText ? `${turn.assistantText}\n${part.text}` : part.text;
|
|
577
|
+
turn.assistantTs = ts;
|
|
578
|
+
pushTextSegment(turn.segments, part.text);
|
|
579
|
+
}
|
|
532
580
|
}
|
|
533
581
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
582
|
+
// Flush any remaining thought buffer after last part
|
|
583
|
+
if (thoughtBuffer.length > 0) {
|
|
584
|
+
const wev = {
|
|
585
|
+
id: `th-${invId}-${eIdx}-${turn.workflow.length}`,
|
|
586
|
+
type: "thought",
|
|
587
|
+
content: thoughtBuffer,
|
|
588
|
+
ts,
|
|
589
|
+
};
|
|
590
|
+
turn.workflow.push(wev);
|
|
591
|
+
pushWorkflowSegment(turn.segments, wev);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const messages = [];
|
|
595
|
+
for (const invId of turnOrder) {
|
|
596
|
+
const turn = turns.get(invId);
|
|
597
|
+
if (turn.userText.trim()) {
|
|
598
|
+
messages.push({
|
|
599
|
+
id: `${invId}:user`,
|
|
600
|
+
role: "user",
|
|
601
|
+
content: turn.userText,
|
|
602
|
+
ts: turn.ts,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
if (turn.assistantText.trim() || turn.workflow.length > 0) {
|
|
606
|
+
messages.push({
|
|
607
|
+
id: `${invId}:assistant`,
|
|
608
|
+
role: "assistant",
|
|
609
|
+
content: turn.assistantText,
|
|
610
|
+
ts: turn.assistantTs,
|
|
611
|
+
workflow: turn.workflow.length > 0 ? turn.workflow : undefined,
|
|
612
|
+
segments: turn.segments.length > 0 ? turn.segments : undefined,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
543
615
|
}
|
|
544
|
-
return messages;
|
|
616
|
+
return { messages, hasMore };
|
|
545
617
|
}
|
|
546
618
|
export async function streamChat(input, callbacks = {}) {
|
|
547
619
|
const { userId } = ensureAuth(input.auth);
|
|
@@ -623,11 +695,17 @@ export async function streamChat(input, callbacks = {}) {
|
|
|
623
695
|
}
|
|
624
696
|
const parts = event.content?.parts ?? [];
|
|
625
697
|
const isThoughtType = normalizedEventType === "thought" || normalizedEventType === "thoughts";
|
|
698
|
+
// Accumulate all visible text from this event (console pattern: batch per event).
|
|
699
|
+
// Use mergeChunks per part to avoid duplicates when backend sends overlapping parts.
|
|
700
|
+
let visibleChunk = "";
|
|
626
701
|
for (const part of parts) {
|
|
627
702
|
const partText = typeof part.text === "string" ? part.text : "";
|
|
628
|
-
|
|
703
|
+
// Thought: check both part.thought and part.type for safety (LiteLLM vs Gemini).
|
|
704
|
+
// Pass raw tokens — do not trim; BPE tokens carry their own whitespace.
|
|
705
|
+
const isThoughtPart = part.thought === true || part.type === "thought" || isThoughtType;
|
|
706
|
+
if (isThoughtPart && partText.length > 0) {
|
|
629
707
|
hasWorkflowEvents = true;
|
|
630
|
-
callbacks.onWorkflow?.(normalizeWorkflowEvent("thought", partText
|
|
708
|
+
callbacks.onWorkflow?.(normalizeWorkflowEvent("thought", partText, {
|
|
631
709
|
partial: event.partial === true,
|
|
632
710
|
}));
|
|
633
711
|
continue;
|
|
@@ -657,13 +735,20 @@ export async function streamChat(input, callbacks = {}) {
|
|
|
657
735
|
}));
|
|
658
736
|
continue;
|
|
659
737
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
738
|
+
// Visible text (non-thought): accumulate with overlap detection.
|
|
739
|
+
// Skip whitespace-only parts (e.g. {text: "\n"}) used as separators in stored data.
|
|
740
|
+
const isVisibleText = part.thought !== true && part.type !== "thought";
|
|
741
|
+
if (isVisibleText && partText.trim().length > 0) {
|
|
742
|
+
visibleChunk = mergeChunks(visibleChunk, partText);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
// Merge accumulated visible text once per event (handles partial/cumulative chunks)
|
|
746
|
+
if (visibleChunk) {
|
|
747
|
+
const merged = mergeChunks(accumulatedText, visibleChunk);
|
|
748
|
+
if (merged !== accumulatedText) {
|
|
749
|
+
accumulatedText = merged;
|
|
750
|
+
hasContent = accumulatedText.trim().length > 0;
|
|
751
|
+
callbacks.onText?.(accumulatedText);
|
|
667
752
|
}
|
|
668
753
|
}
|
|
669
754
|
if (parts.length === 0) {
|
|
@@ -724,7 +809,9 @@ export async function streamChat(input, callbacks = {}) {
|
|
|
724
809
|
}));
|
|
725
810
|
}
|
|
726
811
|
}
|
|
727
|
-
|
|
812
|
+
// Merge top-level event.text even when content.parts has workflow events (e.g. function_call).
|
|
813
|
+
// Some events mix workflow parts with top-level text; we were dropping text when parts.length > 0.
|
|
814
|
+
if (typeof event.text === "string" && event.text.trim()) {
|
|
728
815
|
const merged = mergeChunks(accumulatedText, event.text);
|
|
729
816
|
if (merged !== accumulatedText) {
|
|
730
817
|
accumulatedText = merged;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure utility functions for building and updating MessageSegment arrays.
|
|
3
|
+
*
|
|
4
|
+
* All mutable helpers operate on arrays that the caller owns (history parsing).
|
|
5
|
+
* All immutable helpers return new arrays (streaming state updates).
|
|
6
|
+
*/
|
|
7
|
+
// ─── Mutable helpers (history parsing) ──────────────────────────────────────
|
|
8
|
+
/** Append a workflow event, merging into the last workflow segment when possible. */
|
|
9
|
+
export function pushWorkflowSegment(segments, event) {
|
|
10
|
+
const last = segments[segments.length - 1];
|
|
11
|
+
if (last?.kind === "workflow") {
|
|
12
|
+
last.events.push(event);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
segments.push({ kind: "workflow", events: [event] });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Append text, merging into the last text segment when possible. */
|
|
19
|
+
export function pushTextSegment(segments, text) {
|
|
20
|
+
const trimmed = text.trim();
|
|
21
|
+
if (!trimmed)
|
|
22
|
+
return;
|
|
23
|
+
const last = segments[segments.length - 1];
|
|
24
|
+
if (last?.kind === "text") {
|
|
25
|
+
last.content = `${last.content}\n${trimmed}`;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
segments.push({ kind: "text", content: trimmed });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// ─── Immutable helpers (streaming state updates) ─────────────────────────────
|
|
32
|
+
/**
|
|
33
|
+
* Return a new segment list with the workflow event appended.
|
|
34
|
+
* Merges consecutive workflow events into one segment.
|
|
35
|
+
*
|
|
36
|
+
* When a thought arrives after the text (late from stream), prepend it before the
|
|
37
|
+
* text so it appears above the response — better UX when ADK sends thought last.
|
|
38
|
+
*/
|
|
39
|
+
export function addWorkflowEvent(segments, event) {
|
|
40
|
+
// Late-arriving thought: segments are [text] only → prepend so thought appears above
|
|
41
|
+
const first = segments[0];
|
|
42
|
+
if (event.type === "thought" &&
|
|
43
|
+
segments.length === 1 &&
|
|
44
|
+
first?.kind === "text" &&
|
|
45
|
+
first.content.trim().length > 0) {
|
|
46
|
+
return [{ kind: "workflow", events: [event] }, first];
|
|
47
|
+
}
|
|
48
|
+
const last = segments[segments.length - 1];
|
|
49
|
+
if (last?.kind === "workflow") {
|
|
50
|
+
return [
|
|
51
|
+
...segments.slice(0, -1),
|
|
52
|
+
{ kind: "workflow", events: [...last.events, event] },
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
return [...segments, { kind: "workflow", events: [event] }];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Return a new segment list where the last workflow segment's final thought is replaced.
|
|
59
|
+
* Used when streaming sends an updated/extended thought.
|
|
60
|
+
*/
|
|
61
|
+
export function replaceLastThought(segments, updated) {
|
|
62
|
+
const last = segments[segments.length - 1];
|
|
63
|
+
if (last?.kind !== "workflow")
|
|
64
|
+
return segments;
|
|
65
|
+
const events = last.events.map((e, i) => i === last.events.length - 1 && e.type === "thought" ? updated : e);
|
|
66
|
+
return [...segments.slice(0, -1), { kind: "workflow", events }];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Return a new segment list with the streaming text slice updated.
|
|
70
|
+
* `fullText` is the full accumulated assistant text; `startOffset` marks where the
|
|
71
|
+
* current text segment begins within that string (set each time a workflow event arrives).
|
|
72
|
+
*/
|
|
73
|
+
export function updateStreamingText(segments, fullText, startOffset) {
|
|
74
|
+
const slice = fullText.slice(startOffset).trim();
|
|
75
|
+
if (!slice)
|
|
76
|
+
return segments;
|
|
77
|
+
const last = segments[segments.length - 1];
|
|
78
|
+
if (last?.kind === "text") {
|
|
79
|
+
return [...segments.slice(0, -1), { kind: "text", content: slice }];
|
|
80
|
+
}
|
|
81
|
+
return [...segments, { kind: "text", content: slice }];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Merge older segments (from a Ctrl+U load) in front of existing segments.
|
|
85
|
+
* Used when an invocation spans two page-load batches.
|
|
86
|
+
*/
|
|
87
|
+
export function mergeOlderSegments(existing, older) {
|
|
88
|
+
if (!older?.length)
|
|
89
|
+
return existing;
|
|
90
|
+
if (!existing?.length)
|
|
91
|
+
return older;
|
|
92
|
+
return [...older, ...existing];
|
|
93
|
+
}
|