@rubixkube/rubix 0.0.4 → 0.0.6
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 +14 -0
- package/dist/core/rubix-api.js +105 -46
- package/dist/core/segments.js +93 -0
- package/dist/ui/App.js +221 -43
- 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,20 @@ 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.6] - 2025-03-08
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Workflow event timeline
|
|
13
|
+
- Added Personality
|
|
14
|
+
- Bugfixes and performance improvements
|
|
15
|
+
|
|
16
|
+
## [0.0.5] - 2025-03-06
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Bugfixes and performance improvements
|
|
21
|
+
|
|
8
22
|
## [0.0.4] - 2025-03-03
|
|
9
23
|
|
|
10
24
|
### Added
|
package/dist/core/rubix-api.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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
|
-
|
|
4
|
+
import { pushTextSegment, pushWorkflowSegment } from "./segments.js";
|
|
5
|
+
const DEFAULT_APP_NAME = "Rubix";
|
|
5
6
|
export class StreamError extends Error {
|
|
6
7
|
reason;
|
|
7
8
|
status;
|
|
@@ -87,7 +88,8 @@ export async function refreshAndUpdateAuth(auth) {
|
|
|
87
88
|
}
|
|
88
89
|
catch (error) {
|
|
89
90
|
const msg = error instanceof Error ? error.message : String(error);
|
|
90
|
-
|
|
91
|
+
const fullMsg = msg.includes("Token refresh failed") ? msg : `Token refresh failed: ${msg}`;
|
|
92
|
+
throw new StreamError(`${fullMsg}. Please run /login again.`, {
|
|
91
93
|
reason: "http_error",
|
|
92
94
|
status: 401,
|
|
93
95
|
});
|
|
@@ -129,17 +131,20 @@ function mergeChunks(existing, add) {
|
|
|
129
131
|
if (add === existing)
|
|
130
132
|
return existing;
|
|
131
133
|
if (add.startsWith(existing))
|
|
132
|
-
return add;
|
|
134
|
+
return add; // cumulative resend
|
|
133
135
|
if (existing.startsWith(add))
|
|
134
|
-
return existing;
|
|
136
|
+
return existing; // duplicate smaller resend
|
|
135
137
|
if (add.includes(existing))
|
|
136
|
-
return add;
|
|
138
|
+
return add; // cumulative with prefix text
|
|
137
139
|
if (existing.includes(add))
|
|
138
|
-
return existing;
|
|
140
|
+
return existing; // already included
|
|
141
|
+
// Find maximal overlap where existing ends with the prefix of add
|
|
142
|
+
// Matches console/opel handling: no guard that could drop partial chunks
|
|
139
143
|
const maxK = Math.min(existing.length, add.length);
|
|
140
144
|
for (let k = maxK; k > 0; k -= 1) {
|
|
141
145
|
if (existing.endsWith(add.slice(0, k))) {
|
|
142
|
-
|
|
146
|
+
const newPart = add.slice(k);
|
|
147
|
+
return newPart ? existing + newPart : existing;
|
|
143
148
|
}
|
|
144
149
|
}
|
|
145
150
|
return existing + add;
|
|
@@ -470,9 +475,16 @@ function parseParts(content) {
|
|
|
470
475
|
}
|
|
471
476
|
return content.parts ?? [];
|
|
472
477
|
}
|
|
473
|
-
|
|
478
|
+
function parseContentRole(content) {
|
|
479
|
+
if (!content || typeof content === "string")
|
|
480
|
+
return "";
|
|
481
|
+
return content.role ?? "";
|
|
482
|
+
}
|
|
483
|
+
export async function fetchChatHistory(auth, sessionId, pageSize = 20, appName = DEFAULT_APP_NAME, offset = 0) {
|
|
474
484
|
const { userId } = ensureAuth(auth);
|
|
475
|
-
|
|
485
|
+
// Fetch newest-first so we always get the most recent N messages.
|
|
486
|
+
// offset > 0 means "load older": skip the N most-recent events we already have.
|
|
487
|
+
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)}`;
|
|
476
488
|
const response = await fetchWithAutoRefresh(auth, url, {
|
|
477
489
|
method: "GET",
|
|
478
490
|
headers: headers(auth),
|
|
@@ -482,65 +494,103 @@ export async function fetchChatHistory(auth, sessionId, limit = 50) {
|
|
|
482
494
|
throw new Error(`Failed to load chat history (${response.status}): ${text}`);
|
|
483
495
|
}
|
|
484
496
|
const payload = (await response.json());
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
497
|
+
const raw = payload.chat_history ?? [];
|
|
498
|
+
// If we got a full page there are likely more older events beyond this batch.
|
|
499
|
+
const hasMore = raw.length === pageSize;
|
|
500
|
+
// Events come newest-first from the API — reverse to get chronological order.
|
|
501
|
+
const events = raw.reverse();
|
|
502
|
+
const turns = new Map();
|
|
503
|
+
const turnOrder = [];
|
|
504
|
+
for (const [eIdx, event] of events.entries()) {
|
|
505
|
+
const invId = event.invocation_id ?? `anon-${eIdx}`;
|
|
506
|
+
const ts = event.timestamp ? new Date(event.timestamp).getTime() : Date.now();
|
|
507
|
+
if (!turns.has(invId)) {
|
|
508
|
+
turns.set(invId, { userText: "", assistantText: "", workflow: [], segments: [], ts, assistantTs: ts });
|
|
509
|
+
turnOrder.push(invId);
|
|
510
|
+
}
|
|
511
|
+
const turn = turns.get(invId);
|
|
512
|
+
const parts = parseParts(event.content);
|
|
492
513
|
for (const part of parts) {
|
|
514
|
+
// Thought
|
|
493
515
|
if (part.thought === true) {
|
|
494
516
|
if (typeof part.text === "string" && part.text.trim()) {
|
|
495
|
-
|
|
496
|
-
id: `
|
|
517
|
+
const wev = {
|
|
518
|
+
id: `th-${invId}-${eIdx}-${turn.workflow.length}`,
|
|
497
519
|
type: "thought",
|
|
498
520
|
content: part.text.trim(),
|
|
499
521
|
ts,
|
|
500
|
-
}
|
|
522
|
+
};
|
|
523
|
+
turn.workflow.push(wev);
|
|
524
|
+
pushWorkflowSegment(turn.segments, wev);
|
|
501
525
|
}
|
|
502
526
|
continue;
|
|
503
527
|
}
|
|
528
|
+
// Tool call
|
|
504
529
|
const fc = part.functionCall ?? part.function_call;
|
|
505
530
|
if (fc) {
|
|
506
531
|
const name = typeof fc.name === "string" ? fc.name : "tool";
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
id: `hist-fc-${idx}-${name}`,
|
|
532
|
+
const wev = {
|
|
533
|
+
id: `fc-${invId}-${eIdx}-${name}`,
|
|
510
534
|
type: "function_call",
|
|
511
|
-
content:
|
|
535
|
+
content: fc.args && Object.keys(fc.args).length > 0 ? JSON.stringify(fc.args) : "",
|
|
512
536
|
ts,
|
|
513
537
|
details: { name, id: fc.id },
|
|
514
|
-
}
|
|
538
|
+
};
|
|
539
|
+
turn.workflow.push(wev);
|
|
540
|
+
pushWorkflowSegment(turn.segments, wev);
|
|
515
541
|
continue;
|
|
516
542
|
}
|
|
543
|
+
// Tool response (ADK sends these as author="user" — they belong to the assistant bubble)
|
|
517
544
|
const fr = part.functionResponse ?? part.function_response;
|
|
518
545
|
if (fr) {
|
|
519
546
|
const name = typeof fr.name === "string" ? fr.name : "tool";
|
|
520
|
-
|
|
521
|
-
id: `
|
|
547
|
+
const wev = {
|
|
548
|
+
id: `fr-${invId}-${eIdx}-${name}`,
|
|
522
549
|
type: "function_response",
|
|
523
|
-
content:
|
|
550
|
+
content: extractFunctionResult(fr.response) || (fr.response ? JSON.stringify(fr.response) : `[${name}]`),
|
|
524
551
|
ts,
|
|
525
552
|
details: { name, id: fr.id },
|
|
526
|
-
}
|
|
553
|
+
};
|
|
554
|
+
turn.workflow.push(wev);
|
|
555
|
+
pushWorkflowSegment(turn.segments, wev);
|
|
527
556
|
continue;
|
|
528
557
|
}
|
|
558
|
+
// Plain text — user bubble if author === "user", otherwise assistant bubble
|
|
529
559
|
if (typeof part.text === "string" && part.text.trim()) {
|
|
530
|
-
|
|
560
|
+
if (event.author === "user") {
|
|
561
|
+
turn.userText = turn.userText ? `${turn.userText}\n${part.text}` : part.text;
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
turn.assistantText = turn.assistantText ? `${turn.assistantText}\n${part.text}` : part.text;
|
|
565
|
+
turn.assistantTs = ts;
|
|
566
|
+
pushTextSegment(turn.segments, part.text);
|
|
567
|
+
}
|
|
531
568
|
}
|
|
532
569
|
}
|
|
533
|
-
if (!text.trim() && workflow.length === 0)
|
|
534
|
-
continue;
|
|
535
|
-
messages.push({
|
|
536
|
-
id: msg.invocation_id ? `${msg.invocation_id}-${idx}` : `hist-${idx}`,
|
|
537
|
-
role,
|
|
538
|
-
content: text,
|
|
539
|
-
ts,
|
|
540
|
-
workflow: workflow.length > 0 ? workflow : undefined,
|
|
541
|
-
});
|
|
542
570
|
}
|
|
543
|
-
|
|
571
|
+
const messages = [];
|
|
572
|
+
for (const invId of turnOrder) {
|
|
573
|
+
const turn = turns.get(invId);
|
|
574
|
+
if (turn.userText.trim()) {
|
|
575
|
+
messages.push({
|
|
576
|
+
id: `${invId}:user`,
|
|
577
|
+
role: "user",
|
|
578
|
+
content: turn.userText,
|
|
579
|
+
ts: turn.ts,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
if (turn.assistantText.trim() || turn.workflow.length > 0) {
|
|
583
|
+
messages.push({
|
|
584
|
+
id: `${invId}:assistant`,
|
|
585
|
+
role: "assistant",
|
|
586
|
+
content: turn.assistantText,
|
|
587
|
+
ts: turn.assistantTs,
|
|
588
|
+
workflow: turn.workflow.length > 0 ? turn.workflow : undefined,
|
|
589
|
+
segments: turn.segments.length > 0 ? turn.segments : undefined,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return { messages, hasMore };
|
|
544
594
|
}
|
|
545
595
|
export async function streamChat(input, callbacks = {}) {
|
|
546
596
|
const { userId } = ensureAuth(input.auth);
|
|
@@ -622,6 +672,8 @@ export async function streamChat(input, callbacks = {}) {
|
|
|
622
672
|
}
|
|
623
673
|
const parts = event.content?.parts ?? [];
|
|
624
674
|
const isThoughtType = normalizedEventType === "thought" || normalizedEventType === "thoughts";
|
|
675
|
+
// Accumulate all visible text from this event (console pattern: batch per event)
|
|
676
|
+
let visibleChunk = "";
|
|
625
677
|
for (const part of parts) {
|
|
626
678
|
const partText = typeof part.text === "string" ? part.text : "";
|
|
627
679
|
if ((part.thought === true || isThoughtType) && partText.trim()) {
|
|
@@ -656,13 +708,18 @@ export async function streamChat(input, callbacks = {}) {
|
|
|
656
708
|
}));
|
|
657
709
|
continue;
|
|
658
710
|
}
|
|
711
|
+
// Visible text (non-thought): accumulate for batch merge
|
|
659
712
|
if (partText && part.thought !== true) {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
713
|
+
visibleChunk += partText;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// Merge accumulated visible text once per event (handles partial/cumulative chunks)
|
|
717
|
+
if (visibleChunk) {
|
|
718
|
+
const merged = mergeChunks(accumulatedText, visibleChunk);
|
|
719
|
+
if (merged !== accumulatedText) {
|
|
720
|
+
accumulatedText = merged;
|
|
721
|
+
hasContent = accumulatedText.trim().length > 0;
|
|
722
|
+
callbacks.onText?.(accumulatedText);
|
|
666
723
|
}
|
|
667
724
|
}
|
|
668
725
|
if (parts.length === 0) {
|
|
@@ -723,7 +780,9 @@ export async function streamChat(input, callbacks = {}) {
|
|
|
723
780
|
}));
|
|
724
781
|
}
|
|
725
782
|
}
|
|
726
|
-
|
|
783
|
+
// Merge top-level event.text even when content.parts has workflow events (e.g. function_call).
|
|
784
|
+
// Some events mix workflow parts with top-level text; we were dropping text when parts.length > 0.
|
|
785
|
+
if (typeof event.text === "string" && event.text.trim()) {
|
|
727
786
|
const merged = mergeChunks(accumulatedText, event.text);
|
|
728
787
|
if (merged !== accumulatedText) {
|
|
729
788
|
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
|
+
}
|