@jskit-ai/assistant-runtime 0.1.162 → 0.1.163
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/fixtures/conversation/App.vue +47 -0
- package/fixtures/conversation/index.html +1 -0
- package/fixtures/conversation/main.js +31 -0
- package/fixtures/conversation/vite.config.mjs +69 -0
- package/package.json +7 -7
- package/patterns/assistant-surface/PATTERN.md +17 -0
- package/src/client/components/AssistantSurfaceClientElement.vue +130 -12
- package/src/client/composables/useAssistantRuntime.js +74 -95
- package/src/client/support/assistantRuntimeState.js +34 -0
- package/test/assistantRuntimeState.test.js +47 -0
- package/test/conversation.browser.test.js +226 -0
- package/src/client/support/composerInputSupport.js +0 -29
- package/test/composerInputSupport.test.js +0 -23
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import { useRoute, useRouter } from "vue-router";
|
|
4
|
+
import { useTheme } from "vuetify";
|
|
5
|
+
import { useWebPlacementContext } from "@jskit-ai/shell-web/client/placement";
|
|
6
|
+
import AssistantSurfaceClientElement from "../../src/client/components/AssistantSurfaceClientElement.vue";
|
|
7
|
+
const route = useRoute();
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
const theme = useTheme();
|
|
10
|
+
const { context, mergeContext } = useWebPlacementContext();
|
|
11
|
+
const mounted = ref(true);
|
|
12
|
+
const second = ref(false);
|
|
13
|
+
const compact = ref(false);
|
|
14
|
+
const surface = ref("admin");
|
|
15
|
+
const workspace = computed(() => route.query.workspace || "alpha");
|
|
16
|
+
function switchWorkspace() { router.push({ query: { workspace: workspace.value === "alpha" ? "beta" : "alpha" } }); }
|
|
17
|
+
</script>
|
|
18
|
+
<template>
|
|
19
|
+
<v-app>
|
|
20
|
+
<v-main>
|
|
21
|
+
<div class="fixture">
|
|
22
|
+
<nav aria-label="Fixture controls">
|
|
23
|
+
<button @click="switchWorkspace">Switch workspace</button>
|
|
24
|
+
<button @click="mergeContext({ user: { id: context.user.id === '1' ? '2' : '1' } })">Switch user</button>
|
|
25
|
+
<button @click="surface = surface === 'admin' ? 'console' : 'admin'">Switch surface</button>
|
|
26
|
+
<button @click="mounted = !mounted">Toggle view</button>
|
|
27
|
+
<button @click="second = !second">Second instance</button>
|
|
28
|
+
<button @click="compact = !compact">Resize pane</button>
|
|
29
|
+
<button @click="theme.change(theme.global.name.value === 'dark' ? 'light' : 'dark')">Toggle theme</button>
|
|
30
|
+
</nav>
|
|
31
|
+
<div class="fixture__panes" :class="{ 'fixture__panes--compact': compact }">
|
|
32
|
+
<AssistantSurfaceClientElement v-if="mounted" :surface-id="surface" :layout="compact ? 'compact' : 'page'" assistant-label="Fixture assistant" />
|
|
33
|
+
<AssistantSurfaceClientElement v-if="second" surface-id="console" assistant-label="Second assistant" />
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</v-main>
|
|
37
|
+
</v-app>
|
|
38
|
+
</template>
|
|
39
|
+
<style>
|
|
40
|
+
html, body, #app { height: 100%; margin: 0; }
|
|
41
|
+
.fixture { height: 100dvh; display: flex; flex-direction: column; min-height: 0; }
|
|
42
|
+
.fixture nav { display: flex; flex-wrap: wrap; gap: .5rem; flex: 0 0 auto; }
|
|
43
|
+
.fixture nav button { padding: .25rem; }
|
|
44
|
+
.fixture__panes { display: flex; flex: 1 1 auto; min-height: 0; min-width: 0; }
|
|
45
|
+
.fixture__panes > * { flex: 1 1 0; min-width: 0; }
|
|
46
|
+
.fixture__panes--compact { width: min(360px, 100%); }
|
|
47
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="app"></div><script type="module" src="/main.js"></script></body></html>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createApp } from "vue";
|
|
2
|
+
import { createRouter, createWebHistory } from "vue-router";
|
|
3
|
+
import { VueQueryPlugin } from "@tanstack/vue-query";
|
|
4
|
+
import { createVuetify } from "vuetify";
|
|
5
|
+
import * as components from "vuetify/components";
|
|
6
|
+
import * as directives from "vuetify/directives";
|
|
7
|
+
import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
|
|
8
|
+
import "vuetify/styles";
|
|
9
|
+
import App from "./App.vue";
|
|
10
|
+
import { createWebPlacementRuntime } from "../../../shell-web/src/client/placement/runtime.js";
|
|
11
|
+
|
|
12
|
+
globalThis.__JSKIT_CLIENT_APP_CONFIG__ = {
|
|
13
|
+
surfaceDefinitions: { admin: { requiresWorkspace: true }, console: { requiresWorkspace: false } },
|
|
14
|
+
assistantSurfaces: {
|
|
15
|
+
admin: { settingsSurfaceId: "admin", configScope: "workspace" },
|
|
16
|
+
console: { settingsSurfaceId: "console", configScope: "global" }
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const router = createRouter({ history: createWebHistory(), routes: [{ path: "/:pathMatch(.*)*", component: App }] });
|
|
20
|
+
const app = createApp(App);
|
|
21
|
+
const placement = createWebPlacementRuntime({ components: new Map() });
|
|
22
|
+
placement.setContext({ user: { id: "1" }, surfaceConfig: {
|
|
23
|
+
defaultSurfaceId: "admin",
|
|
24
|
+
surfacesById: { admin: { id: "admin", routeBase: "/", requiresWorkspace: false } }
|
|
25
|
+
} });
|
|
26
|
+
app.provide("jskit.shell-web.runtime.web-placement.client", placement);
|
|
27
|
+
app.provide("jskit.workspaces.web.scope-support", { readRouteScope: ({ route }) => ({ workspaceSlug: route.query.workspace || "alpha" }) });
|
|
28
|
+
app.use(router).use(VueQueryPlugin, { queryClientConfig: { defaultOptions: { queries: { retry: false } } } });
|
|
29
|
+
app.use(createVuetify({ theme: { defaultTheme: "light" }, components, directives, icons: { defaultSet: "mdi", aliases, sets: { mdi } } }));
|
|
30
|
+
await router.isReady();
|
|
31
|
+
app.mount("#app");
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import vue from "@vitejs/plugin-vue";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
root: fileURLToPath(new URL("./", import.meta.url)),
|
|
7
|
+
server: { host: "127.0.0.1", strictPort: true },
|
|
8
|
+
plugins: [vue(), {
|
|
9
|
+
name: "controlled-assistant-api",
|
|
10
|
+
configureServer(server) {
|
|
11
|
+
const streams = [];
|
|
12
|
+
const requests = [];
|
|
13
|
+
server.middlewares.use(async (req, res, next) => {
|
|
14
|
+
const url = new URL(req.url, "http://fixture");
|
|
15
|
+
if (!url.pathname.startsWith("/api/") && !url.pathname.startsWith("/fixture/")) return next();
|
|
16
|
+
const json = (value) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(value)); };
|
|
17
|
+
const send = (stream, event) => stream.res.write(`${JSON.stringify(event)}\n`);
|
|
18
|
+
if (url.pathname === "/api/session") return json({ csrfToken: "fixture-only" });
|
|
19
|
+
if (url.pathname === "/fixture/state") return json({ requests, streams: streams.map(({ closed }) => ({ closed })) });
|
|
20
|
+
if (url.pathname === "/fixture/next") {
|
|
21
|
+
for (const stream of streams.filter((entry) => !entry.closed)) {
|
|
22
|
+
if (url.searchParams.has("fail")) {
|
|
23
|
+
send(stream, { type: "error", message: "Fixture provider failed." });
|
|
24
|
+
send(stream, { type: "done", status: "failed" });
|
|
25
|
+
stream.res.end();
|
|
26
|
+
} else if (url.searchParams.has("finish")) {
|
|
27
|
+
send(stream, { type: "tool_result", toolCallId: "lookup", ok: true, result: { private: "never-render-this-payload" } });
|
|
28
|
+
send(stream, { type: "assistant_delta", delta: " completed." });
|
|
29
|
+
send(stream, { type: "done", status: "completed" });
|
|
30
|
+
stream.res.end();
|
|
31
|
+
} else {
|
|
32
|
+
send(stream, { type: "assistant_delta", delta: "Partial answer" });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return json({ ok: true });
|
|
36
|
+
}
|
|
37
|
+
if (url.pathname.endsWith("/chat/stream")) {
|
|
38
|
+
let body = "";
|
|
39
|
+
for await (const part of req) body += part;
|
|
40
|
+
const payload = JSON.parse(body);
|
|
41
|
+
requests.push({ path: url.pathname, surface: req.headers['x-jskit-surface'], ...payload });
|
|
42
|
+
res.setHeader("Content-Type", "application/x-ndjson");
|
|
43
|
+
const stream = { res, closed: false };
|
|
44
|
+
streams.push(stream);
|
|
45
|
+
res.on("close", () => { stream.closed = true; });
|
|
46
|
+
send(stream, { type: "meta", conversationId: payload.conversationId || "100" });
|
|
47
|
+
send(stream, { type: "tool_call", toolCallId: "lookup", name: "Look up records", arguments: "never-render-this-payload" });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (url.pathname.endsWith("/messages")) {
|
|
51
|
+
const id = url.pathname.split("/").at(-2);
|
|
52
|
+
const entries = Array.from({ length: 70 }, (_, index) => ({
|
|
53
|
+
id: String(index + 1), kind: "chat", role: index % 2 ? "assistant" : "user",
|
|
54
|
+
contentText: index < 2 ? "Identical text" : `Saved ${id} message ${index + 1}.\n\n**Formatted** answer with a [link](https://example.com). Long_text_keeps_wrapping_in_a_narrow_drawer_even_while_other_content_is_streaming.`
|
|
55
|
+
}));
|
|
56
|
+
return json({ data: { type: "assistant-conversation-messages", id, attributes: { entries, totalPages: 1 } } });
|
|
57
|
+
}
|
|
58
|
+
if (url.pathname.endsWith("/conversations")) {
|
|
59
|
+
const older = url.searchParams.has("page[cursor]");
|
|
60
|
+
return json({ data: [{ type: "assistant-conversations", id: older ? "2" : "1", attributes: {
|
|
61
|
+
title: older ? "Older conversation" : "Saved conversation", status: "completed", startedAt: "2026-09-01T00:00:00Z"
|
|
62
|
+
} }], meta: { page: { nextCursor: older ? null : "older" } } });
|
|
63
|
+
}
|
|
64
|
+
res.statusCode = 404;
|
|
65
|
+
json({ error: "Unknown fixture request" });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}]
|
|
69
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.163",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -11,15 +11,15 @@
|
|
|
11
11
|
"./server/actionIds": "./src/server/actionIds.js"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
15
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
14
|
+
"@jskit-ai/assistant-core": "0.1.170",
|
|
15
|
+
"@jskit-ai/database-runtime": "0.1.192",
|
|
16
16
|
"json-rest-schema": "^1.0.17"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
20
|
-
"@jskit-ai/http-web": "0.1.
|
|
21
|
-
"@jskit-ai/kernel": "0.1.
|
|
22
|
-
"@jskit-ai/shell-web": "0.1.
|
|
19
|
+
"@jskit-ai/http-runtime": "0.1.190",
|
|
20
|
+
"@jskit-ai/http-web": "0.1.37",
|
|
21
|
+
"@jskit-ai/kernel": "0.1.192",
|
|
22
|
+
"@jskit-ai/shell-web": "0.1.196",
|
|
23
23
|
"@tanstack/vue-query": "^5.90.5",
|
|
24
24
|
"vue": "^3.5.13",
|
|
25
25
|
"vuetify": "^4.0.0"
|
|
@@ -75,3 +75,20 @@ public workspace support.
|
|
|
75
75
|
- workspace scope on a non-workspace surface
|
|
76
76
|
- copied runtime repositories, actions, or routes
|
|
77
77
|
- generator commands, question metadata, receipts, or provenance
|
|
78
|
+
|
|
79
|
+
## Presentation and view lifetime
|
|
80
|
+
|
|
81
|
+
The surface uses the same `AssistantConversationElement` as applications with
|
|
82
|
+
their own runtime. Use `layout="compact"` for a bounded drawer and the
|
|
83
|
+
`assistantLabel`, `welcomeMessage`, `placeholder`, and `showToolActivity` props
|
|
84
|
+
for presentation. Conversations remains available at all widths. Keep settings
|
|
85
|
+
on the configured settings route. Use the `composer-tools` slot for extra
|
|
86
|
+
application controls and `focus()` when opening a pane; do not target internal
|
|
87
|
+
DOM classes.
|
|
88
|
+
|
|
89
|
+
The runtime owns one draft and maps its stream statuses to the shared contract.
|
|
90
|
+
Typing remains possible during a response; Send waits. Unmounting or switching
|
|
91
|
+
workspace/surface aborts the local response and discards that view's state.
|
|
92
|
+
Reopening restores saved history, not a live stream. Stop cannot guarantee that
|
|
93
|
+
a server-side tool already running has stopped. Custom editor conversations
|
|
94
|
+
should use their own adapter and keep their existing operation/storage owner.
|
|
@@ -1,19 +1,137 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<AssistantClientElement v-bind="runtime" />
|
|
3
|
-
</template>
|
|
4
|
-
|
|
5
1
|
<script setup>
|
|
6
|
-
import {
|
|
2
|
+
import { computed, reactive, ref, toRef } from "vue";
|
|
3
|
+
import { AssistantConversationElement } from "@jskit-ai/assistant-core/client/conversation";
|
|
7
4
|
import { useAssistantRuntime } from "../composables/useAssistantRuntime.js";
|
|
5
|
+
import { mapAssistantConversationTurns } from "../support/assistantRuntimeState.js";
|
|
8
6
|
|
|
9
7
|
const props = defineProps({
|
|
10
|
-
surfaceId: {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
8
|
+
surfaceId: { type: String, required: true },
|
|
9
|
+
layout: { type: String, default: "page", validator: (value) => ["page", "compact"].includes(value) },
|
|
10
|
+
assistantLabel: { type: String, default: "Assistant" },
|
|
11
|
+
welcomeMessage: { type: String, default: "What would you like to do?" },
|
|
12
|
+
placeholder: { type: String, default: "Message the assistant…" },
|
|
13
|
+
showToolActivity: { type: Boolean, default: true }
|
|
14
14
|
});
|
|
15
|
+
const runtime = useAssistantRuntime({ surfaceId: toRef(props, "surfaceId") });
|
|
16
|
+
const state = reactive(runtime.state);
|
|
17
|
+
const conversation = ref(null);
|
|
18
|
+
const historyOpen = ref(false);
|
|
19
|
+
const selectedConversation = computed(() => state.conversationHistory.find((entry) => String(entry.id) === state.activeConversationId));
|
|
20
|
+
const turns = computed(() => mapAssistantConversationTurns(state.messages, state.pendingToolEvents, props));
|
|
21
|
+
const adapter = computed(() => ({
|
|
22
|
+
conversation: {
|
|
23
|
+
turns: turns.value,
|
|
24
|
+
scrollKey: `${state.scopeKey}:${state.activeConversationId || "new"}`,
|
|
25
|
+
loading: state.isRestoringConversation,
|
|
26
|
+
assistantLabel: props.assistantLabel,
|
|
27
|
+
welcomeMessage: state.messages.length || state.isRestoringConversation ? "" : props.welcomeMessage
|
|
28
|
+
},
|
|
29
|
+
composer: {
|
|
30
|
+
draft: state.input,
|
|
31
|
+
canSend: state.canSend,
|
|
32
|
+
disabled: !state.hasRuntimeScope,
|
|
33
|
+
canStop: state.isStreaming,
|
|
34
|
+
stopPending: state.isCanceling,
|
|
35
|
+
placeholder: props.placeholder,
|
|
36
|
+
rows: 2,
|
|
37
|
+
density: props.layout === "compact" ? "compact" : "default",
|
|
38
|
+
submitOnEnter: true,
|
|
39
|
+
submitOnModifierEnter: true
|
|
40
|
+
},
|
|
41
|
+
actions: {
|
|
42
|
+
setDraft(value) { state.input = value; },
|
|
43
|
+
submit: runtime.actions.sendMessage,
|
|
44
|
+
stop: runtime.actions.cancelStream
|
|
45
|
+
}
|
|
46
|
+
}));
|
|
15
47
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
48
|
+
function conversationSubtitle(entry) {
|
|
49
|
+
const details = [runtime.meta.normalizeConversationStatus(entry.status), runtime.meta.formatConversationStartedAt(entry.startedAt), `${Number(entry.messageCount || 0)} messages`];
|
|
50
|
+
if (state.isAdminSurface) {
|
|
51
|
+
details.push(entry.createdByUserDisplayName || entry.createdByUserEmail || (entry.createdByUserId ? `User #${entry.createdByUserId}` : "Unknown user"));
|
|
52
|
+
}
|
|
53
|
+
return details.join(" · ");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function selectConversation(entry) {
|
|
57
|
+
historyOpen.value = false;
|
|
58
|
+
await runtime.actions.selectConversation(entry);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function startNewConversation() {
|
|
62
|
+
runtime.actions.startNewConversation();
|
|
63
|
+
historyOpen.value = false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
defineExpose({ focus: () => conversation.value?.focus() });
|
|
19
67
|
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<section class="assistant-surface" :class="`assistant-surface--${layout}`" :aria-label="assistantLabel">
|
|
71
|
+
<div class="assistant-surface__toolbar">
|
|
72
|
+
<span class="assistant-surface__title">{{ selectedConversation?.title || 'New conversation' }}</span>
|
|
73
|
+
<v-btn variant="text" @click="historyOpen = true">Conversations</v-btn>
|
|
74
|
+
<v-menu v-if="showToolActivity && state.pendingToolEvents.length" location="bottom end">
|
|
75
|
+
<template #activator="{ props: menuProps }">
|
|
76
|
+
<v-btn v-bind="menuProps" variant="text">Activity</v-btn>
|
|
77
|
+
</template>
|
|
78
|
+
<v-list aria-label="Tool activity" max-height="320" max-width="360" class="assistant-surface__activity">
|
|
79
|
+
<v-list-item v-for="event in state.pendingToolEvents" :key="event.id" :title="event.name" :subtitle="event.status" />
|
|
80
|
+
</v-list>
|
|
81
|
+
</v-menu>
|
|
82
|
+
</div>
|
|
83
|
+
<AssistantConversationElement ref="conversation" :adapter="adapter" label="Conversation" class="assistant-surface__conversation">
|
|
84
|
+
<template #message-actions="{ message }">
|
|
85
|
+
<p v-if="message.status === 'failed' && message.error" class="assistant-surface__message-error" role="status">{{ message.error }}</p>
|
|
86
|
+
<p v-if="message.status === 'interrupted'" class="assistant-surface__notice" role="status">Response canceled. A tool already running may still finish.</p>
|
|
87
|
+
</template>
|
|
88
|
+
<template #hints>
|
|
89
|
+
<p v-if="state.error && !state.messages.some((message) => message.error === state.error)" class="assistant-surface__message-error" role="status">{{ state.error }}</p>
|
|
90
|
+
</template>
|
|
91
|
+
<template #composer-tools><slot name="composer-tools" :runtime="runtime" /></template>
|
|
92
|
+
</AssistantConversationElement>
|
|
93
|
+
|
|
94
|
+
<v-dialog v-model="historyOpen" max-width="640" scrollable aria-label="Conversations">
|
|
95
|
+
<v-card class="assistant-surface__history">
|
|
96
|
+
<v-card-title>Conversations</v-card-title>
|
|
97
|
+
<v-card-actions class="assistant-surface__history-actions">
|
|
98
|
+
<v-btn color="primary" :disabled="!state.canStartNewConversation" @click="startNewConversation">Start new conversation</v-btn>
|
|
99
|
+
<v-btn :disabled="state.conversationHistoryLoading" @click="runtime.actions.refreshConversationHistory">Refresh</v-btn>
|
|
100
|
+
<v-btn @click="historyOpen = false">Close</v-btn>
|
|
101
|
+
</v-card-actions>
|
|
102
|
+
<v-card-text>
|
|
103
|
+
<p v-if="state.conversationHistoryError" role="alert">{{ state.conversationHistoryError }}</p>
|
|
104
|
+
<v-skeleton-loader v-if="state.conversationHistoryLoading && !state.conversationHistory.length" type="list-item-two-line@3" aria-label="Loading conversations" />
|
|
105
|
+
<v-list v-else aria-label="Saved conversations">
|
|
106
|
+
<v-list-item v-if="!state.conversationHistory.length" title="No saved conversations" />
|
|
107
|
+
<v-list-item
|
|
108
|
+
v-for="entry in state.conversationHistory" :key="entry.id"
|
|
109
|
+
:active="String(entry.id) === state.activeConversationId"
|
|
110
|
+
:title="entry.title || 'Untitled conversation'" :subtitle="conversationSubtitle(entry)"
|
|
111
|
+
:disabled="state.isStreaming || state.isRestoringConversation" @click="selectConversation(entry)"
|
|
112
|
+
/>
|
|
113
|
+
</v-list>
|
|
114
|
+
<v-btn v-if="state.conversationHistoryHasMore" :disabled="state.conversationHistoryLoadingMore" variant="text" @click="runtime.actions.loadMoreConversationHistory">
|
|
115
|
+
{{ state.conversationHistoryLoadingMore ? 'Loading conversations…' : 'Load older conversations' }}
|
|
116
|
+
</v-btn>
|
|
117
|
+
</v-card-text>
|
|
118
|
+
</v-card>
|
|
119
|
+
</v-dialog>
|
|
120
|
+
</section>
|
|
121
|
+
</template>
|
|
122
|
+
|
|
123
|
+
<style scoped>
|
|
124
|
+
.assistant-surface { display: flex; flex-direction: column; height: 100%; max-height: 100%; min-height: 0; min-width: 0; overflow: hidden; gap: .5rem; }
|
|
125
|
+
.assistant-surface--page { padding: .5rem; }
|
|
126
|
+
.assistant-surface__toolbar { display: flex; align-items: center; flex-wrap: wrap; flex: 0 0 auto; gap: .25rem; }
|
|
127
|
+
.assistant-surface__title { flex: 1 1 8rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .875rem; }
|
|
128
|
+
.assistant-surface__conversation { flex: 1 1 auto; }
|
|
129
|
+
.assistant-surface__activity { overflow: auto; }
|
|
130
|
+
.assistant-surface__message-error, .assistant-surface__notice { font-size: .85rem; margin: .25rem 0; overflow-wrap: anywhere; }
|
|
131
|
+
.assistant-surface__message-error { color: rgb(var(--v-theme-error)); }
|
|
132
|
+
.assistant-surface__notice { color: rgba(var(--v-theme-on-surface), .75); }
|
|
133
|
+
.assistant-surface__history-actions { flex-wrap: wrap; }
|
|
134
|
+
@media (max-width: 600px), (pointer: coarse) {
|
|
135
|
+
.assistant-surface__toolbar .v-btn, .assistant-surface__history .v-btn { min-height: 48px; }
|
|
136
|
+
}
|
|
137
|
+
</style>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed,
|
|
1
|
+
import { computed, onScopeDispose, ref, shallowRef, toValue, watch } from "vue";
|
|
2
2
|
import { useQueryClient } from "@tanstack/vue-query";
|
|
3
3
|
import { getClientAppConfig } from "@jskit-ai/kernel/client";
|
|
4
4
|
import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
mapTranscriptEntriesToAssistantState,
|
|
29
29
|
normalizeToolName
|
|
30
30
|
} from "../support/assistantRuntimeState.js";
|
|
31
|
-
import { insertTextAtSelection } from "../support/composerInputSupport.js";
|
|
32
31
|
import {
|
|
33
32
|
loadConversationTranscript,
|
|
34
33
|
resolveConversationRestorePolicy
|
|
@@ -51,7 +50,8 @@ function toNonNegativeInteger(value, fallback = 0) {
|
|
|
51
50
|
function buildScopeStorageKey(scope = {}) {
|
|
52
51
|
const runtimeSurfaceId = normalizeText(scope?.targetSurfaceId).toLowerCase() || "assistant";
|
|
53
52
|
const workspaceSlug = normalizeText(scope?.workspaceSlug).toLowerCase() || "global";
|
|
54
|
-
|
|
53
|
+
const userId = normalizeRecordId(scope?.userId, { fallback: "anonymous" });
|
|
54
|
+
return `assistant.activeConversationId:${runtimeSurfaceId}:${workspaceSlug}:${userId}`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function readStoredActiveConversationId(scope = {}) {
|
|
@@ -151,11 +151,13 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
151
151
|
const error = ref("");
|
|
152
152
|
const pendingToolEvents = ref([]);
|
|
153
153
|
const conversationId = ref(null);
|
|
154
|
-
const abortController =
|
|
154
|
+
const abortController = shallowRef(null);
|
|
155
|
+
const isCanceling = ref(false);
|
|
156
|
+
let restoreVersion = 0;
|
|
155
157
|
|
|
156
158
|
const placementSnapshot = computed(() => normalizeObject(placementContext.value));
|
|
157
159
|
const assistantSurface = computed(() =>
|
|
158
|
-
resolveAssistantSurfaceConfig(appConfig, surfaceId)
|
|
160
|
+
resolveAssistantSurfaceConfig(appConfig, toValue(surfaceId))
|
|
159
161
|
);
|
|
160
162
|
const routeScope = computed(() => workspaceScopeSupport.readRouteScope(routeContext));
|
|
161
163
|
const runtimeScope = computed(() => {
|
|
@@ -165,6 +167,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
165
167
|
|
|
166
168
|
return {
|
|
167
169
|
targetSurfaceId: normalizeText(assistantSurface.value?.targetSurfaceId).toLowerCase(),
|
|
170
|
+
userId: normalizeRecordId(placementSnapshot.value?.user?.id, { fallback: null }),
|
|
168
171
|
workspaceSlug,
|
|
169
172
|
workspaceId: assistantSurface.value?.runtimeSurfaceRequiresWorkspace
|
|
170
173
|
? normalizeRecordId(placementSnapshot.value?.workspace?.id, { fallback: null })
|
|
@@ -197,7 +200,8 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
197
200
|
normalizeText(input.value)
|
|
198
201
|
);
|
|
199
202
|
});
|
|
200
|
-
const canStartNewConversation = computed(() => Boolean(hasRuntimeScope.value && !isStreaming.value));
|
|
203
|
+
const canStartNewConversation = computed(() => Boolean(hasRuntimeScope.value && !isStreaming.value && !isRestoringConversation.value));
|
|
204
|
+
const scopeKey = computed(() => JSON.stringify(runtimeScope.value));
|
|
201
205
|
|
|
202
206
|
function setRuntimeError(message, dedupeKey = "") {
|
|
203
207
|
const normalizedMessage = normalizeText(message);
|
|
@@ -218,9 +222,9 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
218
222
|
|
|
219
223
|
const conversationHistoryCollection = usePagedCollection({
|
|
220
224
|
queryKey: computed(() =>
|
|
221
|
-
assistantConversationsListQueryKey(runtimeScope.value, {
|
|
225
|
+
[...assistantConversationsListQueryKey(runtimeScope.value, {
|
|
222
226
|
limit: runtimePolicy.historyPageSize
|
|
223
|
-
})
|
|
227
|
+
}), runtimeScope.value.userId]
|
|
224
228
|
),
|
|
225
229
|
queryFn: ({ pageParam = null }) =>
|
|
226
230
|
runtimeApi.listConversations({
|
|
@@ -248,23 +252,6 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
248
252
|
const conversationHistoryHasMore = conversationHistoryCollection.hasMore;
|
|
249
253
|
const conversationHistoryError = conversationHistoryCollection.loadError;
|
|
250
254
|
|
|
251
|
-
watch(conversationId, (nextConversationId, previousConversationId) => {
|
|
252
|
-
if (!hasRuntimeScope.value) {
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const nextConversationIdKey = normalizeRecordId(nextConversationId, { fallback: null });
|
|
257
|
-
if (nextConversationIdKey) {
|
|
258
|
-
writeStoredActiveConversationId(runtimeScope.value, nextConversationIdKey);
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const previousConversationIdKey = normalizeRecordId(previousConversationId, { fallback: null });
|
|
263
|
-
if (previousConversationIdKey) {
|
|
264
|
-
writeStoredActiveConversationId(runtimeScope.value, "");
|
|
265
|
-
}
|
|
266
|
-
});
|
|
267
|
-
|
|
268
255
|
watch(
|
|
269
256
|
[
|
|
270
257
|
hasRuntimeScope,
|
|
@@ -282,7 +269,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
282
269
|
nextConversationHistory,
|
|
283
270
|
nextIsRestoringConversation
|
|
284
271
|
]) => {
|
|
285
|
-
if (!nextHasRuntimeScope || nextConversationHistoryLoading || nextIsRestoringConversation) {
|
|
272
|
+
if (!nextHasRuntimeScope || nextConversationHistoryLoading || nextIsRestoringConversation || isStreaming.value) {
|
|
286
273
|
return;
|
|
287
274
|
}
|
|
288
275
|
|
|
@@ -309,7 +296,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
309
296
|
return;
|
|
310
297
|
}
|
|
311
298
|
|
|
312
|
-
await selectConversationById(storedConversationId);
|
|
299
|
+
await selectConversationById(storedConversationId, { preserveDraft: true });
|
|
313
300
|
},
|
|
314
301
|
{
|
|
315
302
|
immediate: true
|
|
@@ -360,19 +347,16 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
360
347
|
await conversationHistoryCollection.loadMore();
|
|
361
348
|
}
|
|
362
349
|
|
|
363
|
-
async function selectConversationById(nextConversationId) {
|
|
364
|
-
const normalizedConversationId =
|
|
350
|
+
async function selectConversationById(nextConversationId, { preserveDraft = false } = {}) {
|
|
351
|
+
const normalizedConversationId = normalizeRecordId(nextConversationId, { fallback: "" });
|
|
365
352
|
if (!normalizedConversationId || isStreaming.value || isRestoringConversation.value || !hasRuntimeScope.value) {
|
|
366
353
|
return;
|
|
367
354
|
}
|
|
368
355
|
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const previousConversationId = conversationId.value;
|
|
375
|
-
conversationId.value = parsedConversationId;
|
|
356
|
+
const version = ++restoreVersion;
|
|
357
|
+
const scope = runtimeScope.value;
|
|
358
|
+
const previousDraft = input.value;
|
|
359
|
+
if (!preserveDraft) input.value = "";
|
|
376
360
|
isRestoringConversation.value = true;
|
|
377
361
|
setRuntimeError("");
|
|
378
362
|
|
|
@@ -381,27 +365,33 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
381
365
|
pageSize: runtimePolicy.restoreMessagesPageSize,
|
|
382
366
|
maxEntries: runtimePolicy.restoreMessagesMaxEntries,
|
|
383
367
|
fetchPage: (page, pageSize) => queryClient.fetchQuery({
|
|
384
|
-
queryKey: assistantConversationMessagesQueryKey(
|
|
368
|
+
queryKey: [...assistantConversationMessagesQueryKey(scope, normalizedConversationId, {
|
|
385
369
|
page,
|
|
386
370
|
pageSize
|
|
387
|
-
}),
|
|
388
|
-
queryFn: () =>
|
|
389
|
-
|
|
390
|
-
pageSize
|
|
391
|
-
}
|
|
371
|
+
}), scope.userId],
|
|
372
|
+
queryFn: () => {
|
|
373
|
+
if (version !== restoreVersion) throw new DOMException("Conversation changed.", "AbortError");
|
|
374
|
+
return runtimeApi.getConversationMessages(normalizedConversationId, { page, pageSize });
|
|
375
|
+
},
|
|
392
376
|
staleTime: runtimePolicy.historyStaleTimeMs
|
|
393
377
|
})
|
|
394
378
|
});
|
|
395
379
|
|
|
380
|
+
if (version !== restoreVersion) return;
|
|
396
381
|
const restored = mapTranscriptEntriesToAssistantState(transcript.entries);
|
|
382
|
+
conversationId.value = normalizedConversationId;
|
|
383
|
+
writeStoredActiveConversationId(scope, normalizedConversationId);
|
|
397
384
|
messages.value = restored.messages;
|
|
398
385
|
pendingToolEvents.value = restored.pendingToolEvents;
|
|
399
|
-
input.value = "";
|
|
400
386
|
} catch (loadError) {
|
|
401
|
-
|
|
387
|
+
if (version !== restoreVersion) return;
|
|
388
|
+
if (readStoredActiveConversationId(scope) === normalizedConversationId) {
|
|
389
|
+
writeStoredActiveConversationId(scope, "");
|
|
390
|
+
}
|
|
391
|
+
if (!input.value) input.value = previousDraft;
|
|
402
392
|
setRuntimeError(normalizeText(loadError?.message) || "Unable to load conversation.");
|
|
403
393
|
} finally {
|
|
404
|
-
isRestoringConversation.value = false;
|
|
394
|
+
if (version === restoreVersion) isRestoringConversation.value = false;
|
|
405
395
|
}
|
|
406
396
|
}
|
|
407
397
|
|
|
@@ -409,61 +399,33 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
409
399
|
await selectConversationById(conversation?.id);
|
|
410
400
|
}
|
|
411
401
|
|
|
412
|
-
function
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
402
|
+
function clearView() {
|
|
403
|
+
restoreVersion += 1;
|
|
404
|
+
abortController.value?.abort();
|
|
405
|
+
abortController.value = null;
|
|
417
406
|
messages.value = [];
|
|
418
407
|
pendingToolEvents.value = [];
|
|
419
408
|
input.value = "";
|
|
420
|
-
|
|
409
|
+
error.value = "";
|
|
421
410
|
conversationId.value = null;
|
|
422
|
-
writeStoredActiveConversationId(runtimeScope.value, "");
|
|
423
411
|
isStreaming.value = false;
|
|
412
|
+
isCanceling.value = false;
|
|
424
413
|
isRestoringConversation.value = false;
|
|
425
|
-
abortController.value = null;
|
|
426
414
|
}
|
|
427
415
|
|
|
428
|
-
function
|
|
429
|
-
if (
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
if (event?.key === "Enter" && event?.altKey === true && event?.ctrlKey !== true && event?.metaKey !== true) {
|
|
435
|
-
event.preventDefault();
|
|
436
|
-
|
|
437
|
-
const target = event?.target;
|
|
438
|
-
const nextValue = insertTextAtSelection(input.value, target?.selectionStart, target?.selectionEnd, "\n");
|
|
439
|
-
input.value = nextValue.value;
|
|
440
|
-
|
|
441
|
-
void nextTick(() => {
|
|
442
|
-
if (!target || typeof target.setSelectionRange !== "function") {
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
target.setSelectionRange(nextValue.selectionStart, nextValue.selectionEnd);
|
|
447
|
-
});
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
if (
|
|
452
|
-
event?.key === "Enter" &&
|
|
453
|
-
event?.shiftKey !== true &&
|
|
454
|
-
event?.ctrlKey !== true &&
|
|
455
|
-
event?.metaKey !== true &&
|
|
456
|
-
event?.altKey !== true
|
|
457
|
-
) {
|
|
458
|
-
event.preventDefault();
|
|
459
|
-
void sendMessage();
|
|
460
|
-
}
|
|
416
|
+
function startNewConversation() {
|
|
417
|
+
if (!canStartNewConversation.value) return;
|
|
418
|
+
writeStoredActiveConversationId(runtimeScope.value, "");
|
|
419
|
+
clearView();
|
|
461
420
|
}
|
|
462
421
|
|
|
422
|
+
watch(scopeKey, clearView, { flush: "sync" });
|
|
423
|
+
onScopeDispose(clearView);
|
|
424
|
+
|
|
463
425
|
function cancelStream() {
|
|
464
|
-
if (abortController.value)
|
|
465
|
-
|
|
466
|
-
|
|
426
|
+
if (!abortController.value || isCanceling.value) return;
|
|
427
|
+
isCanceling.value = true;
|
|
428
|
+
abortController.value.abort();
|
|
467
429
|
}
|
|
468
430
|
|
|
469
431
|
async function sendMessage() {
|
|
@@ -505,6 +467,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
505
467
|
}, runtimePolicy.timeoutMs);
|
|
506
468
|
|
|
507
469
|
let streamDoneStatus = "";
|
|
470
|
+
const ownsStream = () => abortController.value === streamAbortController;
|
|
508
471
|
|
|
509
472
|
try {
|
|
510
473
|
await runtimeApi.streamChat(
|
|
@@ -517,10 +480,12 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
517
480
|
{
|
|
518
481
|
signal: streamAbortController.signal,
|
|
519
482
|
onEvent(event) {
|
|
483
|
+
if (!ownsStream() || streamAbortController.signal.aborted) return;
|
|
520
484
|
const eventType = normalizeAssistantStreamEventType(event?.type, "");
|
|
521
485
|
|
|
522
486
|
if (eventType === ASSISTANT_STREAM_EVENT_TYPES.META && Object.hasOwn(event || {}, "conversationId")) {
|
|
523
487
|
conversationId.value = normalizeRecordId(event?.conversationId, { fallback: null });
|
|
488
|
+
writeStoredActiveConversationId(runtimeScope.value, conversationId.value);
|
|
524
489
|
return;
|
|
525
490
|
}
|
|
526
491
|
|
|
@@ -552,6 +517,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
552
517
|
...pendingToolEvents.value,
|
|
553
518
|
{
|
|
554
519
|
id: toolCallId,
|
|
520
|
+
messageId: assistantMessageId,
|
|
555
521
|
name: normalizeToolName(event?.name),
|
|
556
522
|
arguments: String(event?.arguments || ""),
|
|
557
523
|
status: "pending",
|
|
@@ -600,6 +566,8 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
600
566
|
}
|
|
601
567
|
);
|
|
602
568
|
|
|
569
|
+
if (!ownsStream()) return;
|
|
570
|
+
if (streamAbortController.signal.aborted) streamDoneStatus = "aborted";
|
|
603
571
|
const assistantMessage = findMessage(assistantMessageId);
|
|
604
572
|
const assistantMessageText = normalizeText(assistantMessage?.text);
|
|
605
573
|
if (!assistantMessageText && streamDoneStatus !== "aborted") {
|
|
@@ -619,6 +587,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
619
587
|
}));
|
|
620
588
|
}
|
|
621
589
|
} catch (streamError) {
|
|
590
|
+
if (!ownsStream()) return;
|
|
622
591
|
if (String(streamError?.name || "") === "AbortError") {
|
|
623
592
|
updateMessage(assistantMessageId, {
|
|
624
593
|
status: "canceled"
|
|
@@ -631,11 +600,19 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
631
600
|
}
|
|
632
601
|
} finally {
|
|
633
602
|
clearTimeout(streamTimeout);
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
603
|
+
if (ownsStream()) {
|
|
604
|
+
const answer = findMessage(assistantMessageId);
|
|
605
|
+
if (answer?.status === "error") {
|
|
606
|
+
updateMessage(assistantMessageId, { error: error.value });
|
|
607
|
+
if (!answer.text && !input.value) input.value = normalizedInput;
|
|
608
|
+
}
|
|
609
|
+
pendingToolEvents.value = interruptPendingToolEvents(pendingToolEvents.value);
|
|
610
|
+
abortController.value = null;
|
|
611
|
+
isStreaming.value = false;
|
|
612
|
+
isCanceling.value = false;
|
|
613
|
+
await invalidateConversationScope();
|
|
614
|
+
await refreshConversationHistory();
|
|
615
|
+
}
|
|
639
616
|
}
|
|
640
617
|
}
|
|
641
618
|
|
|
@@ -657,6 +634,9 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
657
634
|
messages,
|
|
658
635
|
input,
|
|
659
636
|
isStreaming,
|
|
637
|
+
isCanceling,
|
|
638
|
+
hasRuntimeScope,
|
|
639
|
+
scopeKey,
|
|
660
640
|
isRestoringConversation,
|
|
661
641
|
error,
|
|
662
642
|
pendingToolEvents,
|
|
@@ -673,7 +653,6 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
673
653
|
},
|
|
674
654
|
actions: {
|
|
675
655
|
sendMessage,
|
|
676
|
-
handleInputKeydown,
|
|
677
656
|
cancelStream,
|
|
678
657
|
startNewConversation,
|
|
679
658
|
clearConversation: startNewConversation,
|
|
@@ -5,6 +5,25 @@ import {
|
|
|
5
5
|
parseJsonObject
|
|
6
6
|
} from "@jskit-ai/assistant-core/shared";
|
|
7
7
|
import { isAssistantProgressOnlyText } from "../../shared/assistantResponseText.js";
|
|
8
|
+
import { conversationTurnsFromMessages } from "@jskit-ai/assistant-core/shared/conversation";
|
|
9
|
+
|
|
10
|
+
const CONVERSATION_MESSAGE_STATUS = Object.freeze({
|
|
11
|
+
streaming: "inProgress",
|
|
12
|
+
done: "completed",
|
|
13
|
+
error: "failed",
|
|
14
|
+
canceled: "interrupted"
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function mapAssistantConversationTurns(messages, toolEvents = [], { showToolActivity = true } = {}) {
|
|
18
|
+
return conversationTurnsFromMessages(messages.map((message) => ({
|
|
19
|
+
...message,
|
|
20
|
+
messageId: String(message.id),
|
|
21
|
+
status: CONVERSATION_MESSAGE_STATUS[message.status],
|
|
22
|
+
progressUpdates: showToolActivity ? toolEvents
|
|
23
|
+
.filter((event) => event.messageId === message.id)
|
|
24
|
+
.map((event) => ({ id: event.id, text: `${event.name} — ${event.status}` })) : []
|
|
25
|
+
})));
|
|
26
|
+
}
|
|
8
27
|
|
|
9
28
|
function buildId(prefix = "id") {
|
|
10
29
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
@@ -64,6 +83,9 @@ function mapTranscriptEntriesToAssistantState(entries) {
|
|
|
64
83
|
const sourceEntries = Array.isArray(entries) ? entries : [];
|
|
65
84
|
const messages = [];
|
|
66
85
|
const toolEventsById = new Map();
|
|
86
|
+
let latestUserId = null;
|
|
87
|
+
let currentAssistantId = null;
|
|
88
|
+
const pendingAssociation = [];
|
|
67
89
|
|
|
68
90
|
function ensureToolEvent(toolCallId, toolName) {
|
|
69
91
|
const key = normalizeText(toolCallId) || buildId("tool_call");
|
|
@@ -103,6 +125,15 @@ function mapTranscriptEntriesToAssistantState(entries) {
|
|
|
103
125
|
text,
|
|
104
126
|
status: "done"
|
|
105
127
|
});
|
|
128
|
+
if (role === "user") {
|
|
129
|
+
latestUserId = messageId;
|
|
130
|
+
currentAssistantId = null;
|
|
131
|
+
pendingAssociation.length = 0;
|
|
132
|
+
} else {
|
|
133
|
+
currentAssistantId = messageId;
|
|
134
|
+
for (const event of pendingAssociation) event.messageId = messageId;
|
|
135
|
+
pendingAssociation.length = 0;
|
|
136
|
+
}
|
|
106
137
|
continue;
|
|
107
138
|
}
|
|
108
139
|
|
|
@@ -111,6 +142,8 @@ function mapTranscriptEntriesToAssistantState(entries) {
|
|
|
111
142
|
const toolEvent = ensureToolEvent(toolCallId, metadata.tool);
|
|
112
143
|
toolEvent.arguments = String(entry?.contentText || "");
|
|
113
144
|
toolEvent.status = "pending";
|
|
145
|
+
toolEvent.messageId = currentAssistantId || latestUserId;
|
|
146
|
+
pendingAssociation.push(toolEvent);
|
|
114
147
|
continue;
|
|
115
148
|
}
|
|
116
149
|
|
|
@@ -136,5 +169,6 @@ export {
|
|
|
136
169
|
buildId,
|
|
137
170
|
interruptPendingToolEvents,
|
|
138
171
|
mapTranscriptEntriesToAssistantState,
|
|
172
|
+
mapAssistantConversationTurns,
|
|
139
173
|
normalizeToolName
|
|
140
174
|
};
|
|
@@ -132,3 +132,50 @@ test("live stream cleanup interrupts only tool events that remain pending", () =
|
|
|
132
132
|
]
|
|
133
133
|
);
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
test("runtime statuses and tool activity map explicitly without changing message identity or exposing payloads", async () => {
|
|
137
|
+
const { mapAssistantConversationTurns } = await import("../src/client/support/assistantRuntimeState.js");
|
|
138
|
+
const messages = [
|
|
139
|
+
{ id: "user-1", role: "user", status: "done", text: "Question" },
|
|
140
|
+
{ id: "answer-1", role: "assistant", status: "streaming", text: "" }
|
|
141
|
+
];
|
|
142
|
+
const tools = [{ id: "tool-1", messageId: "answer-1", name: "Search records", status: "pending", arguments: "secret", result: { private: true } }];
|
|
143
|
+
let turns = mapAssistantConversationTurns(messages, tools);
|
|
144
|
+
assert.equal(turns[0].turnId, "user-1");
|
|
145
|
+
assert.equal(turns[0].pending, true);
|
|
146
|
+
assert.equal(turns[0].messages.at(-1).text, "Search records — pending");
|
|
147
|
+
assert.doesNotMatch(JSON.stringify(turns), /secret|private/);
|
|
148
|
+
for (const [status, expected] of [["done", "completed"], ["error", "failed"], ["canceled", "interrupted"]]) {
|
|
149
|
+
messages[1] = { ...messages[1], text: "Partial answer", status };
|
|
150
|
+
turns = mapAssistantConversationTurns(messages, tools);
|
|
151
|
+
assert.equal(turns[0].assistant.messageId, "answer-1");
|
|
152
|
+
assert.equal(turns[0].assistant.status, expected);
|
|
153
|
+
assert.equal(turns[0].assistant.text, "Partial answer");
|
|
154
|
+
assert.equal(turns[0].pending, false);
|
|
155
|
+
}
|
|
156
|
+
assert.equal(mapAssistantConversationTurns(messages, tools, { showToolActivity: false })[0].messages.length, 2);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("numeric persisted identities and distinct equal-text replies remain distinct", async () => {
|
|
160
|
+
const { mapAssistantConversationTurns } = await import("../src/client/support/assistantRuntimeState.js");
|
|
161
|
+
const restored = mapTranscriptEntriesToAssistantState([
|
|
162
|
+
{ id: 1, role: "user", kind: "chat", contentText: "Question" },
|
|
163
|
+
{ id: 2, role: "assistant", kind: "chat", contentText: "Same answer" },
|
|
164
|
+
{ id: "3", role: "assistant", kind: "chat", contentText: "Same answer" }
|
|
165
|
+
]);
|
|
166
|
+
const turns = mapAssistantConversationTurns(restored.messages);
|
|
167
|
+
assert.deepEqual(turns.map((turn) => turn.assistant.messageId), ["transcript_2", "transcript_3"]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("restored tool calls retain their association with the reply they preceded", async () => {
|
|
171
|
+
const { mapAssistantConversationTurns } = await import("../src/client/support/assistantRuntimeState.js");
|
|
172
|
+
const restored = mapTranscriptEntriesToAssistantState([
|
|
173
|
+
{ id: 1, role: "user", kind: "chat", contentText: "Question" },
|
|
174
|
+
{ id: 2, role: "assistant", kind: "tool_call", contentText: "secret arguments", metadata: { toolCallId: "t", tool: "Search" } },
|
|
175
|
+
{ id: 3, role: "assistant", kind: "tool_result", contentText: '{"ok":true,"result":{"private":true}}', metadata: { toolCallId: "t" } },
|
|
176
|
+
{ id: 4, role: "assistant", kind: "chat", contentText: "Found it" }
|
|
177
|
+
]);
|
|
178
|
+
const [turn] = mapAssistantConversationTurns(restored.messages, restored.pendingToolEvents);
|
|
179
|
+
assert.equal(turn.messages[1].text, "Search — done");
|
|
180
|
+
assert.doesNotMatch(JSON.stringify(turn), /secret|private/);
|
|
181
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { chromium, expect } from "@playwright/test";
|
|
6
|
+
import { createChromiumLaunchOptions, startViteFixture, stopProcess } from "../../../tooling/testUtils/browserFixture.mjs";
|
|
7
|
+
|
|
8
|
+
const fixtureRoot = fileURLToPath(new URL("../fixtures/conversation/", import.meta.url));
|
|
9
|
+
|
|
10
|
+
test("ready-made assistant preserves streaming, drafts, scope and compact conversation access", {
|
|
11
|
+
skip: process.env.JSKIT_ASSISTANT_RUNTIME_BROWSER_INTEGRATION !== "1",
|
|
12
|
+
timeout: 180_000
|
|
13
|
+
}, async () => {
|
|
14
|
+
const vite = await startViteFixture({ fixtureRoot });
|
|
15
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
16
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
|
17
|
+
const errors = [];
|
|
18
|
+
page.on("pageerror", (error) => errors.push(error.message));
|
|
19
|
+
const input = page.getByRole("textbox", { name: "Message AI assistant" });
|
|
20
|
+
const send = page.getByRole("button", { name: "Send message", exact: true });
|
|
21
|
+
const advance = (query = "") => page.request.get(`${vite.baseURL}/fixture/next${query}`);
|
|
22
|
+
const requestState = async () => (await page.request.get(`${vite.baseURL}/fixture/state`)).json();
|
|
23
|
+
try {
|
|
24
|
+
await page.goto(vite.baseURL);
|
|
25
|
+
await expect(input).toBeEnabled();
|
|
26
|
+
await input.fill("First request");
|
|
27
|
+
await input.dispatchEvent("keydown", { key: "Enter", isComposing: true });
|
|
28
|
+
assert.equal((await requestState()).requests.length, 0);
|
|
29
|
+
await input.press("Enter");
|
|
30
|
+
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
|
|
31
|
+
await expect(input).toHaveValue("");
|
|
32
|
+
await expect(page.getByLabel("Fixture assistant progress")).toBeVisible();
|
|
33
|
+
await expect(send).toBeDisabled();
|
|
34
|
+
await input.fill("Draft typed while streaming. Keep my selection.");
|
|
35
|
+
await input.evaluate((element) => { element.focus(); element.setSelectionRange(6, 11); window.fixtureInput = element; });
|
|
36
|
+
await advance();
|
|
37
|
+
await expect(page.getByText("Partial answer", { exact: true })).toBeVisible();
|
|
38
|
+
await expect(input).toBeFocused();
|
|
39
|
+
assert.deepEqual(await input.evaluate((element) => [element === window.fixtureInput, element.selectionStart, element.selectionEnd]), [true, 6, 11]);
|
|
40
|
+
await advance("?finish=1");
|
|
41
|
+
await expect(page.getByRole("button", { name: "Stop", exact: true })).toHaveCount(0);
|
|
42
|
+
await expect(input).toHaveValue("Draft typed while streaming. Keep my selection.");
|
|
43
|
+
await expect(page.getByText("Partial answer completed.", { exact: true })).toHaveCount(1);
|
|
44
|
+
await expect(page.getByText("never-render-this-payload", { exact: false })).toHaveCount(0);
|
|
45
|
+
assert.equal((await requestState()).requests.length, 1);
|
|
46
|
+
assert.equal((await requestState()).requests[0].surface, "admin");
|
|
47
|
+
|
|
48
|
+
await input.fill("Partial failure");
|
|
49
|
+
await send.click();
|
|
50
|
+
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
|
|
51
|
+
await advance();
|
|
52
|
+
await advance("?fail=1");
|
|
53
|
+
await expect(page.getByText("Fixture provider failed.", { exact: true })).toHaveCount(1);
|
|
54
|
+
await expect(page.getByText("Partial answer", { exact: true })).toBeVisible();
|
|
55
|
+
await input.fill("Failure before answer");
|
|
56
|
+
await send.click();
|
|
57
|
+
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
|
|
58
|
+
await advance("?fail=1");
|
|
59
|
+
await expect(input).toHaveValue("Failure before answer");
|
|
60
|
+
await expect(send).toBeEnabled();
|
|
61
|
+
|
|
62
|
+
await send.click();
|
|
63
|
+
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
|
|
64
|
+
await input.fill("Preserve this after cancellation");
|
|
65
|
+
await page.getByRole("button", { name: "Stop", exact: true }).click();
|
|
66
|
+
await expect(page.getByText("Response canceled. A tool already running may still finish.")).toBeVisible();
|
|
67
|
+
await expect(input).toHaveValue("Preserve this after cancellation");
|
|
68
|
+
await expect.poll(async () => (await requestState()).streams.at(-1).closed).toBe(true);
|
|
69
|
+
|
|
70
|
+
await input.fill("Old workspace request");
|
|
71
|
+
await send.click();
|
|
72
|
+
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
|
|
73
|
+
await input.fill("Old workspace draft");
|
|
74
|
+
await page.getByRole("button", { name: "Switch workspace", exact: true }).click();
|
|
75
|
+
await expect(input).toHaveValue("");
|
|
76
|
+
await expect(page.getByText("Old workspace request", { exact: true })).toHaveCount(0);
|
|
77
|
+
await expect.poll(async () => (await requestState()).streams.at(-1).closed).toBe(true);
|
|
78
|
+
await advance("?finish=1");
|
|
79
|
+
await expect(page.getByText("Partial answer completed.", { exact: true })).toHaveCount(0);
|
|
80
|
+
await input.fill("New workspace request");
|
|
81
|
+
await send.click();
|
|
82
|
+
await expect.poll(async () => (await requestState()).requests.at(-1).path).toContain("/beta/");
|
|
83
|
+
await page.getByRole("button", { name: "Toggle view", exact: true }).click();
|
|
84
|
+
await expect(input).toHaveCount(0);
|
|
85
|
+
await expect.poll(async () => (await requestState()).streams.at(-1).closed).toBe(true);
|
|
86
|
+
await page.getByRole("button", { name: "Toggle view", exact: true }).click();
|
|
87
|
+
await expect(input).toBeVisible();
|
|
88
|
+
await page.getByRole("button", { name: "Conversations", exact: true }).click();
|
|
89
|
+
await expect(page.getByText("Saved conversation", { exact: true })).toBeVisible();
|
|
90
|
+
await page.getByRole("button", { name: "Load older conversations" }).click();
|
|
91
|
+
await expect(page.getByText("Older conversation", { exact: true })).toBeVisible();
|
|
92
|
+
await page.getByText("Saved conversation", { exact: true }).click();
|
|
93
|
+
await expect(page.getByRole("dialog", { name: "Conversations", exact: true })).toHaveCount(0);
|
|
94
|
+
await expect(page.getByText("Saved 1 message 70.", { exact: false })).toBeVisible();
|
|
95
|
+
await expect(page.getByText("Identical text", { exact: true })).toHaveCount(2);
|
|
96
|
+
|
|
97
|
+
const screenshots = process.env.JSKIT_ASSISTANT_SCREENSHOTS;
|
|
98
|
+
if (screenshots) await mkdir(screenshots, { recursive: true });
|
|
99
|
+
for (const viewport of [{ width: 390, height: 844 }, { width: 800, height: 800 }, { width: 1280, height: 900 }, { width: 1280, height: 600 }, { width: 360, height: 600 }]) {
|
|
100
|
+
await page.setViewportSize(viewport);
|
|
101
|
+
await expect(input).toBeVisible();
|
|
102
|
+
const bounds = await input.boundingBox();
|
|
103
|
+
assert.ok(bounds.y + bounds.height <= viewport.height, JSON.stringify({ viewport, bounds }));
|
|
104
|
+
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
105
|
+
const sendBounds = await send.boundingBox();
|
|
106
|
+
assert.ok(sendBounds.y + sendBounds.height <= viewport.height);
|
|
107
|
+
if (viewport.width <= 600) assert.ok(sendBounds.height >= 48);
|
|
108
|
+
await expect(page.getByRole("button", { name: "Conversations", exact: true })).toBeVisible();
|
|
109
|
+
if (screenshots) await page.screenshot({ path: `${screenshots}/runtime-${viewport.width}-${viewport.height}.png` });
|
|
110
|
+
}
|
|
111
|
+
await page.setViewportSize({ width: 1280, height: 800 });
|
|
112
|
+
const draft = "A long draft that remains focused while the pane narrows. ".repeat(4);
|
|
113
|
+
await input.fill(draft);
|
|
114
|
+
await input.evaluate((element) => { element.focus(); element.setSelectionRange(7, 7); });
|
|
115
|
+
const before = await input.evaluate((element) => element.clientHeight);
|
|
116
|
+
await page.getByRole("button", { name: "Resize pane" }).evaluate((element) => element.click());
|
|
117
|
+
await expect.poll(() => input.evaluate((element) => element.clientHeight)).toBeGreaterThan(before);
|
|
118
|
+
await expect(input).toBeFocused();
|
|
119
|
+
await page.getByRole("button", { name: "Toggle theme" }).click();
|
|
120
|
+
if (screenshots) await page.screenshot({ path: `${screenshots}/runtime-dark-drawer.png` });
|
|
121
|
+
await page.getByRole("button", { name: "Second instance" }).click();
|
|
122
|
+
await expect(input).toHaveCount(2);
|
|
123
|
+
await expect(input.first()).toHaveValue(draft);
|
|
124
|
+
await expect(input.last()).toHaveValue("");
|
|
125
|
+
await input.last().fill("Second instance draft");
|
|
126
|
+
await expect(input.first()).toHaveValue(draft);
|
|
127
|
+
await page.getByRole("button", { name: "Second instance" }).click();
|
|
128
|
+
await page.getByRole("button", { name: "Switch surface", exact: true }).click();
|
|
129
|
+
await expect(input).toHaveValue("");
|
|
130
|
+
await expect(page.getByText("Saved 1 message 70.", { exact: false })).toHaveCount(0);
|
|
131
|
+
await input.fill("Private draft before switching user");
|
|
132
|
+
await send.click();
|
|
133
|
+
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
|
|
134
|
+
await page.getByRole("button", { name: "Switch user", exact: true }).click();
|
|
135
|
+
await expect(input).toHaveValue("");
|
|
136
|
+
await expect(page.getByText("Private draft before switching user", { exact: true })).toHaveCount(0);
|
|
137
|
+
await expect.poll(async () => (await requestState()).streams.at(-1).closed).toBe(true);
|
|
138
|
+
assert.deepEqual(errors, []);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
throw new Error(`${error.message}\nPage errors: ${JSON.stringify(errors)}\n${vite.readOutput()}`, { cause: error });
|
|
141
|
+
} finally {
|
|
142
|
+
await browser.close();
|
|
143
|
+
await stopProcess(vite);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("history restores preserve new typing and reject obsolete pages and failed automatic restores", {
|
|
148
|
+
skip: process.env.JSKIT_ASSISTANT_RUNTIME_BROWSER_INTEGRATION !== "1",
|
|
149
|
+
timeout: 60_000
|
|
150
|
+
}, async () => {
|
|
151
|
+
const vite = await startViteFixture({ fixtureRoot });
|
|
152
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
153
|
+
const page = await browser.newPage();
|
|
154
|
+
const input = page.getByRole("textbox", { name: "Message AI assistant" });
|
|
155
|
+
const send = page.getByRole("button", { name: "Send message", exact: true });
|
|
156
|
+
const selectSaved = async () => {
|
|
157
|
+
await page.getByRole("button", { name: "Conversations", exact: true }).click();
|
|
158
|
+
await page.getByText("Saved conversation", { exact: true }).click();
|
|
159
|
+
await expect(page.getByRole("dialog", { name: "Conversations", exact: true })).toHaveCount(0);
|
|
160
|
+
};
|
|
161
|
+
let release;
|
|
162
|
+
let restoreRequests = 0;
|
|
163
|
+
try {
|
|
164
|
+
const held = new Promise((resolve) => { release = resolve; });
|
|
165
|
+
await page.route("**/conversations/1/messages?**", async (route) => {
|
|
166
|
+
restoreRequests += 1;
|
|
167
|
+
await held;
|
|
168
|
+
await route.fulfill({ json: { data: { type: "assistant-conversation-messages", id: "1", attributes: {
|
|
169
|
+
entries: [{ id: "delayed", kind: "chat", role: "assistant", contentText: "Obsolete restored answer" }], totalPages: 2
|
|
170
|
+
} } } });
|
|
171
|
+
});
|
|
172
|
+
await page.goto(vite.baseURL);
|
|
173
|
+
await selectSaved();
|
|
174
|
+
await expect.poll(() => restoreRequests).toBe(1);
|
|
175
|
+
await input.fill("Written during history restore");
|
|
176
|
+
await expect(send).toBeDisabled();
|
|
177
|
+
await page.getByRole("button", { name: "Switch workspace", exact: true }).click();
|
|
178
|
+
await expect(page).toHaveURL(/workspace=beta/);
|
|
179
|
+
await expect(input).toHaveValue("");
|
|
180
|
+
await input.fill("New workspace draft");
|
|
181
|
+
release();
|
|
182
|
+
await page.unrouteAll({ behavior: "wait" });
|
|
183
|
+
await expect(input).toHaveValue("New workspace draft");
|
|
184
|
+
await expect(send).toBeEnabled();
|
|
185
|
+
assert.equal(restoreRequests, 1, "An obsolete restore must not fetch its second page in the new scope");
|
|
186
|
+
await expect(page.getByText("Obsolete restored answer", { exact: true })).toHaveCount(0);
|
|
187
|
+
|
|
188
|
+
let finishRestore;
|
|
189
|
+
const pending = new Promise((resolve) => { finishRestore = resolve; });
|
|
190
|
+
await page.route("**/conversations/1/messages?**", async (route) => {
|
|
191
|
+
await pending;
|
|
192
|
+
await route.continue();
|
|
193
|
+
});
|
|
194
|
+
await selectSaved();
|
|
195
|
+
await input.fill("Keep typing while selected history loads");
|
|
196
|
+
await expect(send).toBeDisabled();
|
|
197
|
+
finishRestore();
|
|
198
|
+
await expect(page.getByText("Saved 1 message 70.", { exact: false })).toBeVisible();
|
|
199
|
+
await expect(input).toHaveValue("Keep typing while selected history loads");
|
|
200
|
+
await expect(send).toBeEnabled();
|
|
201
|
+
await page.unrouteAll({ behavior: "wait" });
|
|
202
|
+
|
|
203
|
+
let failRestore;
|
|
204
|
+
const failure = new Promise((resolve) => { failRestore = resolve; });
|
|
205
|
+
let failedRequests = 0;
|
|
206
|
+
await page.route("**/conversations/1/messages?**", async (route) => {
|
|
207
|
+
failedRequests += 1;
|
|
208
|
+
await failure;
|
|
209
|
+
await route.fulfill({ status: 403, json: { error: "Conversation unavailable." } });
|
|
210
|
+
});
|
|
211
|
+
await page.reload();
|
|
212
|
+
await expect.poll(() => failedRequests).toBe(1);
|
|
213
|
+
await input.fill("Typed during automatic restore");
|
|
214
|
+
failRestore();
|
|
215
|
+
await expect(send).toBeEnabled();
|
|
216
|
+
await expect(input).toHaveValue("Typed during automatic restore");
|
|
217
|
+
await page.getByRole("button", { name: "Conversations", exact: true }).click();
|
|
218
|
+
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
|
219
|
+
await expect(page.getByText("Saved conversation", { exact: true })).toBeVisible();
|
|
220
|
+
assert.equal(failedRequests, 1, "A failed automatic restore must require explicit selection to retry");
|
|
221
|
+
} finally {
|
|
222
|
+
release?.();
|
|
223
|
+
await browser.close();
|
|
224
|
+
await stopProcess(vite);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
function normalizeSelectionBoundary(value, fallback, max) {
|
|
2
|
-
const parsed = Number(value);
|
|
3
|
-
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
4
|
-
return fallback;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
if (parsed > max) {
|
|
8
|
-
return max;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
return parsed;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function insertTextAtSelection(source = "", selectionStart, selectionEnd, text = "") {
|
|
15
|
-
const value = String(source || "");
|
|
16
|
-
const maxBoundary = value.length;
|
|
17
|
-
const normalizedStart = normalizeSelectionBoundary(selectionStart, maxBoundary, maxBoundary);
|
|
18
|
-
const normalizedEnd = normalizeSelectionBoundary(selectionEnd, normalizedStart, maxBoundary);
|
|
19
|
-
const insertedText = String(text ?? "");
|
|
20
|
-
const nextBoundary = normalizedStart + insertedText.length;
|
|
21
|
-
|
|
22
|
-
return {
|
|
23
|
-
value: `${value.slice(0, normalizedStart)}${insertedText}${value.slice(normalizedEnd)}`,
|
|
24
|
-
selectionStart: nextBoundary,
|
|
25
|
-
selectionEnd: nextBoundary
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export { insertTextAtSelection };
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import test from "node:test";
|
|
3
|
-
import { insertTextAtSelection } from "../src/client/support/composerInputSupport.js";
|
|
4
|
-
|
|
5
|
-
test("insertTextAtSelection inserts a line break at the caret", () => {
|
|
6
|
-
const result = insertTextAtSelection("hello world", 5, 5, "\n");
|
|
7
|
-
|
|
8
|
-
assert.deepEqual(result, {
|
|
9
|
-
value: "hello\n world",
|
|
10
|
-
selectionStart: 6,
|
|
11
|
-
selectionEnd: 6
|
|
12
|
-
});
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
test("insertTextAtSelection replaces the active selection with a line break", () => {
|
|
16
|
-
const result = insertTextAtSelection("hello world", 5, 11, "\n");
|
|
17
|
-
|
|
18
|
-
assert.deepEqual(result, {
|
|
19
|
-
value: "hello\n",
|
|
20
|
-
selectionStart: 6,
|
|
21
|
-
selectionEnd: 6
|
|
22
|
-
});
|
|
23
|
-
});
|