@meistrari/chat-nuxt 3.16.0 → 3.17.0-rc.0
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/module.json +1 -1
- package/dist/runtime/assets/css/markstream.css +1 -1
- package/dist/runtime/messages/components/chat-debug-workbench.vue +3 -3
- package/dist/runtime/messages/components/chat-message-list.vue +1 -0
- package/dist/runtime/messages/components/reasoning/chat-reasoning-collapsible-content.vue +7 -2
- package/dist/runtime/messages/components/reasoning/chat-reasoning-steps.vue +10 -1
- package/dist/runtime/messages/components/reasoning/chat-reasoning-thought.vue +80 -77
- package/dist/runtime/messages/components/widgets/chat-thinking-widget.vue +2 -1
- package/dist/runtime/messages/debug/use-chat-debug-diagnostics.d.ts +8 -0
- package/dist/runtime/messages/debug/use-chat-debug-diagnostics.js +79 -22
- package/dist/runtime/messages/debug/use-chat-debug-playback.js +17 -4
- package/dist/runtime/rendering/components/app-markdown-render.vue +22 -1
- package/dist/runtime/rendering/markdown/streaming-text-node.d.vue.ts +9 -0
- package/dist/runtime/rendering/markdown/streaming-text-node.vue +82 -0
- package/dist/runtime/rendering/markdown/streaming-text-node.vue.d.ts +9 -0
- package/dist/runtime/rendering/markdown/streaming-text-segments.d.ts +47 -0
- package/dist/runtime/rendering/markdown/streaming-text-segments.js +51 -0
- package/package.json +1 -1
package/dist/module.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
.markstream-vue{font-family:inherit;--at-apply:"body-16-regular leading-24px text-primary";--list-item-marker:#a3a3a3;--list-item-counter-marker:#262626}.markstream-vue code,.markstream-vue kbd,.markstream-vue pre,.markstream-vue samp{font-family:Geist Mono Variable,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:13px;font-variation-settings:normal;letter-spacing:0;line-height:1.5}.markstream-vue ol,.markstream-vue ul{margin:8px 0;padding-left:1.5em}.markstream-vue .hr-node{border-color:#e5e5e5;border-top-width:.5px;margin-bottom:24px;margin-top:24px}.markstream-vue .strong-node{--at-apply:"font-580"}.markstream-vue .list-node.list-node{margin-bottom:8px;margin-left:4px;margin-top:8px;padding-left:1em}.markstream-vue ul{list-style:disc;list-style-position:outside}.markstream-vue ol{list-style:decimal;list-style-position:outside}.markstream-vue li{margin:4px 0}.markstream-vue .paragraph-node{--at-apply:"body-16-regular leading-24px";margin:0}.markstream-vue>.node-slot[data-node-type=paragraph]:not(:first-child){margin-top:12px}.markstream-vue .my-8{margin-bottom:8px;margin-top:8px}.markstream-vue .heading-node.heading-1,.markstream-vue h1{--at-apply:"heading-h2-semibold text-primary";margin:24px 0 8px}.markstream-vue .heading-node.heading-2,.markstream-vue h2{--at-apply:"heading-h3-semibold text-primary";margin:24px 0 8px}.markstream-vue .heading-node.heading-3,.markstream-vue h3{--at-apply:"heading-h4-semibold text-primary";margin:20px 0 8px}.markstream-vue .heading-node.heading-4,.markstream-vue .heading-node.heading-5,.markstream-vue .heading-node.heading-6,.markstream-vue h4,.markstream-vue h5,.markstream-vue h6{--at-apply:"heading-h5-semibold text-primary";margin:16px 0 8px}.markstream-vue>.node-slot:first-child .heading-node{margin-top:0}.markstream-vue [class*=tooltip],[class*=tooltip-element]{background-color:var(--neutral-900,#1a1a1a)!important;border-radius:4px;color:#fff!important;font-size:12px;padding:4px 8px}.mention-tag{align-items:center;border-radius:9999px;display:inline-flex;font-size:.875em;font-weight:500;gap:4px;padding:2px 8px;vertical-align:middle;white-space:nowrap}.mention-tag-file{background-color:rgba(127,189,247,.12);color:#7fbdf7}.mention-tag-skill{background-color:rgba(155,111,222,.12);color:#9b6fde}.mention-tag-canvas{background-color:rgba(234,161,65,.12);color:#d4882e}.mention-tag>span[class*=i-ph]{flex-shrink:0}
|
|
1
|
+
.markstream-vue{font-family:inherit;--at-apply:"body-16-regular leading-24px text-primary";--list-item-marker:#a3a3a3;--list-item-counter-marker:#262626}.markstream-vue code,.markstream-vue kbd,.markstream-vue pre,.markstream-vue samp{font-family:Geist Mono Variable,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:13px;font-variation-settings:normal;letter-spacing:0;line-height:1.5}.markstream-vue ol,.markstream-vue ul{margin:8px 0;padding-left:1.5em}.markstream-vue .hr-node{border-color:#e5e5e5;border-top-width:.5px;margin-bottom:24px;margin-top:24px}.markstream-vue .strong-node{--at-apply:"font-580"}.markstream-vue .list-node.list-node{margin-bottom:8px;margin-left:4px;margin-top:8px;padding-left:1em}.markstream-vue ul{list-style:disc;list-style-position:outside}.markstream-vue ol{list-style:decimal;list-style-position:outside}.markstream-vue li{margin:4px 0}.markstream-vue .paragraph-node{--at-apply:"body-16-regular leading-24px";margin:0}.markstream-vue>.node-slot[data-node-type=paragraph]:not(:first-child){margin-top:12px}@keyframes chat-stream-chunk-in{0%{opacity:0}to{opacity:1}}.markstream-vue .chat-stream-chunk{animation:chat-stream-chunk-in var(--chat-stream-fade-duration,.32s) cubic-bezier(.2,0,0,1) both}@media (prefers-reduced-motion:reduce){.markstream-vue .chat-stream-chunk{animation:none}}.markstream-vue .typewriter-cursor,.markstream-vue .typewriter-simple-cursor .typewriter-simple-cursor-target:after{display:none}.markstream-vue .my-8{margin-bottom:8px;margin-top:8px}.markstream-vue .heading-node.heading-1,.markstream-vue h1{--at-apply:"heading-h2-semibold text-primary";margin:24px 0 8px}.markstream-vue .heading-node.heading-2,.markstream-vue h2{--at-apply:"heading-h3-semibold text-primary";margin:24px 0 8px}.markstream-vue .heading-node.heading-3,.markstream-vue h3{--at-apply:"heading-h4-semibold text-primary";margin:20px 0 8px}.markstream-vue .heading-node.heading-4,.markstream-vue .heading-node.heading-5,.markstream-vue .heading-node.heading-6,.markstream-vue h4,.markstream-vue h5,.markstream-vue h6{--at-apply:"heading-h5-semibold text-primary";margin:16px 0 8px}.markstream-vue>.node-slot:first-child .heading-node{margin-top:0}.markstream-vue [class*=tooltip],[class*=tooltip-element]{background-color:var(--neutral-900,#1a1a1a)!important;border-radius:4px;color:#fff!important;font-size:12px;padding:4px 8px}.mention-tag{align-items:center;border-radius:9999px;display:inline-flex;font-size:.875em;font-weight:500;gap:4px;padding:2px 8px;vertical-align:middle;white-space:nowrap}.mention-tag-file{background-color:rgba(127,189,247,.12);color:#7fbdf7}.mention-tag-skill{background-color:rgba(155,111,222,.12);color:#9b6fde}.mention-tag-canvas{background-color:rgba(234,161,65,.12);color:#d4882e}.mention-tag>span[class*=i-ph]{flex-shrink:0}
|
|
@@ -286,7 +286,7 @@ function eventToneClass(tone) {
|
|
|
286
286
|
<dl class="metric-grid">
|
|
287
287
|
<div>
|
|
288
288
|
<dt body-10-regular text-tertiary>
|
|
289
|
-
Event →
|
|
289
|
+
Event → frame
|
|
290
290
|
</dt>
|
|
291
291
|
<dd heading-h3-semibold tabular-nums>
|
|
292
292
|
{{ diagnostics.paintLatencyMs.toFixed(1) }}<small>ms</small>
|
|
@@ -294,7 +294,7 @@ function eventToneClass(tone) {
|
|
|
294
294
|
</div>
|
|
295
295
|
<div>
|
|
296
296
|
<dt body-10-regular text-tertiary>
|
|
297
|
-
|
|
297
|
+
Est. missed frames
|
|
298
298
|
</dt>
|
|
299
299
|
<dd heading-h3-semibold tabular-nums>
|
|
300
300
|
{{ diagnostics.droppedFrames }}
|
|
@@ -390,7 +390,7 @@ function eventToneClass(tone) {
|
|
|
390
390
|
</div>
|
|
391
391
|
<div>
|
|
392
392
|
<dt text-tertiary>
|
|
393
|
-
|
|
393
|
+
Unexpected move
|
|
394
394
|
</dt><dd text-primary tabular-nums>
|
|
395
395
|
{{ diagnostics.maxScrollJump.toFixed(0) }} px
|
|
396
396
|
</dd>
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
defineProps({
|
|
2
|
+
const props = defineProps({
|
|
3
3
|
open: { type: Boolean, required: true }
|
|
4
4
|
});
|
|
5
|
+
const hasOpened = ref(props.open);
|
|
6
|
+
watch(() => props.open, (open) => {
|
|
7
|
+
if (open)
|
|
8
|
+
hasOpened.value = true;
|
|
9
|
+
});
|
|
5
10
|
</script>
|
|
6
11
|
|
|
7
12
|
<template>
|
|
@@ -15,7 +20,7 @@ defineProps({
|
|
|
15
20
|
:inert="open ? void 0 : true"
|
|
16
21
|
:aria-hidden="!open"
|
|
17
22
|
>
|
|
18
|
-
<slot />
|
|
23
|
+
<slot v-if="hasOpened" />
|
|
19
24
|
</TelaCollapsibleContent>
|
|
20
25
|
</div>
|
|
21
26
|
</template>
|
|
@@ -13,7 +13,12 @@ const groupedReasoning = computed(() => {
|
|
|
13
13
|
</script>
|
|
14
14
|
|
|
15
15
|
<template>
|
|
16
|
-
<div
|
|
16
|
+
<div
|
|
17
|
+
v-if="groupedReasoning.length > 0"
|
|
18
|
+
class="chat-reasoning-steps"
|
|
19
|
+
:class="{ 'chat-reasoning-steps--streaming': !completed }"
|
|
20
|
+
flex="~ col" gap-2px w-full
|
|
21
|
+
>
|
|
17
22
|
<ChatReasoningMessage
|
|
18
23
|
v-for="(step, index) in groupedReasoning"
|
|
19
24
|
:key="getReasoningStepKey(step, index)"
|
|
@@ -23,3 +28,7 @@ const groupedReasoning = computed(() => {
|
|
|
23
28
|
/>
|
|
24
29
|
</div>
|
|
25
30
|
</template>
|
|
31
|
+
|
|
32
|
+
<style scoped>
|
|
33
|
+
@keyframes chat-reasoning-step-in{0%{opacity:0}to{opacity:1}}.chat-reasoning-steps--streaming>:deep(*){animation:chat-reasoning-step-in .24s cubic-bezier(.2,0,0,1) both}@media (prefers-reduced-motion:reduce){.chat-reasoning-steps--streaming>:deep(*){animation:none}}
|
|
34
|
+
</style>
|
|
@@ -94,93 +94,96 @@ onUnmounted(() => {
|
|
|
94
94
|
</script>
|
|
95
95
|
|
|
96
96
|
<template>
|
|
97
|
-
<div
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
<div class="chat-reasoning-stage" w-full>
|
|
98
|
+
<Transition name="chat-reasoning-presence" :appear="!completed">
|
|
99
|
+
<div v-if="!completed && !hasReasoning" key="thinking" class="chat-reasoning-placeholder">
|
|
100
|
+
<ChatThinkingIndicator />
|
|
101
|
+
</div>
|
|
100
102
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
items-center
|
|
118
|
-
gap-4px
|
|
119
|
-
min-h-32px
|
|
120
|
-
max-w-full
|
|
121
|
-
py-6px
|
|
122
|
-
text-left
|
|
123
|
-
transition-colors
|
|
124
|
-
cursor-pointer
|
|
125
|
-
:aria-busy="!completed"
|
|
126
|
-
:aria-label="isExpanded ? 'Ocultar detalhes do trabalho' : 'Mostrar detalhes do trabalho'"
|
|
127
|
-
>
|
|
128
|
-
<span
|
|
129
|
-
v-if="completed"
|
|
130
|
-
key="completed-text"
|
|
131
|
-
body-14-regular
|
|
132
|
-
text-secondary
|
|
133
|
-
tabular-nums
|
|
134
|
-
group-hover:text-primary
|
|
135
|
-
transition-colors
|
|
136
|
-
>
|
|
137
|
-
Trabalhou por {{ formattedDuration || "alguns momentos" }}
|
|
138
|
-
</span>
|
|
139
|
-
<div
|
|
140
|
-
v-else
|
|
141
|
-
key="loading-text"
|
|
142
|
-
flex
|
|
143
|
-
min-w-0
|
|
103
|
+
<!-- Com reasoning (loading ou completo): header clicável + steps colapsáveis -->
|
|
104
|
+
<TelaCollapsible
|
|
105
|
+
v-else-if="hasReasoning"
|
|
106
|
+
key="reasoning"
|
|
107
|
+
v-model:open="isExpanded"
|
|
108
|
+
class="chat-reasoning-disclosure"
|
|
109
|
+
data-chat-scroll-preserve
|
|
110
|
+
>
|
|
111
|
+
<TelaCollapsibleTrigger as-child>
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
data-chat-scroll-preserve
|
|
115
|
+
class="group"
|
|
116
|
+
data-chat-scroll-preserve-trigger
|
|
117
|
+
inline-flex
|
|
118
|
+
self-start
|
|
144
119
|
items-center
|
|
145
|
-
gap-
|
|
120
|
+
gap-4px
|
|
121
|
+
min-h-32px
|
|
122
|
+
max-w-full
|
|
123
|
+
py-6px
|
|
124
|
+
text-left
|
|
125
|
+
transition-colors
|
|
126
|
+
cursor-pointer
|
|
127
|
+
:aria-busy="!completed"
|
|
128
|
+
:aria-label="isExpanded ? 'Ocultar detalhes do trabalho' : 'Mostrar detalhes do trabalho'"
|
|
146
129
|
>
|
|
147
|
-
<span v-if="hasReasoning" body-14-regular text-secondary truncate>
|
|
148
|
-
{{ currentStepName }}
|
|
149
|
-
</span>
|
|
150
130
|
<span
|
|
151
|
-
v-if="
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
131
|
+
v-if="completed"
|
|
132
|
+
key="completed-text"
|
|
133
|
+
body-14-regular
|
|
134
|
+
text-secondary
|
|
135
|
+
tabular-nums
|
|
136
|
+
group-hover:text-primary
|
|
137
|
+
transition-colors
|
|
155
138
|
>
|
|
156
|
-
{{
|
|
139
|
+
Trabalhou por {{ formattedDuration || "alguns momentos" }}
|
|
157
140
|
</span>
|
|
158
|
-
|
|
141
|
+
<div
|
|
142
|
+
v-else
|
|
143
|
+
key="loading-text"
|
|
144
|
+
flex
|
|
145
|
+
min-w-0
|
|
146
|
+
items-center
|
|
147
|
+
gap-6px
|
|
148
|
+
>
|
|
149
|
+
<span v-if="hasReasoning" body-14-regular text-secondary truncate>
|
|
150
|
+
{{ currentStepName }}
|
|
151
|
+
</span>
|
|
152
|
+
<span
|
|
153
|
+
v-if="liveFormattedDuration"
|
|
154
|
+
body-12-regular
|
|
155
|
+
text-tertiary
|
|
156
|
+
shrink-0
|
|
157
|
+
>
|
|
158
|
+
{{ liveFormattedDuration }}
|
|
159
|
+
</span>
|
|
160
|
+
</div>
|
|
159
161
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
162
|
+
<TelaIcon
|
|
163
|
+
name="i-ph-caret-right"
|
|
164
|
+
size="14px"
|
|
165
|
+
color="icon-tertiary"
|
|
166
|
+
shrink-0
|
|
167
|
+
class="chat-reasoning-disclosure__caret"
|
|
168
|
+
:class="{ 'rotate-90': isExpanded }"
|
|
169
|
+
/>
|
|
170
|
+
</button>
|
|
171
|
+
</TelaCollapsibleTrigger>
|
|
170
172
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
173
|
+
<!-- Open while running, collapsible once done -->
|
|
174
|
+
<ChatReasoningCollapsibleContent :open="isExpanded">
|
|
175
|
+
<div class="chat-reasoning-details" pb-8px>
|
|
176
|
+
<ChatReasoningSteps
|
|
177
|
+
:reasoning-data="displayedReasoning"
|
|
178
|
+
:completed="completed"
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
</ChatReasoningCollapsibleContent>
|
|
182
|
+
</TelaCollapsible>
|
|
183
|
+
</Transition>
|
|
181
184
|
</div>
|
|
182
185
|
</template>
|
|
183
186
|
|
|
184
187
|
<style scoped>
|
|
185
|
-
.chat-reasoning-details{contain:layout style}.chat-reasoning-disclosure__caret{transition:transform .2s cubic-bezier(.2,0,0,1)}@media (prefers-reduced-motion:reduce){.chat-reasoning-disclosure__caret{transition-duration:0s}}
|
|
188
|
+
.chat-reasoning-stage{display:grid}.chat-reasoning-stage>*{grid-area:1/1;min-width:0}.chat-reasoning-placeholder{align-items:center;align-self:start;display:flex;min-height:32px}.chat-reasoning-presence-enter-active,.chat-reasoning-presence-leave-active{transition:opacity .22s cubic-bezier(.2,0,0,1)}.chat-reasoning-presence-leave-active{pointer-events:none}.chat-reasoning-presence-enter-from,.chat-reasoning-presence-leave-to{opacity:0}.chat-reasoning-details{contain:layout style}.chat-reasoning-disclosure__caret{transition:transform .2s cubic-bezier(.2,0,0,1)}@media (prefers-reduced-motion:reduce){.chat-reasoning-disclosure__caret,.chat-reasoning-presence-enter-active,.chat-reasoning-presence-leave-active{transition-duration:0s}}
|
|
186
189
|
</style>
|
|
@@ -11,6 +11,14 @@ export declare function shouldIgnoreChatDebugLayoutShift(entry: {
|
|
|
11
11
|
hadRecentInput: boolean;
|
|
12
12
|
startTime: number;
|
|
13
13
|
}, ignoreUntil: number): boolean;
|
|
14
|
+
export interface ChatScrollSample {
|
|
15
|
+
top: number;
|
|
16
|
+
height: number;
|
|
17
|
+
viewport: number;
|
|
18
|
+
pinned?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function unexpectedChatScrollMovement(previous: ChatScrollSample, next: ChatScrollSample): number;
|
|
21
|
+
export declare function findChatDebugScrollRoot(stage: HTMLElement): HTMLElement | null;
|
|
14
22
|
type ListenerTarget = {
|
|
15
23
|
addEventListener: (type: string, listener: unknown, options?: unknown) => void;
|
|
16
24
|
removeEventListener: (type: string, listener: unknown, options?: unknown) => void;
|
|
@@ -2,6 +2,25 @@ import { nextTick, onBeforeUnmount, onMounted, reactive, watch } from "vue";
|
|
|
2
2
|
export function shouldIgnoreChatDebugLayoutShift(entry, ignoreUntil) {
|
|
3
3
|
return entry.hadRecentInput || entry.startTime <= ignoreUntil;
|
|
4
4
|
}
|
|
5
|
+
export function unexpectedChatScrollMovement(previous, next) {
|
|
6
|
+
const movement = next.top - previous.top;
|
|
7
|
+
const previousBottom = previous.height - previous.viewport - previous.top;
|
|
8
|
+
const nextBottom = next.height - next.viewport - next.top;
|
|
9
|
+
const following = (previous.pinned ?? previousBottom <= 64) && (next.pinned ?? nextBottom <= 64);
|
|
10
|
+
const targetMovement = Math.max(0, next.height - next.viewport) - previous.top;
|
|
11
|
+
const expected = following ? Math.max(Math.min(0, targetMovement), Math.min(Math.max(0, targetMovement), movement)) : 0;
|
|
12
|
+
return Math.abs(movement - expected);
|
|
13
|
+
}
|
|
14
|
+
export function findChatDebugScrollRoot(stage) {
|
|
15
|
+
let element = stage.querySelector("[data-chat-conversation]");
|
|
16
|
+
while (element) {
|
|
17
|
+
if (element.matches("[data-reka-scroll-area-viewport]") || ["auto", "scroll"].includes(getComputedStyle(element).overflowY)) {
|
|
18
|
+
return element;
|
|
19
|
+
}
|
|
20
|
+
element = element.parentElement;
|
|
21
|
+
}
|
|
22
|
+
return stage.ownerDocument?.scrollingElement ?? null;
|
|
23
|
+
}
|
|
5
24
|
function resolveListenerCapture(options) {
|
|
6
25
|
if (typeof options === "boolean")
|
|
7
26
|
return options;
|
|
@@ -76,12 +95,19 @@ export function useChatDebugDiagnostics(options) {
|
|
|
76
95
|
let probeInterval;
|
|
77
96
|
let layoutShiftObserver;
|
|
78
97
|
let paintMeasurement = 0;
|
|
79
|
-
let
|
|
98
|
+
let previousScroll = null;
|
|
99
|
+
let previousScrollRoot = null;
|
|
100
|
+
const frameSamples = [];
|
|
101
|
+
let frameIntervalMs = 16.67;
|
|
102
|
+
let ignoreScrollUntil = 0;
|
|
80
103
|
let clockBaselineDriftMs = null;
|
|
81
104
|
let lastSeenSeekRevision = options.seekRevision.value;
|
|
82
105
|
let ignoreLayoutShiftUntil = 0;
|
|
83
106
|
let observedStage = null;
|
|
84
107
|
function reset() {
|
|
108
|
+
paintMeasurement += 1;
|
|
109
|
+
frameSamples.length = 0;
|
|
110
|
+
frameIntervalMs = 16.67;
|
|
85
111
|
diagnostics.paintLatencyMs = 0;
|
|
86
112
|
diagnostics.droppedFrames = 0;
|
|
87
113
|
diagnostics.domNodes = 0;
|
|
@@ -92,7 +118,7 @@ export function useChatDebugDiagnostics(options) {
|
|
|
92
118
|
diagnostics.maxScrollJump = 0;
|
|
93
119
|
diagnostics.clockDriftMs = 0;
|
|
94
120
|
diagnostics.findings = [];
|
|
95
|
-
|
|
121
|
+
previousScroll = null;
|
|
96
122
|
clockBaselineDriftMs = null;
|
|
97
123
|
lastSeenSeekRevision = options.seekRevision.value;
|
|
98
124
|
ignoreLayoutShiftUntil = performance.now() + 100;
|
|
@@ -103,19 +129,31 @@ export function useChatDebugDiagnostics(options) {
|
|
|
103
129
|
diagnostics.findings.push({ kind, label, atMs: options.elapsedMs.value });
|
|
104
130
|
}
|
|
105
131
|
function recordFrame(frameDeltaMs) {
|
|
106
|
-
if (frameDeltaMs <=
|
|
132
|
+
if (frameDeltaMs <= 0)
|
|
133
|
+
return;
|
|
134
|
+
if (frameDeltaMs < 50) {
|
|
135
|
+
frameSamples.push(frameDeltaMs);
|
|
136
|
+
if (frameSamples.length > 60)
|
|
137
|
+
frameSamples.shift();
|
|
138
|
+
const sorted = [...frameSamples].sort((a, b) => a - b);
|
|
139
|
+
if (sorted.length >= 10)
|
|
140
|
+
frameIntervalMs = sorted[Math.floor(sorted.length * 0.2)];
|
|
141
|
+
}
|
|
142
|
+
if (frameDeltaMs <= Math.max(50, frameIntervalMs * 1.5))
|
|
107
143
|
return;
|
|
108
|
-
diagnostics.droppedFrames += Math.max(1, Math.round(frameDeltaMs /
|
|
144
|
+
diagnostics.droppedFrames += Math.max(1, Math.round(frameDeltaMs / frameIntervalMs) - 1);
|
|
109
145
|
addFinding("frame", "A long frame interrupted playback");
|
|
110
146
|
}
|
|
111
|
-
function
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
147
|
+
function rebaseProbes() {
|
|
148
|
+
previousScroll = null;
|
|
149
|
+
clockBaselineDriftMs = null;
|
|
150
|
+
diagnostics.clockDriftMs = 0;
|
|
151
|
+
paintMeasurement += 1;
|
|
152
|
+
ignoreLayoutShiftUntil = performance.now() + 100;
|
|
153
|
+
}
|
|
154
|
+
function handleScrollIntent() {
|
|
155
|
+
previousScroll = null;
|
|
156
|
+
ignoreScrollUntil = performance.now() + 500;
|
|
119
157
|
}
|
|
120
158
|
function sampleStage() {
|
|
121
159
|
const stage = options.stageRef.value;
|
|
@@ -132,7 +170,7 @@ export function useChatDebugDiagnostics(options) {
|
|
|
132
170
|
const virtualAge = options.elapsedMs.value - mountEvent.atMs;
|
|
133
171
|
return actualAge - virtualAge;
|
|
134
172
|
});
|
|
135
|
-
const shouldProbeClock = options.playing.value && options.speed.value === "1x" && pendingClockDrifts.length > 0;
|
|
173
|
+
const shouldProbeClock = (typeof document === "undefined" || document.visibilityState !== "hidden") && options.playing.value && options.speed.value === "1x" && pendingClockDrifts.length > 0;
|
|
136
174
|
if (lastSeenSeekRevision !== options.seekRevision.value) {
|
|
137
175
|
diagnostics.clockDriftMs = 0;
|
|
138
176
|
clockBaselineDriftMs = null;
|
|
@@ -152,26 +190,34 @@ export function useChatDebugDiagnostics(options) {
|
|
|
152
190
|
}
|
|
153
191
|
if (diagnostics.clockDriftMs > 1e3)
|
|
154
192
|
addFinding("clock", "A wall-clock timer diverged from virtual playback");
|
|
155
|
-
const scrollable =
|
|
193
|
+
const scrollable = findChatDebugScrollRoot(stage);
|
|
156
194
|
if (!scrollable) {
|
|
157
195
|
diagnostics.scrollPosition = "idle";
|
|
158
196
|
diagnostics.scrollTop = 0;
|
|
159
197
|
diagnostics.scrollHeight = stage.scrollHeight;
|
|
160
|
-
|
|
198
|
+
previousScroll = null;
|
|
161
199
|
return;
|
|
162
200
|
}
|
|
163
201
|
const nextScrollTop = scrollable.scrollTop;
|
|
164
202
|
const distanceToBottom = scrollable.scrollHeight - scrollable.clientHeight - nextScrollTop;
|
|
165
|
-
diagnostics.scrollPosition = distanceToBottom <=
|
|
203
|
+
diagnostics.scrollPosition = scrollable.scrollHeight <= scrollable.clientHeight + 2 ? "idle" : distanceToBottom <= 64 ? "pinned" : "detached";
|
|
166
204
|
diagnostics.scrollTop = Math.round(nextScrollTop);
|
|
167
205
|
diagnostics.scrollHeight = scrollable.scrollHeight;
|
|
168
|
-
|
|
169
|
-
|
|
206
|
+
const pinned = stage.querySelector("[data-chat-bottom-pinned]")?.getAttribute("data-chat-bottom-pinned");
|
|
207
|
+
const nextScroll = {
|
|
208
|
+
top: nextScrollTop,
|
|
209
|
+
height: scrollable.scrollHeight,
|
|
210
|
+
viewport: scrollable.clientHeight,
|
|
211
|
+
pinned: pinned === void 0 || pinned === null ? void 0 : pinned === "true"
|
|
212
|
+
};
|
|
213
|
+
if (previousScroll && previousScrollRoot === scrollable && performance.now() >= ignoreScrollUntil) {
|
|
214
|
+
const jump = unexpectedChatScrollMovement(previousScroll, nextScroll);
|
|
170
215
|
diagnostics.maxScrollJump = Math.max(diagnostics.maxScrollJump, jump);
|
|
171
216
|
if (jump > 120)
|
|
172
|
-
addFinding("scroll", "
|
|
217
|
+
addFinding("scroll", "Unexpected scroll movement exceeded 120 px");
|
|
173
218
|
}
|
|
174
|
-
|
|
219
|
+
previousScroll = nextScroll;
|
|
220
|
+
previousScrollRoot = scrollable;
|
|
175
221
|
}
|
|
176
222
|
function setupLayoutShiftProbe() {
|
|
177
223
|
if (!("PerformanceObserver" in window) || !PerformanceObserver.supportedEntryTypes.includes("layout-shift"))
|
|
@@ -199,6 +245,7 @@ export function useChatDebugDiagnostics(options) {
|
|
|
199
245
|
if (!target.closest("[data-chat-scroll-preserve], [data-chat-scroll-preserve-trigger]"))
|
|
200
246
|
return;
|
|
201
247
|
ignoreLayoutShiftUntil = performance.now() + 500;
|
|
248
|
+
handleScrollIntent();
|
|
202
249
|
}
|
|
203
250
|
watch(options.appliedEventCount, async () => {
|
|
204
251
|
const measurement = ++paintMeasurement;
|
|
@@ -208,10 +255,12 @@ export function useChatDebugDiagnostics(options) {
|
|
|
208
255
|
if (measurement !== paintMeasurement)
|
|
209
256
|
return;
|
|
210
257
|
diagnostics.paintLatencyMs = performance.now() - startedAt;
|
|
211
|
-
if (diagnostics.paintLatencyMs >
|
|
212
|
-
addFinding("paint", "Event-to-
|
|
258
|
+
if (options.playing.value && performance.now() >= ignoreLayoutShiftUntil && diagnostics.paintLatencyMs > Math.max(50, frameIntervalMs * 2)) {
|
|
259
|
+
addFinding("paint", "Event-to-frame latency exceeded the frame budget");
|
|
260
|
+
}
|
|
213
261
|
});
|
|
214
262
|
});
|
|
263
|
+
watch(options.seekRevision, rebaseProbes, { flush: "sync" });
|
|
215
264
|
watch(options.scenario, () => {
|
|
216
265
|
nextTick(sampleStage);
|
|
217
266
|
});
|
|
@@ -220,12 +269,20 @@ export function useChatDebugDiagnostics(options) {
|
|
|
220
269
|
setupLayoutShiftProbe();
|
|
221
270
|
observedStage = options.stageRef.value;
|
|
222
271
|
observedStage?.addEventListener?.("click", handleStageClick, true);
|
|
272
|
+
for (const event of ["wheel", "touchstart", "pointerdown", "keydown"])
|
|
273
|
+
observedStage?.addEventListener?.(event, handleScrollIntent, true);
|
|
274
|
+
if (typeof document !== "undefined")
|
|
275
|
+
document.addEventListener("visibilitychange", rebaseProbes);
|
|
223
276
|
sampleStage();
|
|
224
277
|
});
|
|
225
278
|
onBeforeUnmount(() => {
|
|
226
279
|
if (probeInterval)
|
|
227
280
|
clearInterval(probeInterval);
|
|
228
281
|
observedStage?.removeEventListener?.("click", handleStageClick, true);
|
|
282
|
+
for (const event of ["wheel", "touchstart", "pointerdown", "keydown"])
|
|
283
|
+
observedStage?.removeEventListener?.(event, handleScrollIntent, true);
|
|
284
|
+
if (typeof document !== "undefined")
|
|
285
|
+
document.removeEventListener("visibilitychange", rebaseProbes);
|
|
229
286
|
layoutShiftObserver?.disconnect();
|
|
230
287
|
});
|
|
231
288
|
return { diagnostics, reset, recordFrame };
|
|
@@ -16,11 +16,11 @@ export function useChatDebugPlayback(scenarios, options = {}) {
|
|
|
16
16
|
() => scenarios.find((scenario) => scenario.id === selectedScenarioId.value) ?? scenarios[0]
|
|
17
17
|
);
|
|
18
18
|
const manualEvents = shallowRef([]);
|
|
19
|
+
const appliedEventCount = computed(() => countAppliedChatDebugEvents(selectedScenario.value, elapsedMs.value));
|
|
19
20
|
const playbackState = computed(() => manualEvents.value.reduce(
|
|
20
21
|
applyChatDebugEvent,
|
|
21
|
-
resolveChatDebugPlayback(selectedScenario.value,
|
|
22
|
+
resolveChatDebugPlayback(selectedScenario.value, selectedScenario.value.events[appliedEventCount.value - 1]?.atMs ?? -1)
|
|
22
23
|
));
|
|
23
|
-
const appliedEventCount = computed(() => countAppliedChatDebugEvents(selectedScenario.value, elapsedMs.value));
|
|
24
24
|
const currentEvent = computed(() => selectedScenario.value.events[appliedEventCount.value - 1] ?? null);
|
|
25
25
|
const progress = computed(() => selectedScenario.value.durationMs === 0 ? 0 : elapsedMs.value / selectedScenario.value.durationMs);
|
|
26
26
|
const uniqueEventTimes = computed(() => Array.from(new Set(selectedScenario.value.events.map((event) => event.atMs))));
|
|
@@ -40,7 +40,8 @@ export function useChatDebugPlayback(scenarios, options = {}) {
|
|
|
40
40
|
}
|
|
41
41
|
function seek(nextElapsedMs) {
|
|
42
42
|
seekRevision.value += 1;
|
|
43
|
-
manualEvents.value
|
|
43
|
+
if (manualEvents.value.length > 0)
|
|
44
|
+
manualEvents.value = [];
|
|
44
45
|
setElapsed(nextElapsedMs);
|
|
45
46
|
}
|
|
46
47
|
function restart() {
|
|
@@ -89,8 +90,13 @@ export function useChatDebugPlayback(scenarios, options = {}) {
|
|
|
89
90
|
previousFrameAt = 0;
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
93
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
|
94
|
+
previousFrameAt = 0;
|
|
95
|
+
animationFrame = requestAnimationFrame(tick);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
92
98
|
if (previousFrameAt > 0) {
|
|
93
|
-
const frameDelta =
|
|
99
|
+
const frameDelta = timestamp - previousFrameAt;
|
|
94
100
|
options.onFrame?.(frameDelta);
|
|
95
101
|
const nextElapsed = speed.value === "gap" ? advanceGapCompressed(frameDelta) : elapsedMs.value + frameDelta * (speed.value === "4x" ? 4 : 1);
|
|
96
102
|
setElapsed(nextElapsed);
|
|
@@ -120,11 +126,18 @@ export function useChatDebugPlayback(scenarios, options = {}) {
|
|
|
120
126
|
if (isPlaying)
|
|
121
127
|
animationFrame = requestAnimationFrame(tick);
|
|
122
128
|
});
|
|
129
|
+
function handleVisibilityChange() {
|
|
130
|
+
previousFrameAt = 0;
|
|
131
|
+
}
|
|
123
132
|
onMounted(() => {
|
|
124
133
|
window.addEventListener("keydown", handleKeyboard);
|
|
134
|
+
if (typeof document !== "undefined")
|
|
135
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
125
136
|
});
|
|
126
137
|
onBeforeUnmount(() => {
|
|
127
138
|
window.removeEventListener("keydown", handleKeyboard);
|
|
139
|
+
if (typeof document !== "undefined")
|
|
140
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
128
141
|
cancelAnimationFrame(animationFrame);
|
|
129
142
|
});
|
|
130
143
|
return {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import MarkdownRender, { removeCustomComponents, setCustomComponents } from "markstream-vue";
|
|
3
3
|
import { wrapCustomMarkdownComponents } from "../markdown/custom-components";
|
|
4
|
+
import { STREAMING_TEXT_FADE_MS } from "../markdown/streaming-text-segments";
|
|
5
|
+
import StreamingTextNode from "../markdown/streaming-text-node.vue";
|
|
4
6
|
const props = defineProps({
|
|
5
7
|
content: { type: [String, null], required: false, default: "" },
|
|
6
8
|
final: { type: Boolean, required: false, default: true },
|
|
@@ -21,8 +23,27 @@ function escapeSingleDollarSigns(text) {
|
|
|
21
23
|
}
|
|
22
24
|
const escapedContent = computed(() => escapeSingleDollarSigns(props.content ?? ""));
|
|
23
25
|
const customMarkdownComponents = computed(() => wrapCustomMarkdownComponents(props.customComponents));
|
|
24
|
-
|
|
26
|
+
const streamingTextActive = ref(props.typewriter);
|
|
27
|
+
let streamingTextTimer;
|
|
28
|
+
watch(() => props.typewriter, (typewriter) => {
|
|
29
|
+
if (streamingTextTimer !== void 0) {
|
|
30
|
+
clearTimeout(streamingTextTimer);
|
|
31
|
+
streamingTextTimer = void 0;
|
|
32
|
+
}
|
|
33
|
+
if (typewriter) {
|
|
34
|
+
streamingTextActive.value = true;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
streamingTextTimer = setTimeout(() => {
|
|
38
|
+
streamingTextTimer = void 0;
|
|
39
|
+
streamingTextActive.value = false;
|
|
40
|
+
}, STREAMING_TEXT_FADE_MS);
|
|
41
|
+
});
|
|
42
|
+
const registeredComponents = computed(() => streamingTextActive.value ? { text: StreamingTextNode, ...customMarkdownComponents.value } : customMarkdownComponents.value);
|
|
43
|
+
watch(registeredComponents, (components) => setCustomComponents(scopeId, components), { immediate: true });
|
|
25
44
|
onBeforeUnmount(() => {
|
|
45
|
+
if (streamingTextTimer !== void 0)
|
|
46
|
+
clearTimeout(streamingTextTimer);
|
|
26
47
|
removeCustomComponents(scopeId);
|
|
27
48
|
});
|
|
28
49
|
</script>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
node: {
|
|
3
|
+
content?: string | null;
|
|
4
|
+
center?: boolean;
|
|
5
|
+
};
|
|
6
|
+
};
|
|
7
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
8
|
+
declare const _default: typeof __VLS_export;
|
|
9
|
+
export default _default;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import {
|
|
3
|
+
STREAMING_TEXT_FADE_MS,
|
|
4
|
+
applyStreamingTextContent,
|
|
5
|
+
createStreamingTextState,
|
|
6
|
+
nextStreamingTextSettleAt,
|
|
7
|
+
renderedStreamingText,
|
|
8
|
+
settleStreamingTextSegments
|
|
9
|
+
} from "./streaming-text-segments";
|
|
10
|
+
const props = defineProps({
|
|
11
|
+
node: { type: Object, required: true }
|
|
12
|
+
});
|
|
13
|
+
const attrs = useAttrs();
|
|
14
|
+
const providedFade = inject("markstreamFade", void 0);
|
|
15
|
+
const streamState = inject("markstreamTextStreamState", void 0);
|
|
16
|
+
const indexKey = computed(() => {
|
|
17
|
+
const key = attrs["index-key"] ?? attrs.indexKey;
|
|
18
|
+
return key === void 0 || key === null || key === "" ? "" : String(key);
|
|
19
|
+
});
|
|
20
|
+
const reducedMotion = typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)").matches : false;
|
|
21
|
+
const fadeEnabled = computed(() => {
|
|
22
|
+
const attrFade = attrs.fade;
|
|
23
|
+
if (attrFade === "" || attrFade === true || attrFade === "true")
|
|
24
|
+
return true;
|
|
25
|
+
if (attrFade === false || attrFade === "false")
|
|
26
|
+
return false;
|
|
27
|
+
return typeof providedFade?.value === "boolean" ? providedFade.value : true;
|
|
28
|
+
});
|
|
29
|
+
const animate = computed(() => fadeEnabled.value && !reducedMotion);
|
|
30
|
+
function content() {
|
|
31
|
+
return String(props.node.content ?? "");
|
|
32
|
+
}
|
|
33
|
+
const state = shallowRef(createStreamingTextState(content(), {
|
|
34
|
+
persisted: indexKey.value ? streamState?.get(indexKey.value) : void 0,
|
|
35
|
+
now: Date.now(),
|
|
36
|
+
animate: animate.value
|
|
37
|
+
}));
|
|
38
|
+
let settleTimer;
|
|
39
|
+
function clearSettleTimer() {
|
|
40
|
+
if (settleTimer !== void 0) {
|
|
41
|
+
clearTimeout(settleTimer);
|
|
42
|
+
settleTimer = void 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function scheduleSettle() {
|
|
46
|
+
if (settleTimer !== void 0)
|
|
47
|
+
return;
|
|
48
|
+
const settleAt = nextStreamingTextSettleAt(state.value);
|
|
49
|
+
if (settleAt === null)
|
|
50
|
+
return;
|
|
51
|
+
settleTimer = setTimeout(() => {
|
|
52
|
+
settleTimer = void 0;
|
|
53
|
+
state.value = settleStreamingTextSegments(state.value, Date.now());
|
|
54
|
+
scheduleSettle();
|
|
55
|
+
}, Math.max(0, settleAt - Date.now()) + 16);
|
|
56
|
+
}
|
|
57
|
+
function persist() {
|
|
58
|
+
if (indexKey.value)
|
|
59
|
+
streamState?.set(indexKey.value, renderedStreamingText(state.value));
|
|
60
|
+
}
|
|
61
|
+
watch([content, animate], ([nextContent]) => {
|
|
62
|
+
state.value = applyStreamingTextContent(state.value, nextContent, { now: Date.now(), animate: animate.value });
|
|
63
|
+
persist();
|
|
64
|
+
scheduleSettle();
|
|
65
|
+
});
|
|
66
|
+
persist();
|
|
67
|
+
onMounted(scheduleSettle);
|
|
68
|
+
onBeforeUnmount(clearSettleTimer);
|
|
69
|
+
const fadeStyle = { "--chat-stream-fade-duration": `${STREAMING_TEXT_FADE_MS}ms` };
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<template>
|
|
73
|
+
<span
|
|
74
|
+
class="text-node"
|
|
75
|
+
:class="{ 'text-node-center': node.center }"
|
|
76
|
+
:style="fadeStyle"
|
|
77
|
+
><span>{{ state.settled }}</span><span
|
|
78
|
+
v-for="segment in state.segments"
|
|
79
|
+
:key="segment.id"
|
|
80
|
+
class="chat-stream-chunk"
|
|
81
|
+
>{{ segment.text }}</span></span>
|
|
82
|
+
</template>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
node: {
|
|
3
|
+
content?: string | null;
|
|
4
|
+
center?: boolean;
|
|
5
|
+
};
|
|
6
|
+
};
|
|
7
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
8
|
+
declare const _default: typeof __VLS_export;
|
|
9
|
+
export default _default;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure state for the streaming text node. Text that has already been shown
|
|
3
|
+
* lives in `settled`; every append while streaming becomes its own segment so
|
|
4
|
+
* several chunks can fade in at once instead of the newest one cutting the
|
|
5
|
+
* previous fade short. Segments are folded back into `settled` once they have
|
|
6
|
+
* been on screen for the fade duration, keeping the DOM small.
|
|
7
|
+
*/
|
|
8
|
+
/** How long a streamed chunk takes to fade in. Mirrored to CSS via a custom property. */
|
|
9
|
+
export declare const STREAMING_TEXT_FADE_MS = 320;
|
|
10
|
+
/** Upper bound on live segments so a paused animation clock cannot grow the DOM forever. */
|
|
11
|
+
export declare const STREAMING_TEXT_MAX_SEGMENTS = 48;
|
|
12
|
+
export interface StreamingTextSegment {
|
|
13
|
+
id: number;
|
|
14
|
+
text: string;
|
|
15
|
+
startedAt: number;
|
|
16
|
+
}
|
|
17
|
+
export interface StreamingTextState {
|
|
18
|
+
settled: string;
|
|
19
|
+
segments: readonly StreamingTextSegment[];
|
|
20
|
+
nextId: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function renderedStreamingText(state: StreamingTextState): string;
|
|
23
|
+
/**
|
|
24
|
+
* Initial state for a node. Content that was already rendered by a previous
|
|
25
|
+
* instance of the same node (markstream persists it per index key while it
|
|
26
|
+
* re-parses inline markup) stays settled; only the unseen tail animates.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createStreamingTextState(content: string, options: {
|
|
29
|
+
persisted?: string;
|
|
30
|
+
now: number;
|
|
31
|
+
animate: boolean;
|
|
32
|
+
}): StreamingTextState;
|
|
33
|
+
/**
|
|
34
|
+
* Apply the next content snapshot. A pure append becomes a new segment; any
|
|
35
|
+
* other change (rewrite, shrink, animation disabled) settles everything so
|
|
36
|
+
* the text never lags behind the parser.
|
|
37
|
+
*/
|
|
38
|
+
export declare function applyStreamingTextContent(state: StreamingTextState, content: string, options: {
|
|
39
|
+
now: number;
|
|
40
|
+
animate: boolean;
|
|
41
|
+
}): StreamingTextState;
|
|
42
|
+
/** Fold segments that finished fading into `settled`. Order is preserved, so only a settled head can be folded. */
|
|
43
|
+
export declare function settleStreamingTextSegments(state: StreamingTextState, now: number, durationMs?: number): StreamingTextState;
|
|
44
|
+
/** Fold the oldest segments until at most `max` remain live. */
|
|
45
|
+
export declare function capStreamingTextSegments(state: StreamingTextState, max: number): StreamingTextState;
|
|
46
|
+
/** Time at which the oldest live segment finishes fading, or null when nothing is animating. */
|
|
47
|
+
export declare function nextStreamingTextSettleAt(state: StreamingTextState, durationMs?: number): number | null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const STREAMING_TEXT_FADE_MS = 320;
|
|
2
|
+
export const STREAMING_TEXT_MAX_SEGMENTS = 48;
|
|
3
|
+
export function renderedStreamingText(state) {
|
|
4
|
+
let text = state.settled;
|
|
5
|
+
for (const segment of state.segments)
|
|
6
|
+
text += segment.text;
|
|
7
|
+
return text;
|
|
8
|
+
}
|
|
9
|
+
export function createStreamingTextState(content, options) {
|
|
10
|
+
const { persisted, now, animate } = options;
|
|
11
|
+
if (animate && persisted !== void 0 && content.length > persisted.length && content.startsWith(persisted)) {
|
|
12
|
+
return {
|
|
13
|
+
settled: persisted,
|
|
14
|
+
segments: [{ id: 0, text: content.slice(persisted.length), startedAt: now }],
|
|
15
|
+
nextId: 1
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return { settled: content, segments: [], nextId: 0 };
|
|
19
|
+
}
|
|
20
|
+
export function applyStreamingTextContent(state, content, options) {
|
|
21
|
+
const { now, animate } = options;
|
|
22
|
+
const rendered = renderedStreamingText(state);
|
|
23
|
+
if (content === rendered && (animate || state.segments.length === 0))
|
|
24
|
+
return state;
|
|
25
|
+
if (!animate || !content.startsWith(rendered))
|
|
26
|
+
return { settled: content, segments: [], nextId: state.nextId };
|
|
27
|
+
const segments = [...state.segments, { id: state.nextId, text: content.slice(rendered.length), startedAt: now }];
|
|
28
|
+
return capStreamingTextSegments({ settled: state.settled, segments, nextId: state.nextId + 1 }, STREAMING_TEXT_MAX_SEGMENTS);
|
|
29
|
+
}
|
|
30
|
+
export function settleStreamingTextSegments(state, now, durationMs = STREAMING_TEXT_FADE_MS) {
|
|
31
|
+
let settledCount = 0;
|
|
32
|
+
while (settledCount < state.segments.length && state.segments[settledCount].startedAt + durationMs <= now)
|
|
33
|
+
settledCount += 1;
|
|
34
|
+
if (settledCount === 0)
|
|
35
|
+
return state;
|
|
36
|
+
return foldStreamingTextSegments(state, settledCount);
|
|
37
|
+
}
|
|
38
|
+
export function capStreamingTextSegments(state, max) {
|
|
39
|
+
const excess = state.segments.length - max;
|
|
40
|
+
return excess > 0 ? foldStreamingTextSegments(state, excess) : state;
|
|
41
|
+
}
|
|
42
|
+
export function nextStreamingTextSettleAt(state, durationMs = STREAMING_TEXT_FADE_MS) {
|
|
43
|
+
const head = state.segments[0];
|
|
44
|
+
return head ? head.startedAt + durationMs : null;
|
|
45
|
+
}
|
|
46
|
+
function foldStreamingTextSegments(state, count) {
|
|
47
|
+
let settled = state.settled;
|
|
48
|
+
for (let index = 0; index < count; index += 1)
|
|
49
|
+
settled += state.segments[index].text;
|
|
50
|
+
return { settled, segments: state.segments.slice(count), nextId: state.nextId };
|
|
51
|
+
}
|