@jskit-ai/assistant-core 0.1.180 → 0.1.182
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/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/App.vue +16 -2
- package/fixtures/responsive-assistant/ConversationFixture.vue +14 -1
- package/package.json +5 -5
- package/src/client/conversation/AssistantTranscript.vue +23 -3
- package/test/assistantScroll.browser.test.js +58 -3
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
12
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
13
|
-
"@jskit-ai/kernel": "0.1.
|
|
11
|
+
"@jskit-ai/assistant-core": "0.1.182",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.1.202",
|
|
13
|
+
"@jskit-ai/kernel": "0.1.204",
|
|
14
14
|
"@mdi/js": "^7.4.47",
|
|
15
15
|
"vue": "^3.5.13",
|
|
16
16
|
"vuetify": "^4.0.0",
|
|
17
|
-
"@jskit-ai/connectors-core": "0.1.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.1.
|
|
17
|
+
"@jskit-ai/connectors-core": "0.1.21",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.1.21"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
@@ -7,6 +7,7 @@ const draft = ref("");
|
|
|
7
7
|
const query = new URLSearchParams(location.search);
|
|
8
8
|
const controls = query.has("controls");
|
|
9
9
|
const deferredHistory = query.has("history");
|
|
10
|
+
const pagedHistory = query.has("paged-history");
|
|
10
11
|
const streaming = query.has("streaming");
|
|
11
12
|
const liveText = ref("");
|
|
12
13
|
const liveMessage = computed(() => ({ messageId: "stream-answer", role: "assistant", text: liveText.value, status: "inProgress" }));
|
|
@@ -16,11 +17,18 @@ const loadMoreError = ref("");
|
|
|
16
17
|
const historyRequests = ref(0);
|
|
17
18
|
const hasMoreBefore = ref(controls);
|
|
18
19
|
let completeHistory = null;
|
|
20
|
+
function historyPage(first) {
|
|
21
|
+
return Array.from({ length: 20 }, (_, index) => ["user", "assistant"].map(role => ({
|
|
22
|
+
id: `${role}-${first + index}`, role, status: "completed",
|
|
23
|
+
text: `${role} message ${first + index}. A deliberately detailed update keeps the conversation tall enough to exercise the real overflow container.`
|
|
24
|
+
}))).flat();
|
|
25
|
+
}
|
|
19
26
|
const messages = ref(Array.from({ length: 70 }, (_, index) => ({
|
|
20
27
|
id: `message-${index}`, role: index % 2 ? "assistant" : "user", status: "completed",
|
|
21
28
|
text: `Conversation line ${index + 1}: responsive scroll containment keeps the composer visible.\n\nMore text with **formatting**, a [link](https://example.com), and a_long_word_that_must_wrap_in_narrow_panes_without_overflow.`
|
|
22
29
|
})));
|
|
23
30
|
if (controls) messages.value[0].text = messages.value[0].text.repeat(4);
|
|
31
|
+
if (pagedHistory) messages.value = historyPage(21);
|
|
24
32
|
if (streaming) messages.value.push({ id: "stream-question", role: "user", text: "Show the answer as it arrives." });
|
|
25
33
|
function finishStream() {
|
|
26
34
|
messages.value.push({ ...liveMessage.value, status: "completed" });
|
|
@@ -29,7 +37,12 @@ function finishStream() {
|
|
|
29
37
|
function finishHistory(error = "") {
|
|
30
38
|
loadMoreError.value = error;
|
|
31
39
|
if (!error) {
|
|
32
|
-
|
|
40
|
+
if (pagedHistory) {
|
|
41
|
+
messages.value.unshift(...historyPage(1));
|
|
42
|
+
hasMoreBefore.value = false;
|
|
43
|
+
} else {
|
|
44
|
+
messages.value.unshift({ id: `older-${historyRequests.value}`, role: "assistant", text: "Older conversation paragraph. ".repeat(40), status: "completed" });
|
|
45
|
+
}
|
|
33
46
|
}
|
|
34
47
|
loadingMore.value = false;
|
|
35
48
|
completeHistory({ changed: !error });
|
|
@@ -57,7 +70,7 @@ const adapter = computed(() => ({
|
|
|
57
70
|
<template>
|
|
58
71
|
<v-app>
|
|
59
72
|
<v-main>
|
|
60
|
-
<main class="assistant-responsive-fixture">
|
|
73
|
+
<main class="assistant-responsive-fixture" :class="{ 'assistant-responsive-fixture--paged': pagedHistory }">
|
|
61
74
|
<nav v-if="streaming">
|
|
62
75
|
<button @click="liveText += 'Growing answer. '">Receive text</button>
|
|
63
76
|
<button @click="finishStream">Finish answer</button>
|
|
@@ -80,6 +93,7 @@ const adapter = computed(() => ({
|
|
|
80
93
|
<style>
|
|
81
94
|
html, body, #app { height: 100%; margin: 0; min-height: 0; }
|
|
82
95
|
.assistant-responsive-fixture { display: flex; flex-direction: column; height: 100dvh; min-height: 0; padding: .5rem; }
|
|
96
|
+
.assistant-responsive-fixture--paged { width: min(420px, 100%); margin: auto; }
|
|
83
97
|
.assistant-responsive-fixture nav { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 1rem; }
|
|
84
98
|
.assistant-responsive-fixture__conversation { flex: 1 1 auto; }
|
|
85
99
|
</style>
|
|
@@ -4,6 +4,8 @@ import AssistantConversationElement from "../../src/client/conversation/Assistan
|
|
|
4
4
|
import { useAssistantAttachments } from "../../src/client/conversation/useAssistantAttachments.js";
|
|
5
5
|
import { useAssistantSuggestions } from "../../src/client/conversation/useAssistantSuggestions.js";
|
|
6
6
|
const phase = ref("idle");
|
|
7
|
+
const attribution = new URLSearchParams(location.search).has("attribution");
|
|
8
|
+
const assistantLabel = ref("Current assistant");
|
|
7
9
|
const draft = ref("");
|
|
8
10
|
const narrow = ref(false);
|
|
9
11
|
const submissions = ref(0);
|
|
@@ -51,7 +53,17 @@ const goalState = reactive({
|
|
|
51
53
|
resume() { goal.value = { ...goal.value, status: "active", sampledAt: Date.now() }; }
|
|
52
54
|
});
|
|
53
55
|
const adapter = reactive({
|
|
54
|
-
conversation: {
|
|
56
|
+
conversation: {
|
|
57
|
+
assistantLabel,
|
|
58
|
+
turns: attribution ? [
|
|
59
|
+
{ turnId: "one", assistantLabel: "First assistant", assistantDetails: "First model",
|
|
60
|
+
assistant: { role: "assistant", text: "First answer" } },
|
|
61
|
+
{ turnId: "two", assistantLabel: "Second assistant", assistantDetails: "Second model",
|
|
62
|
+
assistant: { role: "assistant", text: "Second answer", assistantLabel: "Message assistant", assistantDetails: "Message model" } },
|
|
63
|
+
{ turnId: "three", assistant: { role: "assistant", text: "Unattributed answer" } }
|
|
64
|
+
] : [],
|
|
65
|
+
visible: true, scrollKey: "fixture", welcomeMessage: "Composer responsiveness fixture"
|
|
66
|
+
},
|
|
55
67
|
composer: {
|
|
56
68
|
draft,
|
|
57
69
|
rows: 2,
|
|
@@ -76,6 +88,7 @@ const adapter = reactive({
|
|
|
76
88
|
<v-app>
|
|
77
89
|
<v-main>
|
|
78
90
|
<div class="controls">
|
|
91
|
+
<button v-if="attribution" @click="assistantLabel = 'Next assistant'">Change assistant</button>
|
|
79
92
|
<button v-for="state in ['idle', 'active', 'reconnecting', 'stopping', 'stopped']" :key="state" @click="phase = state">External {{ state }}</button>
|
|
80
93
|
<button @click="narrow = !narrow">Resize pane</button>
|
|
81
94
|
<button @click="feedback = !feedback">Toggle action feedback</button>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.182",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"@ai-sdk/perplexity": "^4.0.43",
|
|
35
35
|
"@ai-sdk/togetherai": "^3.0.49",
|
|
36
36
|
"@ai-sdk/xai": "^4.0.58",
|
|
37
|
-
"@jskit-ai/resource-core": "0.1.
|
|
38
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
37
|
+
"@jskit-ai/resource-core": "0.1.146",
|
|
38
|
+
"@jskit-ai/resource-crud-core": "0.1.146",
|
|
39
39
|
"@mdi/js": "^7.4.47",
|
|
40
40
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
|
41
41
|
"ai": "^7.0.101",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"ws": "^8.20.1"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
48
|
-
"@jskit-ai/kernel": "0.1.
|
|
47
|
+
"@jskit-ai/http-runtime": "0.1.202",
|
|
48
|
+
"@jskit-ai/kernel": "0.1.204",
|
|
49
49
|
"vue": "^3.5.13",
|
|
50
50
|
"vuetify": "^4.0.0"
|
|
51
51
|
},
|
|
@@ -197,7 +197,7 @@
|
|
|
197
197
|
:messages="entry.messages"
|
|
198
198
|
:pending="isWorking && entry === displayEntries.at(-1)"
|
|
199
199
|
:preview-limit="progressPreviewLimit"
|
|
200
|
-
:aria-label="`${assistantLabel} progress`"
|
|
200
|
+
:aria-label="`${entry.turn.assistantLabel || assistantLabel} progress`"
|
|
201
201
|
/>
|
|
202
202
|
<div
|
|
203
203
|
v-else
|
|
@@ -209,7 +209,9 @@
|
|
|
209
209
|
<v-icon :icon="mdiRobotOutline" size="16" />
|
|
210
210
|
</span>
|
|
211
211
|
<div class="assistant-transcript__message-header">
|
|
212
|
-
<span
|
|
212
|
+
<span :title="entry.message.assistantDetails || entry.turn.assistantDetails || undefined">
|
|
213
|
+
{{ entry.message.assistantLabel || entry.turn.assistantLabel || assistantLabel }}
|
|
214
|
+
</span>
|
|
213
215
|
</div>
|
|
214
216
|
</div>
|
|
215
217
|
<div class="assistant-transcript__message assistant-transcript__message--assistant">
|
|
@@ -774,6 +776,7 @@ function updateLatestFollowFromScroll(event = {}) {
|
|
|
774
776
|
// Keep following the reader while the request is pending, before rows prepend.
|
|
775
777
|
if (loadMoreScrollSnapshot && target.scrollHeight === loadMoreScrollSnapshot.scrollHeight) {
|
|
776
778
|
loadMoreScrollSnapshot.scrollTop = scrollTop;
|
|
779
|
+
loadMoreScrollSnapshot.anchor = visibleHistoryAnchor(target);
|
|
777
780
|
}
|
|
778
781
|
const shouldFollow = scrollElementNearBottom(target);
|
|
779
782
|
if (!shouldFollow && !userScrollIntent.value && followingLatest.value) {
|
|
@@ -797,6 +800,17 @@ function updateLatestFollowFromScroll(event = {}) {
|
|
|
797
800
|
}
|
|
798
801
|
}
|
|
799
802
|
|
|
803
|
+
function visibleHistoryAnchor(element) {
|
|
804
|
+
const top = element.getBoundingClientRect().top;
|
|
805
|
+
for (const turn of element.querySelectorAll(".assistant-transcript__turn")) {
|
|
806
|
+
const bounds = turn.getBoundingClientRect();
|
|
807
|
+
if (bounds.bottom > top) {
|
|
808
|
+
return { element: turn, offset: bounds.top - top };
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
|
|
800
814
|
function requestLoadMore() {
|
|
801
815
|
if (!props.hasMoreBefore || props.loadingMore || loadMoreScrollSnapshot) {
|
|
802
816
|
return;
|
|
@@ -809,6 +823,7 @@ function requestLoadMore() {
|
|
|
809
823
|
loadMoreRequestVersion = version;
|
|
810
824
|
loadMoreScrollSnapshot = element
|
|
811
825
|
? {
|
|
826
|
+
anchor: visibleHistoryAnchor(element),
|
|
812
827
|
scrollHeight: element.scrollHeight,
|
|
813
828
|
scrollKey: props.scrollKey,
|
|
814
829
|
scrollTop: element.scrollTop,
|
|
@@ -842,7 +857,12 @@ async function completeLoadMoreRequest(version, changed) {
|
|
|
842
857
|
}
|
|
843
858
|
const element = bodyElement.value;
|
|
844
859
|
if (element) {
|
|
845
|
-
|
|
860
|
+
// Offscreen turns use estimated heights. Preserve the visible turn instead
|
|
861
|
+
// of moving by the total height difference, which can change during layout.
|
|
862
|
+
const anchor = snapshot.anchor;
|
|
863
|
+
previousScrollTop = anchor && element.contains(anchor.element)
|
|
864
|
+
? element.scrollTop + anchor.element.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset
|
|
865
|
+
: snapshot.scrollTop + (element.scrollHeight - snapshot.scrollHeight);
|
|
846
866
|
element.scrollTop = previousScrollTop;
|
|
847
867
|
}
|
|
848
868
|
clearLoadMoreScrollSnapshot();
|
|
@@ -22,6 +22,26 @@ const VIEWPORTS = Object.freeze([
|
|
|
22
22
|
Object.freeze({ name: "drawer", width: 360, height: 600 })
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
|
+
test("saved reply attribution remains when the current assistant changes", {
|
|
26
|
+
skip: !RUN_BROWSER_TEST, timeout: 60_000
|
|
27
|
+
}, async () => {
|
|
28
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
29
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
30
|
+
try {
|
|
31
|
+
const page = await browser.newPage();
|
|
32
|
+
await page.goto(`${vite.baseURL}/?conversation=1&attribution=1`);
|
|
33
|
+
const names = page.locator('[data-message-role="assistant"] .assistant-transcript__message-header span');
|
|
34
|
+
await expect(names).toHaveText(["First assistant", "Message assistant", "Current assistant"]);
|
|
35
|
+
await expect(names.nth(0)).toHaveAttribute("title", "First model");
|
|
36
|
+
await expect(names.nth(1)).toHaveAttribute("title", "Message model");
|
|
37
|
+
await page.getByRole("button", { name: "Change assistant", exact: true }).click();
|
|
38
|
+
await expect(names).toHaveText(["First assistant", "Message assistant", "Next assistant"]);
|
|
39
|
+
} finally {
|
|
40
|
+
await browser.close();
|
|
41
|
+
await stopProcess(vite);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
25
45
|
test("streaming answers grow in one bubble, preserve drafts and settle once at every width", {
|
|
26
46
|
skip: !RUN_BROWSER_TEST, timeout: 90_000
|
|
27
47
|
}, async () => {
|
|
@@ -219,6 +239,41 @@ test("conversation scrolling preserves history anchors and resets expansion on s
|
|
|
219
239
|
}
|
|
220
240
|
});
|
|
221
241
|
|
|
242
|
+
test("a delayed final history page preserves the visible message at every width", {
|
|
243
|
+
skip: !RUN_BROWSER_TEST,
|
|
244
|
+
timeout: 60_000
|
|
245
|
+
}, async () => {
|
|
246
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
247
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
248
|
+
try {
|
|
249
|
+
for (const width of [390, 800, 1365]) {
|
|
250
|
+
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
251
|
+
await page.goto(`${vite.baseURL}/?controls=1&history=1&paged-history=1`);
|
|
252
|
+
const body = page.locator(".assistant-transcript__body");
|
|
253
|
+
await expect(body).toBeVisible();
|
|
254
|
+
await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
|
|
255
|
+
await body.evaluate(element => {
|
|
256
|
+
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
|
|
257
|
+
element.scrollTop = 0;
|
|
258
|
+
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
259
|
+
});
|
|
260
|
+
await expect(page.getByRole("button", { name: "Loading older messages…", exact: true })).toBeDisabled();
|
|
261
|
+
const anchor = page.getByText("user message 21.", { exact: false });
|
|
262
|
+
const before = (await anchor.boundingBox()).y;
|
|
263
|
+
await page.getByRole("button", { name: "Complete history load", exact: true }).evaluate(element => element.click());
|
|
264
|
+
await expect(page.getByText("user message 1.", { exact: false })).toHaveCount(1);
|
|
265
|
+
await expect(page.getByRole("button", { name: "Loading older messages…", exact: true })).toHaveCount(0);
|
|
266
|
+
await expect.poll(async () => Math.abs((await anchor.boundingBox()).y - before)).toBeLessThan(2);
|
|
267
|
+
await expect(page.locator("output")).toHaveText("History requests: 1");
|
|
268
|
+
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
269
|
+
await page.close();
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
await browser.close();
|
|
273
|
+
await stopProcess(vite);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
222
277
|
test("upward scrolling loads older history once and preserves retry and selection behavior", {
|
|
223
278
|
skip: !RUN_BROWSER_TEST,
|
|
224
279
|
timeout: 90_000
|
|
@@ -249,14 +304,14 @@ test("upward scrolling loads older history once and preserves retry and selectio
|
|
|
249
304
|
const cdp = await page.context().newCDPSession(page);
|
|
250
305
|
const x = bounds.x + bounds.width / 2;
|
|
251
306
|
const y = bounds.y + 40;
|
|
307
|
+
const stopped = body.evaluate(element => new Promise(resolve => {
|
|
308
|
+
element.addEventListener("scrollend", () => resolve(), { once: true });
|
|
309
|
+
}));
|
|
252
310
|
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
|
253
311
|
for (let offset = 20; offset <= 380; offset += 20) {
|
|
254
312
|
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + offset }] });
|
|
255
313
|
await page.evaluate(() => new Promise(requestAnimationFrame));
|
|
256
314
|
}
|
|
257
|
-
const stopped = body.evaluate(element => new Promise(resolve => {
|
|
258
|
-
element.addEventListener("scrollend", () => resolve(), { once: true });
|
|
259
|
-
}));
|
|
260
315
|
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
261
316
|
await stopped;
|
|
262
317
|
await cdp.detach();
|