@jskit-ai/assistant-core 0.1.165 → 0.1.167
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/App.vue +104 -0
- package/examples/conversation/README.md +42 -0
- package/examples/conversation/index.html +2 -0
- package/examples/conversation/main.js +9 -0
- package/examples/conversation/package.json +22 -0
- package/examples/conversation/server.js +123 -0
- package/examples/conversation/vite.config.js +8 -0
- package/package.json +59 -7
- package/src/client/conversation/AssistantComposerActions.vue +43 -0
- package/src/client/conversation/AssistantConversationElement.vue +78 -0
- package/src/client/conversation/AssistantProgress.vue +88 -0
- package/src/client/conversation/AssistantPromptInput.vue +467 -0
- package/src/client/conversation/AssistantTranscript.vue +1361 -0
- package/src/client/conversation/LongTextInlineParts.vue +64 -0
- package/src/client/conversation/LongTextPreviewBlocks.vue +324 -0
- package/src/client/conversation/index.js +9 -0
- package/src/client/conversation/submitText.js +29 -0
- package/src/client/conversation/useScrollToBottom.js +151 -0
- package/src/server/conversation/codexClient.js +328 -0
- package/src/server/conversation/codexEvents.js +623 -0
- package/src/server/conversation/codexTurn.js +280 -0
- package/src/server/conversation/index.js +2 -0
- package/src/server/conversation/memoryStorage.js +76 -0
- package/src/server/conversation/normalize.js +7 -0
- package/src/server/conversation/openCodeClient.js +512 -0
- package/src/server/conversation/transcript.js +92 -0
- package/src/shared/conversation/answerChoiceSugar.js +191 -0
- package/src/shared/conversation/index.js +6 -0
- package/src/shared/conversation/longTextBlocks.js +403 -0
- package/src/shared/conversation/numberedQuestionSugar.js +295 -0
- package/src/shared/conversation/scrollFollowState.js +32 -0
- package/src/shared/conversation/thinkingText.js +13 -0
- package/src/shared/conversation/turns.js +30 -0
- package/src/testing/conversationStorageContract.js +35 -0
- package/test/conversation.test.js +57 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-app>
|
|
3
|
+
<main class="example">
|
|
4
|
+
<header><h1>Assistant</h1><span>{{ providerLabel }}</span></header>
|
|
5
|
+
<AssistantConversationElement
|
|
6
|
+
:adapter="adapter" :configuration="configuration"
|
|
7
|
+
:configuration-mode="configurationMode" :configuration-fields="fields"
|
|
8
|
+
>
|
|
9
|
+
<template #hints><p v-if="error" role="alert">{{ error }}</p></template>
|
|
10
|
+
</AssistantConversationElement>
|
|
11
|
+
</main>
|
|
12
|
+
</v-app>
|
|
13
|
+
</template>
|
|
14
|
+
<script setup>
|
|
15
|
+
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
|
16
|
+
import { AssistantConversationElement } from "@jskit-ai/assistant-core/client/conversation";
|
|
17
|
+
|
|
18
|
+
const turns = ref([]), draft = ref(""), loading = ref(true), pending = ref(false), error = ref("");
|
|
19
|
+
const providerLabel = ref(""), configurationMode = ref("hidden"), configuration = ref({ tone: "concise" });
|
|
20
|
+
const fields = [{ name: "tone", label: "Answer style", items: ["concise", "detailed"] }];
|
|
21
|
+
let request = null;
|
|
22
|
+
let disposed = false;
|
|
23
|
+
|
|
24
|
+
async function reload() {
|
|
25
|
+
loading.value = true;
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch("/api/conversation");
|
|
28
|
+
if (!response.ok) throw new Error("Could not load the conversation.");
|
|
29
|
+
const data = await response.json();
|
|
30
|
+
if (disposed) return;
|
|
31
|
+
turns.value = data.turns;
|
|
32
|
+
providerLabel.value = data.providerLabel;
|
|
33
|
+
configurationMode.value = data.configurationMode;
|
|
34
|
+
configuration.value = data.configuration;
|
|
35
|
+
error.value = "";
|
|
36
|
+
} catch (failure) { if (!disposed) error.value = failure.message; }
|
|
37
|
+
finally { if (!disposed) loading.value = false; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function submit({ configuration: selectedConfiguration }) {
|
|
41
|
+
if (pending.value || !draft.value.trim()) return;
|
|
42
|
+
const text = draft.value;
|
|
43
|
+
pending.value = true;
|
|
44
|
+
error.value = "";
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
request = controller;
|
|
47
|
+
try {
|
|
48
|
+
const response = await fetch("/api/messages", {
|
|
49
|
+
method: "POST", headers: { "Content-Type": "application/json" }, signal: controller.signal,
|
|
50
|
+
body: JSON.stringify({ messageId: crypto.randomUUID(), text, configuration: selectedConfiguration })
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok) throw new Error((await response.json()).error || "Could not send.");
|
|
53
|
+
if (draft.value === text) draft.value = "";
|
|
54
|
+
const reader = response.body.getReader(), decoder = new TextDecoder();
|
|
55
|
+
let buffer = "";
|
|
56
|
+
try {
|
|
57
|
+
while (true) {
|
|
58
|
+
const { value, done } = await reader.read();
|
|
59
|
+
if (done) break;
|
|
60
|
+
buffer += decoder.decode(value, { stream: true });
|
|
61
|
+
let newline;
|
|
62
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
63
|
+
const event = JSON.parse(buffer.slice(0, newline));
|
|
64
|
+
buffer = buffer.slice(newline + 1);
|
|
65
|
+
if (disposed) continue;
|
|
66
|
+
if (event.type === "snapshot") turns.value = event.turns;
|
|
67
|
+
if (event.type === "error") error.value = event.message;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} finally { reader.releaseLock(); }
|
|
71
|
+
} catch (failure) { if (!disposed) error.value = failure.message; }
|
|
72
|
+
finally { if (!disposed) pending.value = false; request = null; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function stop() {
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch("/api/run", { method: "DELETE" });
|
|
78
|
+
if (!response.ok) throw new Error("Could not stop the assistant.");
|
|
79
|
+
} catch (failure) { error.value = failure.message; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const adapter = reactive({
|
|
83
|
+
conversation: { turns, loading, visible: true, assistantLabel: "Assistant", scrollKey: "example" },
|
|
84
|
+
composer: {
|
|
85
|
+
draft, pending, disabled: pending, submitOnModifierEnter: true,
|
|
86
|
+
canSend: computed(() => !loading.value && !pending.value && Boolean(draft.value.trim())),
|
|
87
|
+
canStop: pending, placeholder: "Ask a question…"
|
|
88
|
+
},
|
|
89
|
+
actions: { reload, submit, stop,
|
|
90
|
+
setDraft: (value) => { draft.value = value; },
|
|
91
|
+
updateConfiguration: (value) => { configuration.value = value; }
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
onMounted(reload);
|
|
95
|
+
onBeforeUnmount(() => { disposed = true; request?.abort(); });
|
|
96
|
+
</script>
|
|
97
|
+
<style>
|
|
98
|
+
html, body, #app { margin: 0; height: 100%; }
|
|
99
|
+
.example { display: flex; flex-direction: column; height: 100dvh; max-width: 60rem; width: 100%; margin: auto; padding: 1rem; box-sizing: border-box; }
|
|
100
|
+
.example header { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
|
|
101
|
+
.example h1 { font-size: 1.4rem; }
|
|
102
|
+
.example header span { color: #666; font-size: .875rem; }
|
|
103
|
+
.example > .assistant-conversation { flex: 1; min-height: 0; }
|
|
104
|
+
</style>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Standalone conversation example
|
|
2
|
+
|
|
3
|
+
Copy this directory into a new application, then run:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install
|
|
7
|
+
npm run server
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
In a second terminal:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm run dev
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Open http://127.0.0.1:5175. `npm run build` builds the client. The default demo
|
|
17
|
+
provider streams a labelled example response and needs no credentials.
|
|
18
|
+
|
|
19
|
+
Optional server environment:
|
|
20
|
+
|
|
21
|
+
| Variable | Meaning |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `ASSISTANT_API_KEY` | Enables the existing JSKIT API-model client; server only. |
|
|
24
|
+
| `ASSISTANT_PROVIDER` | `openai`, `deepseek`, or `anthropic`; default `openai`. |
|
|
25
|
+
| `ASSISTANT_MODEL` | Model available to that provider/account. |
|
|
26
|
+
| `ASSISTANT_CONFIGURATION_MODE` | `hidden`, `readonly`, or `editable`; default `editable`. |
|
|
27
|
+
| `ASSISTANT_STORAGE_MODULE` | Path to an app module exporting async `createStorage()`. Otherwise history is held in memory and disappears on restart. |
|
|
28
|
+
|
|
29
|
+
This is a single-user local template with one conversation. A hosted app must
|
|
30
|
+
derive storage scope from authenticated actor/workspace/conversation ownership,
|
|
31
|
+
authorize every route and attachment, and retain provider-delivery evidence for
|
|
32
|
+
reconciliation after crashes. The example never executes the same message ID
|
|
33
|
+
twice. Retrying a request whose provider delivery is uncertain requires the app
|
|
34
|
+
to resolve that uncertainty first.
|
|
35
|
+
|
|
36
|
+
The server owns model choice, credentials and allowed configuration. Hidden or
|
|
37
|
+
read-only configuration always uses the server's fixed settings, regardless of
|
|
38
|
+
the submitted object. Replace the storage adapter to use your application's
|
|
39
|
+
database or files; the UI has no storage assumptions.
|
|
40
|
+
|
|
41
|
+
See the JSKIT guide **Embeddable assistant conversations** for the complete
|
|
42
|
+
client, storage and provider contracts and reusable storage-adapter checks.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createApp } from "vue";
|
|
2
|
+
import { createVuetify } from "vuetify";
|
|
3
|
+
import * as components from "vuetify/components";
|
|
4
|
+
import * as directives from "vuetify/directives";
|
|
5
|
+
import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
|
|
6
|
+
import "vuetify/styles";
|
|
7
|
+
import App from "./App.vue";
|
|
8
|
+
|
|
9
|
+
createApp(App).use(createVuetify({ components, directives, icons: { defaultSet: "mdi", aliases, sets: { mdi } } })).mount("#app");
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "assistant-conversation-example",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite --host=127.0.0.1 --port=5175 --strictPort",
|
|
7
|
+
"server": "node server.js",
|
|
8
|
+
"build": "vite build"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@jskit-ai/assistant-core": "0.1.167",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.1.189",
|
|
13
|
+
"@jskit-ai/kernel": "0.1.191",
|
|
14
|
+
"@mdi/js": "^7.4.47",
|
|
15
|
+
"vue": "^3.5.13",
|
|
16
|
+
"vuetify": "^4.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
20
|
+
"vite": "^8.2.1"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { createAiClient } from "@jskit-ai/assistant-core/server";
|
|
6
|
+
import { createConversationTranscript, createMemoryConversationStorage } from "@jskit-ai/assistant-core/server/conversation";
|
|
7
|
+
|
|
8
|
+
// This template is a single-user local application. A hosted app derives scope
|
|
9
|
+
// from authenticated actor/workspace/conversation ownership on every operation.
|
|
10
|
+
const scope = "example:local-user:conversation";
|
|
11
|
+
const storage = process.env.ASSISTANT_STORAGE_MODULE
|
|
12
|
+
? await (await import(pathToFileURL(resolve(process.env.ASSISTANT_STORAGE_MODULE)).href)).createStorage()
|
|
13
|
+
: createMemoryConversationStorage();
|
|
14
|
+
const transcript = createConversationTranscript({ storage });
|
|
15
|
+
const configurationMode = process.env.ASSISTANT_CONFIGURATION_MODE || "editable";
|
|
16
|
+
if (!["hidden", "readonly", "editable"].includes(configurationMode)) throw new Error("Invalid configuration mode.");
|
|
17
|
+
const fixedConfiguration = Object.freeze({ tone: "concise" });
|
|
18
|
+
const ai = process.env.ASSISTANT_API_KEY ? createAiClient({
|
|
19
|
+
provider: process.env.ASSISTANT_PROVIDER || "openai", apiKey: process.env.ASSISTANT_API_KEY,
|
|
20
|
+
model: process.env.ASSISTANT_MODEL
|
|
21
|
+
}) : null;
|
|
22
|
+
let active = null;
|
|
23
|
+
|
|
24
|
+
function configurationFor(input) {
|
|
25
|
+
if (configurationMode !== "editable") return fixedConfiguration;
|
|
26
|
+
if (!input || Object.keys(input).some((key) => key !== "tone") || !["concise", "detailed"].includes(input.tone)) {
|
|
27
|
+
throw new Error("Choose a supported answer style.");
|
|
28
|
+
}
|
|
29
|
+
return { tone: input.tone };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function* reply(messages, configuration, signal) {
|
|
33
|
+
if (ai) {
|
|
34
|
+
const stream = await ai.createChatCompletionStream({ signal, messages: [
|
|
35
|
+
{ role: "system", content: `Answer in a ${configuration.tone} style.` }, ...messages
|
|
36
|
+
] });
|
|
37
|
+
for await (const chunk of stream) {
|
|
38
|
+
const text = chunk.choices?.[0]?.delta?.content;
|
|
39
|
+
if (text) yield text;
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const answer = `This is the **demo provider**. Your message was:\n\n${messages.at(-1).content}\n\nThe application supplies the provider and storage; JSKIT supplies the conversation UI and transcript behavior.`;
|
|
44
|
+
for (const word of answer.match(/\S+\s*/g) || []) {
|
|
45
|
+
await delay(35, undefined, { signal });
|
|
46
|
+
yield word;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function body(request) {
|
|
51
|
+
if (!request.headers["content-type"]?.startsWith("application/json")) throw new Error("Expected JSON.");
|
|
52
|
+
let text = "";
|
|
53
|
+
for await (const chunk of request) {
|
|
54
|
+
text += chunk;
|
|
55
|
+
if (Buffer.byteLength(text) > 16_384) throw new Error("Message is too large.");
|
|
56
|
+
}
|
|
57
|
+
return JSON.parse(text);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function json(response, status, value) {
|
|
61
|
+
response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
62
|
+
response.end(JSON.stringify(value));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
createServer(async (request, response) => {
|
|
66
|
+
try {
|
|
67
|
+
if (request.method === "GET" && request.url === "/api/conversation") {
|
|
68
|
+
json(response, 200, { turns: await transcript.readConversationLog(scope), configurationMode,
|
|
69
|
+
configuration: fixedConfiguration, providerLabel: ai ? `${ai.provider} · ${ai.defaultModel}` : "Demo provider · transient storage" });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (request.method === "DELETE" && request.url === "/api/run") {
|
|
73
|
+
active?.abort();
|
|
74
|
+
json(response, 200, { stopped: true });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (request.method !== "POST" || request.url !== "/api/messages") { json(response, 404, { error: "Not found." }); return; }
|
|
78
|
+
const input = await body(request);
|
|
79
|
+
if (typeof input.text !== "string" || !input.text.trim() || input.text.length > 8000 ||
|
|
80
|
+
typeof input.messageId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(input.messageId)) throw new Error("Invalid message.");
|
|
81
|
+
const configuration = configurationFor(input.configuration);
|
|
82
|
+
if (active) { json(response, 409, { error: "The assistant is already working." }); return; }
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
active = controller;
|
|
85
|
+
let turn;
|
|
86
|
+
try { turn = await transcript.writeConversationUserMessage(scope, input); }
|
|
87
|
+
catch (failure) { active = null; throw failure; }
|
|
88
|
+
response.writeHead(200, { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store" });
|
|
89
|
+
const emit = (event) => { if (!response.destroyed) response.write(`${JSON.stringify(event)}\n`); };
|
|
90
|
+
const disconnected = () => controller.abort();
|
|
91
|
+
response.on("close", disconnected);
|
|
92
|
+
let answer = "";
|
|
93
|
+
try {
|
|
94
|
+
// A duplicate request returns saved state; it never launches the provider again.
|
|
95
|
+
if (turn) {
|
|
96
|
+
const history = await transcript.readConversationLog(scope);
|
|
97
|
+
emit({ type: "snapshot", turns: history.map((entry) => entry.turnId === turn.turnId ? { ...entry, pending: true } : entry) });
|
|
98
|
+
const messages = history.flatMap((entry) => [entry.user, entry.assistant].filter(Boolean))
|
|
99
|
+
.slice(-20).map((entry) => ({ role: entry.role, content: entry.text }));
|
|
100
|
+
for await (const text of reply(messages, configuration, controller.signal)) {
|
|
101
|
+
answer += text;
|
|
102
|
+
if (answer.length > 32_000) throw new Error("Response limit reached.");
|
|
103
|
+
emit({ type: "snapshot", turns: history.map((entry) => entry.turnId === turn.turnId
|
|
104
|
+
? { ...entry, pending: true, assistant: { role: "assistant", text: answer }, messages: [...entry.messages, { role: "assistant", text: answer }] }
|
|
105
|
+
: entry) });
|
|
106
|
+
}
|
|
107
|
+
if (!answer.trim()) throw new Error("The provider returned an empty answer.");
|
|
108
|
+
}
|
|
109
|
+
} catch (failure) {
|
|
110
|
+
emit({ type: "error", message: controller.signal.aborted ? "Stopped." : failure.message });
|
|
111
|
+
answer ||= controller.signal.aborted ? "Stopped." : "The assistant could not complete this request.";
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
if (turn) await transcript.upsertConversationAssistantMessage(scope, { turnId: turn.turnId, text: answer });
|
|
115
|
+
emit({ type: "snapshot", turns: await transcript.readConversationLog(scope) });
|
|
116
|
+
} catch (failure) { emit({ type: "error", message: `Conversation could not be saved: ${failure.message}` }); }
|
|
117
|
+
finally {
|
|
118
|
+
response.off("close", disconnected);
|
|
119
|
+
active = null;
|
|
120
|
+
response.end();
|
|
121
|
+
}
|
|
122
|
+
} catch (failure) { json(response, 400, { error: failure.message }); }
|
|
123
|
+
}).listen(3041, "127.0.0.1", () => { console.log("Assistant example API: http://127.0.0.1:3041"); });
|
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.167",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -8,19 +8,31 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
"./client": "./src/client/index.js",
|
|
10
10
|
"./server": "./src/server/index.js",
|
|
11
|
-
"./shared": "./src/shared/index.js"
|
|
11
|
+
"./shared": "./src/shared/index.js",
|
|
12
|
+
"./client/conversation": "./src/client/conversation/index.js",
|
|
13
|
+
"./server/codex-events": "./src/server/conversation/codexEvents.js",
|
|
14
|
+
"./server/codex-turn": "./src/server/conversation/codexTurn.js",
|
|
15
|
+
"./server/codex-client": "./src/server/conversation/codexClient.js",
|
|
16
|
+
"./server/opencode-client": "./src/server/conversation/openCodeClient.js",
|
|
17
|
+
"./server/conversation": "./src/server/conversation/index.js",
|
|
18
|
+
"./shared/conversation": "./src/shared/conversation/index.js",
|
|
19
|
+
"./client/conversation-submit": "./src/client/conversation/submitText.js",
|
|
20
|
+
"./client/conversation-scroll": "./src/client/conversation/useScrollToBottom.js",
|
|
21
|
+
"./testing/conversation-storage": "./src/testing/conversationStorageContract.js"
|
|
12
22
|
},
|
|
13
23
|
"dependencies": {
|
|
14
|
-
"@jskit-ai/resource-core": "0.1.
|
|
15
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
24
|
+
"@jskit-ai/resource-core": "0.1.133",
|
|
25
|
+
"@jskit-ai/resource-crud-core": "0.1.133",
|
|
16
26
|
"dompurify": "^3.4.13",
|
|
17
27
|
"json-rest-schema": "^1.0.17",
|
|
18
28
|
"marked": "^17.0.4",
|
|
19
|
-
"openai": "^6.22.0"
|
|
29
|
+
"openai": "^6.22.0",
|
|
30
|
+
"@mdi/js": "^7.4.47",
|
|
31
|
+
"ws": "^8.20.1"
|
|
20
32
|
},
|
|
21
33
|
"peerDependencies": {
|
|
22
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
23
|
-
"@jskit-ai/kernel": "0.1.
|
|
34
|
+
"@jskit-ai/http-runtime": "0.1.189",
|
|
35
|
+
"@jskit-ai/kernel": "0.1.191",
|
|
24
36
|
"vue": "^3.5.13",
|
|
25
37
|
"vuetify": "^4.0.0"
|
|
26
38
|
},
|
|
@@ -55,6 +67,46 @@
|
|
|
55
67
|
{
|
|
56
68
|
"subpath": "./shared",
|
|
57
69
|
"summary": "Exports reusable assistant validators, path helpers, query keys, and stream/settings events."
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"subpath": "./client/conversation",
|
|
73
|
+
"summary": "Embeddable conversation element, transcript, composer and progress presentation with application-owned state and actions."
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"subpath": "./server/conversation",
|
|
77
|
+
"summary": "Conversation transcript lifecycle with replaceable application storage and an explicit transient memory adapter."
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"subpath": "./shared/conversation",
|
|
81
|
+
"summary": "Turn mapping, rich-text parsing, question helpers and scroll-follow state."
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"subpath": "./server/codex-client",
|
|
85
|
+
"summary": "Codex app-server JSON-RPC client; the application supplies the endpoint, identity and request authorization."
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"subpath": "./server/codex-turn",
|
|
89
|
+
"summary": "Native Codex detached-turn observation and final-result recovery."
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"subpath": "./server/codex-events",
|
|
93
|
+
"summary": "Native Codex notification and thread-history classification."
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"subpath": "./server/opencode-client",
|
|
97
|
+
"summary": "Bounded OpenCode loopback HTTP and SSE client."
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"subpath": "./client/conversation-submit",
|
|
101
|
+
"summary": "Companion text submission through the application draft and canonical send action."
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"subpath": "./client/conversation-scroll",
|
|
105
|
+
"summary": "Layout-aware scroll-follow operations."
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"subpath": "./testing/conversation-storage",
|
|
109
|
+
"summary": "Reusable checks for application conversation storage adapters."
|
|
58
110
|
}
|
|
59
111
|
],
|
|
60
112
|
"capabilities": []
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="assistant-composer-actions">
|
|
3
|
+
<div class="assistant-composer-actions__tools"><slot /></div>
|
|
4
|
+
<div class="assistant-composer-actions__delivery">
|
|
5
|
+
<v-btn
|
|
6
|
+
v-if="state.canStop" color="error" :disabled="state.stopDisabled || state.stopPending"
|
|
7
|
+
:aria-busy="state.stopPending ? 'true' : undefined" :prepend-icon="mdiStopCircleOutline"
|
|
8
|
+
size="small" variant="tonal" @click="emit('stop')"
|
|
9
|
+
>
|
|
10
|
+
{{ state.stopPending ? 'Stopping…' : 'Stop' }}
|
|
11
|
+
</v-btn>
|
|
12
|
+
<v-btn
|
|
13
|
+
ref="sendButton" class="assistant-composer-actions__send" color="primary" :disabled="!state.canSend"
|
|
14
|
+
:aria-busy="state.pending ? 'true' : undefined" :aria-label="state.submitAriaLabel || state.submitLabel || 'Send message'"
|
|
15
|
+
:title="state.submitTitle" :prepend-icon="state.submitIcon || mdiSend" size="small" variant="flat"
|
|
16
|
+
@click="emit('submit')"
|
|
17
|
+
>
|
|
18
|
+
{{ state.submitLabel || (state.pending ? 'Sending…' : 'Send') }}
|
|
19
|
+
</v-btn>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</template>
|
|
23
|
+
<script setup>
|
|
24
|
+
import { ref } from "vue";
|
|
25
|
+
import { mdiSend, mdiStopCircleOutline } from "@mdi/js";
|
|
26
|
+
defineProps({ state: { type: Object, required: true } });
|
|
27
|
+
const emit = defineEmits(["submit", "stop"]);
|
|
28
|
+
const sendButton = ref(null);
|
|
29
|
+
defineExpose({ focus() { (sendButton.value?.$el || sendButton.value)?.focus?.(); } });
|
|
30
|
+
</script>
|
|
31
|
+
<style scoped>
|
|
32
|
+
.assistant-composer-actions, .assistant-composer-actions__tools, .assistant-composer-actions__delivery {
|
|
33
|
+
align-items: center; display: flex; gap: 0.3rem; min-width: 0;
|
|
34
|
+
}
|
|
35
|
+
.assistant-composer-actions { flex-wrap: wrap; justify-content: space-between; }
|
|
36
|
+
.assistant-composer-actions__tools { flex: 1 1 auto; flex-wrap: wrap; }
|
|
37
|
+
.assistant-composer-actions__delivery { flex: 0 0 auto; margin-left: auto; }
|
|
38
|
+
.assistant-composer-actions__send { min-width: 5.25rem; }
|
|
39
|
+
@media (pointer: coarse) {
|
|
40
|
+
.assistant-composer-actions__delivery > .v-btn { min-height: 3rem; min-width: 3rem; }
|
|
41
|
+
.assistant-composer-actions__delivery > .assistant-composer-actions__send { min-width: 5.25rem; }
|
|
42
|
+
}
|
|
43
|
+
</style>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="assistant-conversation" :aria-label="label">
|
|
3
|
+
<AssistantTranscript
|
|
4
|
+
v-bind="adapter.conversation" class="assistant-conversation__transcript"
|
|
5
|
+
@load-more="adapter.actions?.loadMore?.($event)" @reload="adapter.actions?.reload?.()"
|
|
6
|
+
@resend-turn="adapter.actions?.resend?.($event)" @cancel-turn="adapter.actions?.cancel?.($event)"
|
|
7
|
+
@edit-turn="adapter.actions?.edit?.($event)" @link-click="adapter.actions?.openLink?.($event)"
|
|
8
|
+
>
|
|
9
|
+
<template v-for="name in transcriptSlots" #[name]="scope"><slot :name="name" v-bind="scope" /></template>
|
|
10
|
+
</AssistantTranscript>
|
|
11
|
+
<slot name="hints" :adapter="adapter" />
|
|
12
|
+
<slot name="composer" :adapter="adapter">
|
|
13
|
+
<AssistantPromptInput
|
|
14
|
+
v-if="adapter.composer" ref="input" v-bind="adapter.composer"
|
|
15
|
+
:model-value="adapter.composer.draft || ''" :submit-enabled="adapter.composer.canSend"
|
|
16
|
+
:aria-label="adapter.composer.ariaLabel || 'Message AI assistant'" :rows="adapter.composer.rows || 2"
|
|
17
|
+
tab-to-submit @update:model-value="adapter.actions.setDraft($event)"
|
|
18
|
+
@submit="submit" @tab-to-submit="delivery?.focus()"
|
|
19
|
+
>
|
|
20
|
+
<template v-if="$slots['input-start']" #input-start><slot name="input-start" :adapter="adapter" /></template>
|
|
21
|
+
<template #footer>
|
|
22
|
+
<AssistantComposerActions ref="delivery" :state="adapter.composer" @submit="submit" @stop="stop">
|
|
23
|
+
<slot
|
|
24
|
+
v-if="configurationMode !== 'hidden'" name="configuration"
|
|
25
|
+
:configuration="configuration" :disabled="configurationMode !== 'editable' || adapter.composer.pending"
|
|
26
|
+
:update="updateConfiguration"
|
|
27
|
+
>
|
|
28
|
+
<v-select
|
|
29
|
+
v-for="field in configurationFields" :key="field.name"
|
|
30
|
+
:model-value="configuration[field.name]" :label="field.label" :items="field.items"
|
|
31
|
+
:disabled="configurationMode !== 'editable' || adapter.composer.pending"
|
|
32
|
+
density="compact" hide-details variant="outlined" class="assistant-conversation__setting"
|
|
33
|
+
@update:model-value="updateConfiguration({ ...configuration, [field.name]: $event })"
|
|
34
|
+
/>
|
|
35
|
+
</slot>
|
|
36
|
+
<slot name="composer-tools" :adapter="adapter" />
|
|
37
|
+
</AssistantComposerActions>
|
|
38
|
+
</template>
|
|
39
|
+
</AssistantPromptInput>
|
|
40
|
+
</slot>
|
|
41
|
+
</section>
|
|
42
|
+
</template>
|
|
43
|
+
<script setup>
|
|
44
|
+
import { computed, ref, useSlots } from "vue";
|
|
45
|
+
import AssistantTranscript from "./AssistantTranscript.vue";
|
|
46
|
+
import AssistantPromptInput from "./AssistantPromptInput.vue";
|
|
47
|
+
import AssistantComposerActions from "./AssistantComposerActions.vue";
|
|
48
|
+
const props = defineProps({
|
|
49
|
+
adapter: { type: Object, required: true },
|
|
50
|
+
label: { type: String, default: "Assistant conversation" },
|
|
51
|
+
configuration: { type: Object, default: () => ({}) },
|
|
52
|
+
configurationFields: { type: Array, default: () => [] },
|
|
53
|
+
configurationMode: { type: String, default: "hidden", validator: (value) => ["hidden", "readonly", "editable"].includes(value) }
|
|
54
|
+
});
|
|
55
|
+
const slots = useSlots();
|
|
56
|
+
const transcriptSlots = computed(() => ["attachments", "system-message", "message-actions"].filter((name) => slots[name]));
|
|
57
|
+
const input = ref(null);
|
|
58
|
+
const delivery = ref(null);
|
|
59
|
+
function submit() {
|
|
60
|
+
if (!props.adapter.composer?.canSend) return;
|
|
61
|
+
return props.adapter.actions.submit({ configuration: props.configuration });
|
|
62
|
+
}
|
|
63
|
+
function stop() {
|
|
64
|
+
const state = props.adapter.composer;
|
|
65
|
+
if (!state?.canStop || state.stopDisabled || state.stopPending) return;
|
|
66
|
+
return props.adapter.actions.stop();
|
|
67
|
+
}
|
|
68
|
+
function updateConfiguration(value) {
|
|
69
|
+
if (props.configurationMode !== "editable" || props.adapter.composer?.pending) return;
|
|
70
|
+
return props.adapter.actions?.updateConfiguration?.(value);
|
|
71
|
+
}
|
|
72
|
+
defineExpose({ focus: () => input.value?.focus(), submit, stop });
|
|
73
|
+
</script>
|
|
74
|
+
<style scoped>
|
|
75
|
+
.assistant-conversation { display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; }
|
|
76
|
+
.assistant-conversation__transcript { flex: 1 1 auto; min-height: 0; }
|
|
77
|
+
.assistant-conversation__setting { min-width: 8rem; max-width: 16rem; }
|
|
78
|
+
</style>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="assistant-progress">
|
|
3
|
+
<button
|
|
4
|
+
v-if="messages.length > previewCount"
|
|
5
|
+
:aria-expanded="expanded"
|
|
6
|
+
class="assistant-progress__toggle"
|
|
7
|
+
type="button"
|
|
8
|
+
@click="expanded = !expanded"
|
|
9
|
+
>
|
|
10
|
+
{{ toggleLabel }}
|
|
11
|
+
</button>
|
|
12
|
+
<div
|
|
13
|
+
v-for="(message, index) in visibleMessages"
|
|
14
|
+
:key="message.key || message.id || index"
|
|
15
|
+
class="assistant-progress__message"
|
|
16
|
+
>
|
|
17
|
+
{{ message.text }}
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
</template>
|
|
21
|
+
|
|
22
|
+
<script setup>
|
|
23
|
+
import { computed, ref } from "vue";
|
|
24
|
+
|
|
25
|
+
const props = defineProps({
|
|
26
|
+
messages: { type: Array, default: () => [] },
|
|
27
|
+
pending: { type: Boolean, default: false },
|
|
28
|
+
previewLimit: { type: Number, default: 2 }
|
|
29
|
+
});
|
|
30
|
+
const expanded = ref(false);
|
|
31
|
+
const previewCount = computed(() => props.pending ? props.previewLimit : 0);
|
|
32
|
+
const visibleMessages = computed(() => {
|
|
33
|
+
if (expanded.value) {
|
|
34
|
+
return props.messages;
|
|
35
|
+
}
|
|
36
|
+
return previewCount.value > 0 ? props.messages.slice(-previewCount.value) : [];
|
|
37
|
+
});
|
|
38
|
+
const toggleLabel = computed(() => {
|
|
39
|
+
if (expanded.value) {
|
|
40
|
+
return previewCount.value > 0
|
|
41
|
+
? `Show latest ${previewCount.value} progress updates`
|
|
42
|
+
: "Hide progress updates";
|
|
43
|
+
}
|
|
44
|
+
return `Show all ${props.messages.length} progress ${props.messages.length === 1 ? "update" : "updates"}`;
|
|
45
|
+
});
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<style scoped>
|
|
49
|
+
.assistant-progress {
|
|
50
|
+
color: rgba(var(--v-theme-on-surface), 0.58);
|
|
51
|
+
display: grid;
|
|
52
|
+
font-size: 0.78rem;
|
|
53
|
+
gap: 0.18rem;
|
|
54
|
+
line-height: 1.42;
|
|
55
|
+
min-width: 0;
|
|
56
|
+
overflow-wrap: anywhere;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.assistant-progress__message {
|
|
60
|
+
white-space: pre-wrap;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.assistant-progress__toggle {
|
|
64
|
+
background: transparent;
|
|
65
|
+
border: 0;
|
|
66
|
+
color: rgb(var(--v-theme-primary));
|
|
67
|
+
cursor: pointer;
|
|
68
|
+
font: inherit;
|
|
69
|
+
justify-self: start;
|
|
70
|
+
padding: 0.12rem 0;
|
|
71
|
+
text-align: left;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.assistant-progress__toggle:hover {
|
|
75
|
+
text-decoration: underline;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.assistant-progress__toggle:focus-visible {
|
|
79
|
+
outline: 2px solid rgb(var(--v-theme-primary));
|
|
80
|
+
outline-offset: 2px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@media (pointer: coarse) {
|
|
84
|
+
.assistant-progress__toggle {
|
|
85
|
+
min-height: 3rem;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
</style>
|