@antelopejs/dms-ai 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/constants/frontend-module.d.ts +2 -0
- package/dist/constants/frontend-module.js +6 -1
- package/dist/constants/frontend-module.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/frontend-vue/app/components/ActivityDetail.vue +11 -13
- package/frontend-vue/app/components/ChangesView.vue +56 -49
- package/frontend-vue/app/plugins/ai.client.ts +16 -6
- package/frontend-vue/app/runtime/assistant-activity.ts +38 -21
- package/frontend-vue/app/runtime/sidecar-status.ts +78 -56
- package/frontend-vue/app/views/ChangeSetPanel.vue +13 -18
- package/frontend-vue/app/views/UnavailableState.vue +1 -1
- package/frontend-vue/app/views/composables/useAiData.ts +116 -0
- package/frontend-vue/app/views/composables/useAssistant.ts +8 -2
- package/frontend-vue/app/views/composables/useChangeSetActions.ts +7 -6
- package/frontend-vue/app/views/utils/change-sets.ts +0 -7
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/assistant-activity.test.ts +69 -0
- package/frontend-vue/tests/sidecar-guest.test.ts +1 -1
- package/frontend-vue/tests/sidecar-status.test.ts +93 -15
- package/frontend-vue/tests/support/dms-frontend-module.ts +9 -0
- package/frontend-vue/tests/views/api-data-reads.test.ts +445 -0
- package/frontend-vue/tests/views/use-assistant.test.ts +53 -0
- package/frontend-vue/vitest.config.ts +10 -0
- package/package.json +2 -2
- package/frontend-vue/app/views/composables/useAiRequest.ts +0 -89
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Codex is selectable once its CLI is installed.
|
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
19
19
|
|
|
20
|
-
Add the module to a project that already uses `@antelopejs/dms
|
|
20
|
+
Add the module to a project that already uses `@antelopejs/dms` 0.9 (with `@antelopejs/interface-dms` 0.7 and `@antelopejs/dms-frontend` 0.6: the views read their data with its `useApiData`, and the module's routes under `/ai/**` reach the backend through the frontend server, which the module declares as a backend prefix with the session):
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
23
|
ajs project modules add @antelopejs/dms-ai
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { FrontendBackendProxy } from "@antelopejs/interface-dms/page";
|
|
1
2
|
export declare const FRONTEND_MODULE_NAME = "@antelopejs/dms-ai-frontend-vue";
|
|
2
3
|
export declare const FRONTEND_MODULE_DIR = "../frontend-vue";
|
|
3
4
|
export declare const FRONTEND_MODULE_PRIORITY = 100;
|
|
5
|
+
export declare const FRONTEND_BACKEND_PROXY: FrontendBackendProxy;
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.FRONTEND_MODULE_PRIORITY = exports.FRONTEND_MODULE_DIR = exports.FRONTEND_MODULE_NAME = void 0;
|
|
3
|
+
exports.FRONTEND_BACKEND_PROXY = exports.FRONTEND_MODULE_PRIORITY = exports.FRONTEND_MODULE_DIR = exports.FRONTEND_MODULE_NAME = void 0;
|
|
4
|
+
const module_1 = require("./module");
|
|
4
5
|
exports.FRONTEND_MODULE_NAME = "@antelopejs/dms-ai-frontend-vue";
|
|
5
6
|
exports.FRONTEND_MODULE_DIR = "../frontend-vue";
|
|
6
7
|
exports.FRONTEND_MODULE_PRIORITY = 100;
|
|
8
|
+
exports.FRONTEND_BACKEND_PROXY = {
|
|
9
|
+
path: `${module_1.ROUTE_PREFIX}/**`,
|
|
10
|
+
auth: "session",
|
|
11
|
+
};
|
|
7
12
|
//# sourceMappingURL=frontend-module.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"frontend-module.js","sourceRoot":"","sources":["../../src/constants/frontend-module.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"frontend-module.js","sourceRoot":"","sources":["../../src/constants/frontend-module.ts"],"names":[],"mappings":";;;AACA,qCAAwC;AAE3B,QAAA,oBAAoB,GAAG,iCAAiC,CAAC;AACzD,QAAA,mBAAmB,GAAG,iBAAiB,CAAC;AACxC,QAAA,wBAAwB,GAAG,GAAG,CAAC;AAO/B,QAAA,sBAAsB,GAAyB;IAC1D,IAAI,EAAE,GAAG,qBAAY,KAAK;IAC1B,IAAI,EAAE,SAAS;CAChB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -30,6 +30,7 @@ async function start() {
|
|
|
30
30
|
sourcePath: node_path_1.default.join(__dirname, frontend_module_1.FRONTEND_MODULE_DIR),
|
|
31
31
|
renderer: { name: "vue", version: "3" },
|
|
32
32
|
priority: frontend_module_1.FRONTEND_MODULE_PRIORITY,
|
|
33
|
+
backendProxies: [frontend_module_1.FRONTEND_BACKEND_PROXY],
|
|
33
34
|
});
|
|
34
35
|
const [moduleRoots, skillDirs, builderEnabled] = await Promise.all([
|
|
35
36
|
(0, module_roots_1.collectModuleRoots)(),
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAoBA,8BAEC;AAED,sBA0BC;AAOD,0BAEC;AA3DD,0DAA6B;AAC7B,gEAA6D;AAC7D,yDAAmE;AACnE,iDAAwD;AACxD,kDAAsD;AACtD,qCAA6D;AAC7D,iEAKqC;AACrC,iDAAyD;AACzD,2DAA8D;AAC9D,6DAAgE;AAChE,6DAAyD;AACzD,qDAAuD;AACvD,mBAAiB;AACjB,oBAAkB;AAElB,SAAgB,SAAS,CAAC,MAAe;IACvC,IAAA,kBAAS,EAAC,IAAA,oBAAW,EAAC,MAAM,CAAC,CAAC,CAAC;AACjC,CAAC;AAEM,KAAK,UAAU,KAAK;IACzB,IAAA,4BAAe,GAAE,CAAC;IAClB,MAAM,IAAA,wBAAiB,EAAC;QACtB,IAAI,EAAE,sCAAoB;QAC1B,UAAU,EAAE,mBAAI,CAAC,IAAI,CAAC,SAAS,EAAE,qCAAmB,CAAC;QACrD,QAAQ,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE;QACvC,QAAQ,EAAE,0CAAwB;QAClC,cAAc,EAAE,CAAC,wCAAsB,CAAC;KACzC,CAAC,CAAC;IAIH,MAAM,CAAC,WAAW,EAAE,SAAS,EAAE,cAAc,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;QACjE,IAAA,iCAAkB,GAAE;QACpB,IAAA,mCAAmB,GAAE;QACrB,IAAA,6BAAkB,GAAE;KACrB,CAAC,CAAC;IACH,KAAK,IAAA,4BAAY,EAAC;QAChB,eAAe,EAAE,OAAO,CAAC,GAAG,EAAE;QAC9B,GAAG,IAAA,kBAAS,GAAE;QACd,WAAW;QACX,SAAS;QACT,cAAc;KACf,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;QACf,iBAAO,CAAC,KAAK,CAAC,GAAG,4BAAkB,gBAAgB,EAAE,GAAG,CAAC,CAAC;IAC5D,CAAC,CAAC,CAAC;AACL,CAAC;AAOD,SAAgB,OAAO;IACrB,IAAA,0BAAe,GAAE,CAAC;AACpB,CAAC"}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
3
|
import DecisionLabel from "../views/DecisionLabel.vue";
|
|
4
4
|
import DiffView from "../views/DiffView.vue";
|
|
5
5
|
import RowStepper from "../views/RowStepper.vue";
|
|
6
6
|
import ResultPill from "../views/ResultPill.vue";
|
|
7
7
|
import UnavailableState from "../views/UnavailableState.vue";
|
|
8
|
-
import {
|
|
8
|
+
import { useAiData } from "../views/composables/useAiData";
|
|
9
9
|
import { useAssistant } from "../views/composables/useAssistant";
|
|
10
10
|
import { useViewFormat } from "../views/composables/useViewFormat";
|
|
11
11
|
import { AI_PAGES, AI_ROUTES, CHANGE_SET_QUERY_KEY } from "../views/constants";
|
|
@@ -30,29 +30,27 @@ interface Props {
|
|
|
30
30
|
const props = defineProps<Props>();
|
|
31
31
|
|
|
32
32
|
const { t } = useI18n();
|
|
33
|
-
const { $authFetch } = useAuthFetch();
|
|
34
33
|
const { moment, toolLabel, clock } = useViewFormat();
|
|
35
34
|
const assistant = useAssistant();
|
|
36
|
-
const detail = useAiRequest<ActivityDetailPayload>();
|
|
37
35
|
const isRawShown = ref(false);
|
|
38
36
|
|
|
39
37
|
const OUTPUT_MAX_LINES = 14;
|
|
40
38
|
const RAW_MAX_LINES = 20;
|
|
41
39
|
|
|
42
40
|
const rowId = computed(() => props.rowData?.id ?? props.rowData?._id);
|
|
41
|
+
// Read with the page: a deep link (`?record=<id>`) renders the entry on the
|
|
42
|
+
// server and hands it over. The drawer opened again shows the entry read
|
|
43
|
+
// before and reads it again, as a pending call may have ended since.
|
|
44
|
+
const detail = useAiData<ActivityDetailPayload>(() =>
|
|
45
|
+
rowId.value
|
|
46
|
+
? `${AI_ROUTES.activity}/${encodeURIComponent(rowId.value)}`
|
|
47
|
+
: null,
|
|
48
|
+
);
|
|
43
49
|
|
|
44
50
|
function load(): void {
|
|
45
|
-
|
|
46
|
-
if (!id) return;
|
|
47
|
-
void detail.run(() =>
|
|
48
|
-
$authFetch<ActivityDetailPayload>(
|
|
49
|
-
`${AI_ROUTES.activity}/${encodeURIComponent(id)}`,
|
|
50
|
-
),
|
|
51
|
-
);
|
|
51
|
+
void detail.refresh();
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
watch(rowId, load, { immediate: true });
|
|
55
|
-
|
|
56
54
|
const row = computed(() => props.rowData);
|
|
57
55
|
const eyebrow = computed(() => {
|
|
58
56
|
const current = row.value;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed,
|
|
2
|
+
import { computed, ref, watch } from "vue";
|
|
3
3
|
import { useDmsRoute } from "#dms/frontend-module";
|
|
4
4
|
import ChangeSetPanel from "../views/ChangeSetPanel.vue";
|
|
5
5
|
import UnavailableState from "../views/UnavailableState.vue";
|
|
6
|
-
import {
|
|
6
|
+
import { useAiData, useAiPagedData } from "../views/composables/useAiData";
|
|
7
7
|
import { useAssistant } from "../views/composables/useAssistant";
|
|
8
8
|
import { useViewFormat } from "../views/composables/useViewFormat";
|
|
9
9
|
import {
|
|
@@ -20,34 +20,18 @@ import type {
|
|
|
20
20
|
import {
|
|
21
21
|
changeSetIcon,
|
|
22
22
|
matchesSearch,
|
|
23
|
-
readSelectedSet,
|
|
24
23
|
writeSelectedSet,
|
|
25
24
|
} from "../views/utils/change-sets";
|
|
26
25
|
|
|
27
26
|
type ScopeFilter = ScopeId | "all";
|
|
28
27
|
|
|
29
28
|
const { t } = useI18n();
|
|
30
|
-
const { $authFetch } = useAuthFetch();
|
|
31
29
|
const { moment } = useViewFormat();
|
|
32
30
|
const assistant = useAssistant();
|
|
33
31
|
const route = useDmsRoute();
|
|
34
32
|
|
|
35
|
-
const list = useAiRequest<ListPayload<ChangeSetSummary>>();
|
|
36
|
-
const status = useAiRequest<AssistantStatus>();
|
|
37
|
-
const changeSets = ref<ChangeSetSummary[]>([]);
|
|
38
|
-
const total = ref(0);
|
|
39
33
|
const scope = ref<ScopeFilter>("all");
|
|
40
34
|
const search = ref("");
|
|
41
|
-
const selectedId = ref<string | undefined>(readSelectedSet());
|
|
42
|
-
// The list loads on the client only: until its first answer the server and
|
|
43
|
-
// the hydrating client both draw the skeleton, never a premature empty state.
|
|
44
|
-
const hasLoaded = ref(false);
|
|
45
|
-
|
|
46
|
-
const SCOPE_ITEMS = computed(() => [
|
|
47
|
-
{ value: "all", label: t("dms_ai.views.changes.filter_all") },
|
|
48
|
-
{ value: "safe", label: t("dms_ai.views.changes.filter_safe") },
|
|
49
|
-
{ value: "vibe", label: t("dms_ai.views.changes.filter_code") },
|
|
50
|
-
]);
|
|
51
35
|
|
|
52
36
|
function listQuery(offset: number): Record<string, string | number> {
|
|
53
37
|
const query: Record<string, string | number> = {
|
|
@@ -58,46 +42,69 @@ function listQuery(offset: number): Record<string, string | number> {
|
|
|
58
42
|
return { ...query, scope: scope.value, filter_scope: `is:${scope.value}` };
|
|
59
43
|
}
|
|
60
44
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
45
|
+
type ChangesPage = ListPayload<ChangeSetSummary>;
|
|
46
|
+
|
|
47
|
+
/** The sets the pages hold, in order. */
|
|
48
|
+
function loadedCount(pages: ChangesPage[]): number {
|
|
49
|
+
return pages.reduce((count, page) => count + page.results.length, 0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The status and the list's first page are read with the page: a server
|
|
53
|
+
// render shows them, and the selected set's panel, at once. "Load more"
|
|
54
|
+
// appends a page under the scope's key; the page's refreshes read every
|
|
55
|
+
// page loaded again.
|
|
56
|
+
const status = useAiData<AssistantStatus>(AI_ROUTES.status);
|
|
57
|
+
const list = useAiPagedData<ChangesPage, number>(
|
|
58
|
+
() => `dms-ai:changes:${scope.value}`,
|
|
59
|
+
({ $authFetch, cursor, signal }) =>
|
|
60
|
+
$authFetch<ChangesPage>(AI_ROUTES.changes, {
|
|
61
|
+
query: listQuery(cursor ?? 0),
|
|
62
|
+
signal,
|
|
65
63
|
}),
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
{
|
|
65
|
+
nextCursor: (page, pages) => {
|
|
66
|
+
const loaded = loadedCount(pages);
|
|
67
|
+
return page.results.length > 0 && loaded < page.total ? loaded : null;
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
const changeSets = computed(() =>
|
|
72
|
+
list.pages.value.flatMap((page) => page.results),
|
|
73
|
+
);
|
|
74
|
+
const total = computed(() => list.pages.value.at(-1)?.total ?? 0);
|
|
75
|
+
|
|
76
|
+
function selectedFromRoute(): string | undefined {
|
|
77
|
+
const id = route.query[CHANGE_SET_QUERY_KEY];
|
|
78
|
+
return typeof id === "string" && id ? id : undefined;
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
/** The set picked here or named by the URL (`?set=`); the first one until then. */
|
|
82
|
+
const pickedId = ref<string | undefined>(selectedFromRoute());
|
|
83
|
+
const selectedId = computed(() => pickedId.value ?? changeSets.value[0]?.id);
|
|
84
|
+
|
|
85
|
+
const SCOPE_ITEMS = computed(() => [
|
|
86
|
+
{ value: "all", label: t("dms_ai.views.changes.filter_all") },
|
|
87
|
+
{ value: "safe", label: t("dms_ai.views.changes.filter_safe") },
|
|
88
|
+
{ value: "vibe", label: t("dms_ai.views.changes.filter_code") },
|
|
89
|
+
]);
|
|
90
|
+
|
|
75
91
|
function reload(): Promise<void> {
|
|
76
|
-
return
|
|
92
|
+
return list.refresh();
|
|
77
93
|
}
|
|
78
94
|
|
|
95
|
+
/** Appends the next page; a failure keeps the sets listed and the button. */
|
|
79
96
|
function loadMore(): Promise<void> {
|
|
80
|
-
return
|
|
97
|
+
return list.loadMore();
|
|
81
98
|
}
|
|
82
99
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
void reload();
|
|
100
|
+
watch(selectedFromRoute, (id) => {
|
|
101
|
+
if (id) pickedId.value = id;
|
|
86
102
|
});
|
|
87
103
|
|
|
88
|
-
watch(scope, reload);
|
|
89
|
-
|
|
90
|
-
watch(
|
|
91
|
-
() => route.query[CHANGE_SET_QUERY_KEY],
|
|
92
|
-
(id) => {
|
|
93
|
-
if (id) selectedId.value = id;
|
|
94
|
-
},
|
|
95
|
-
);
|
|
96
|
-
|
|
97
104
|
function select(id: string | number | undefined): void {
|
|
98
105
|
if (id === undefined) return;
|
|
99
|
-
|
|
100
|
-
writeSelectedSet(
|
|
106
|
+
pickedId.value = String(id);
|
|
107
|
+
writeSelectedSet(pickedId.value);
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
function sublabel(set: ChangeSetSummary): string {
|
|
@@ -134,9 +141,10 @@ const isCheckpointOff = computed(
|
|
|
134
141
|
);
|
|
135
142
|
const isFirstLoad = computed(
|
|
136
143
|
() =>
|
|
137
|
-
|
|
144
|
+
changeSets.value.length === 0 &&
|
|
145
|
+
(list.pending.value || list.status.value === "idle"),
|
|
138
146
|
);
|
|
139
|
-
const hasMore = computed(() =>
|
|
147
|
+
const hasMore = computed(() => list.hasMore.value);
|
|
140
148
|
const listLabel = computed(() =>
|
|
141
149
|
t("dms_ai.views.changes.list_label", { count: total.value }),
|
|
142
150
|
);
|
|
@@ -230,7 +238,6 @@ const emptyActions = computed(() =>
|
|
|
230
238
|
v-if="selectedId"
|
|
231
239
|
:key="selectedId"
|
|
232
240
|
:change-set-id="selectedId"
|
|
233
|
-
@changed="reload"
|
|
234
241
|
/>
|
|
235
242
|
</DmsMasterDetail>
|
|
236
243
|
<div v-if="hasMore" class="flex lg:w-[380px] lg:justify-center">
|
|
@@ -239,7 +246,7 @@ const emptyActions = computed(() =>
|
|
|
239
246
|
variant="outline"
|
|
240
247
|
size="sm"
|
|
241
248
|
icon="i-ph-caret-down"
|
|
242
|
-
:loading="list.
|
|
249
|
+
:loading="list.loadingMore.value"
|
|
243
250
|
:label="t('dms_ai.views.common.load_more')"
|
|
244
251
|
@click="loadMore"
|
|
245
252
|
/>
|
|
@@ -70,6 +70,7 @@ import { runWhenLoggedIn } from "../runtime/session-gate";
|
|
|
70
70
|
import { installToggleShortcut } from "../runtime/shortcuts";
|
|
71
71
|
import {
|
|
72
72
|
createSidecarStatusController,
|
|
73
|
+
type SidecarInfoPayload,
|
|
73
74
|
type SidecarStatus,
|
|
74
75
|
type SidecarStatusController,
|
|
75
76
|
} from "../runtime/sidecar-status";
|
|
@@ -395,8 +396,13 @@ function createRuntime(
|
|
|
395
396
|
const holds = createStreamHolds();
|
|
396
397
|
const sidecarStatus = followSidecarStatus(controller, client);
|
|
397
398
|
const activity = createAssistantActivity({
|
|
398
|
-
|
|
399
|
-
|
|
399
|
+
// The application's read: polled for as long as the app runs, while
|
|
400
|
+
// the interval says so.
|
|
401
|
+
readStatus: (interval) =>
|
|
402
|
+
useApiData<AssistantStatusPayload>(STATUS_PATH, {
|
|
403
|
+
refreshInterval: interval,
|
|
404
|
+
refreshOnPageRefresh: false,
|
|
405
|
+
}),
|
|
400
406
|
chat: chat.transport,
|
|
401
407
|
shouldPoll: () =>
|
|
402
408
|
!panel.isOpen.value ||
|
|
@@ -413,7 +419,6 @@ function createRuntime(
|
|
|
413
419
|
activity,
|
|
414
420
|
currentPage,
|
|
415
421
|
});
|
|
416
|
-
void activity.refresh();
|
|
417
422
|
const stops = [
|
|
418
423
|
installToggleShortcut({ onToggle: panel.toggle }),
|
|
419
424
|
followPanel({ panel, activity, holds }, client),
|
|
@@ -462,8 +467,13 @@ async function startAssistant(
|
|
|
462
467
|
devReload: useDevReload(),
|
|
463
468
|
router: useDmsRouter(),
|
|
464
469
|
};
|
|
465
|
-
|
|
466
|
-
|
|
470
|
+
// Read once the gate opens, then polled while the sidecar comes back.
|
|
471
|
+
const controller = createSidecarStatusController((interval) =>
|
|
472
|
+
useApiData<SidecarInfoPayload>(SIDECAR_INFO_PATH, {
|
|
473
|
+
immediate: false,
|
|
474
|
+
refreshInterval: interval,
|
|
475
|
+
refreshOnPageRefresh: false,
|
|
476
|
+
}),
|
|
467
477
|
);
|
|
468
478
|
if (!(await controller.init())) {
|
|
469
479
|
context.runWithContext(() => unregisterSidePanel(SIDE_PANEL_ID));
|
|
@@ -496,7 +506,7 @@ export default defineDmsPlugin((context) => {
|
|
|
496
506
|
const appMounted = new Promise<void>((resolve) =>
|
|
497
507
|
context.hook("app:mounted", resolve),
|
|
498
508
|
);
|
|
499
|
-
const { loggedIn } =
|
|
509
|
+
const { loggedIn } = useDmsUserSession();
|
|
500
510
|
const stopGate = runWhenLoggedIn(
|
|
501
511
|
() => loggedIn.value,
|
|
502
512
|
() =>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed, type ComputedRef, type Ref, ref } from "vue";
|
|
1
|
+
import { computed, type ComputedRef, type Ref, ref, watch } from "vue";
|
|
2
2
|
import type { ChatTransport } from "./chat-transport";
|
|
3
3
|
import { STATUS_POLL_MS } from "./constants";
|
|
4
4
|
import { isTypedMessage } from "./typed-message";
|
|
@@ -35,10 +35,24 @@ export interface AssistantActivity {
|
|
|
35
35
|
stop: () => void;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** The read of the backend's status: `useApiData`, polled every `interval`. */
|
|
39
|
+
export interface AssistantStatusRead {
|
|
40
|
+
data: Readonly<Ref<AssistantStatusPayload | null>>;
|
|
41
|
+
refresh: () => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Opens the status read, polled every `interval` ms while it is above 0 and
|
|
46
|
+
* not polled while it is 0.
|
|
47
|
+
*/
|
|
48
|
+
export type OpenAssistantStatusRead = (
|
|
49
|
+
interval: () => number,
|
|
50
|
+
) => AssistantStatusRead;
|
|
51
|
+
|
|
38
52
|
export interface AssistantActivityOptions {
|
|
39
|
-
|
|
53
|
+
readStatus: OpenAssistantStatusRead;
|
|
40
54
|
chat: ChatTransport;
|
|
41
|
-
/** Whether polling is useful now: the
|
|
55
|
+
/** Whether polling is useful now: the panel is closed, or its stream is down. */
|
|
42
56
|
shouldPoll: () => boolean;
|
|
43
57
|
}
|
|
44
58
|
|
|
@@ -55,8 +69,9 @@ function sum(
|
|
|
55
69
|
|
|
56
70
|
/**
|
|
57
71
|
* What the tab knows of the assistant's work across chats, from the backend's
|
|
58
|
-
* status (polled while the panel is closed
|
|
59
|
-
*
|
|
72
|
+
* status (polled while the panel is closed or its stream down: the polling
|
|
73
|
+
* follows `shouldPoll`) and from the conversation lists the sidecar
|
|
74
|
+
* broadcasts while the stream is open.
|
|
60
75
|
*/
|
|
61
76
|
export function createAssistantActivity(
|
|
62
77
|
options: AssistantActivityOptions,
|
|
@@ -65,16 +80,21 @@ export function createAssistantActivity(
|
|
|
65
80
|
const workingConversations = ref(0);
|
|
66
81
|
const lastError = ref<string | null>(null);
|
|
67
82
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
const isStopped = ref(false);
|
|
84
|
+
const status = options.readStatus(() =>
|
|
85
|
+
!isStopped.value && options.shouldPoll() ? STATUS_POLL_MS : 0,
|
|
86
|
+
);
|
|
87
|
+
// A failed read keeps the last answer: the counts stay as they were.
|
|
88
|
+
const stopFollowing = watch(
|
|
89
|
+
status.data,
|
|
90
|
+
(payload) => {
|
|
91
|
+
if (!payload) return;
|
|
92
|
+
pendingApprovals.value = payload.pendingApprovals ?? 0;
|
|
93
|
+
workingConversations.value = payload.workingConversations ?? 0;
|
|
94
|
+
lastError.value = payload.lastError ?? null;
|
|
95
|
+
},
|
|
96
|
+
{ immediate: true },
|
|
97
|
+
);
|
|
78
98
|
|
|
79
99
|
const stopListening = options.chat.onMessage((msg) => {
|
|
80
100
|
if (!isListFrame(msg)) return;
|
|
@@ -85,10 +105,6 @@ export function createAssistantActivity(
|
|
|
85
105
|
);
|
|
86
106
|
});
|
|
87
107
|
|
|
88
|
-
const timer = setInterval(() => {
|
|
89
|
-
if (options.shouldPoll()) void refresh();
|
|
90
|
-
}, STATUS_POLL_MS);
|
|
91
|
-
|
|
92
108
|
return {
|
|
93
109
|
pendingApprovals,
|
|
94
110
|
workingConversations,
|
|
@@ -96,9 +112,10 @@ export function createAssistantActivity(
|
|
|
96
112
|
() => pendingApprovals.value > 0 || workingConversations.value > 0,
|
|
97
113
|
),
|
|
98
114
|
lastError,
|
|
99
|
-
refresh,
|
|
115
|
+
refresh: () => status.refresh(),
|
|
100
116
|
stop: () => {
|
|
101
|
-
|
|
117
|
+
isStopped.value = true;
|
|
118
|
+
stopFollowing();
|
|
102
119
|
stopListening();
|
|
103
120
|
},
|
|
104
121
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Ref, readonly, ref, watch } from "vue";
|
|
1
2
|
import { createBackoff, nextDelay, resetBackoff } from "./backoff";
|
|
2
3
|
import {
|
|
3
4
|
SIDECAR_POLL_DELAYS_MS,
|
|
@@ -38,31 +39,52 @@ export interface SidecarStatusController {
|
|
|
38
39
|
dispose: () => void;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The read of the sidecar's info the controller follows: `useApiData` in the
|
|
44
|
+
* dashboard, opened with `immediate: false` and polled every `interval`.
|
|
45
|
+
*/
|
|
46
|
+
/** The backend's sidecar info, as far as the tab reads it. */
|
|
47
|
+
export type SidecarInfoPayload = Partial<SidecarInfo>;
|
|
48
|
+
|
|
49
|
+
export interface SidecarInfoRead {
|
|
50
|
+
data: Readonly<Ref<SidecarInfoPayload | null>>;
|
|
51
|
+
error: Readonly<Ref<unknown>>;
|
|
52
|
+
pending: Readonly<Ref<boolean>>;
|
|
53
|
+
/** The first read. */
|
|
54
|
+
execute: () => Promise<void>;
|
|
55
|
+
/** Reads the info again now. */
|
|
56
|
+
refresh: () => Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Opens the info read, polled every `interval` ms while it is above 0: the
|
|
61
|
+
* controller raises it while the sidecar comes back, and sets it to 0 once
|
|
62
|
+
* the sidecar answers or gives up.
|
|
63
|
+
*/
|
|
64
|
+
export type OpenSidecarInfoRead = (
|
|
65
|
+
interval: Readonly<Ref<number>>,
|
|
66
|
+
) => SidecarInfoRead;
|
|
67
|
+
|
|
68
|
+
/** The info of the read's last answer, or null when it failed. */
|
|
69
|
+
function readInfo(read: SidecarInfoRead): SidecarInfo | null {
|
|
70
|
+
const body = read.data.value;
|
|
71
|
+
if (read.error.value || !body) return null;
|
|
72
|
+
return {
|
|
73
|
+
isRunning: body.isRunning === true,
|
|
74
|
+
hasGivenUp: body.hasGivenUp === true,
|
|
75
|
+
disabled: body.disabled === true,
|
|
76
|
+
};
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
export function createSidecarStatusController(
|
|
59
|
-
|
|
80
|
+
openRead: OpenSidecarInfoRead,
|
|
60
81
|
): SidecarStatusController {
|
|
61
82
|
const backoff = createBackoff();
|
|
62
83
|
const subscribers = new Set<(status: SidecarStatus) => void>();
|
|
84
|
+
const pollInterval = ref(0);
|
|
63
85
|
let status: SidecarStatus = SIDECAR_STATUS_CONNECTING;
|
|
64
|
-
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
65
86
|
let disposed = false;
|
|
87
|
+
const read = openRead(readonly(pollInterval));
|
|
66
88
|
|
|
67
89
|
function notify(): void {
|
|
68
90
|
for (const cb of subscribers) cb(status);
|
|
@@ -74,18 +96,8 @@ export function createSidecarStatusController(
|
|
|
74
96
|
notify();
|
|
75
97
|
}
|
|
76
98
|
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
const delay = nextDelay(backoff, SIDECAR_POLL_DELAYS_MS);
|
|
80
|
-
pollTimer = setTimeout(() => {
|
|
81
|
-
pollTimer = null;
|
|
82
|
-
void poll();
|
|
83
|
-
}, delay);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function poll(): Promise<void> {
|
|
87
|
-
if (disposed) return;
|
|
88
|
-
handleInfo(await fetchInfo(fetchDetails));
|
|
99
|
+
function keepPolling(): void {
|
|
100
|
+
pollInterval.value = nextDelay(backoff, SIDECAR_POLL_DELAYS_MS);
|
|
89
101
|
}
|
|
90
102
|
|
|
91
103
|
function handleInfo(info: SidecarInfo | null): void {
|
|
@@ -93,31 +105,56 @@ export function createSidecarStatusController(
|
|
|
93
105
|
// Probe itself failed (backend momentarily unreachable): keep waiting.
|
|
94
106
|
if (info === null) {
|
|
95
107
|
setStatus(SIDECAR_STATUS_REVIVING);
|
|
96
|
-
|
|
108
|
+
keepPolling();
|
|
97
109
|
return;
|
|
98
110
|
}
|
|
99
111
|
if (info.hasGivenUp) {
|
|
112
|
+
pollInterval.value = 0;
|
|
100
113
|
setStatus(SIDECAR_STATUS_UNAVAILABLE);
|
|
101
114
|
return;
|
|
102
115
|
}
|
|
103
116
|
if (info.isRunning) {
|
|
104
117
|
resetBackoff(backoff);
|
|
118
|
+
pollInterval.value = 0;
|
|
105
119
|
setStatus(SIDECAR_STATUS_CONNECTED);
|
|
106
120
|
return;
|
|
107
121
|
}
|
|
108
122
|
// Down but not terminal — the probe already asked the backend to respawn;
|
|
109
123
|
// keep polling until it reports the sidecar running.
|
|
110
124
|
setStatus(SIDECAR_STATUS_REVIVING);
|
|
111
|
-
|
|
125
|
+
keepPolling();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Each answer, a probe's or the polling's, moves the status.
|
|
129
|
+
const stopFollowing = watch(
|
|
130
|
+
read.pending,
|
|
131
|
+
(isPending, wasPending) => {
|
|
132
|
+
if (wasPending && !isPending) handleInfo(readInfo(read));
|
|
133
|
+
},
|
|
134
|
+
{ flush: "sync" },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
function dispose(): void {
|
|
138
|
+
disposed = true;
|
|
139
|
+
pollInterval.value = 0;
|
|
140
|
+
stopFollowing();
|
|
141
|
+
subscribers.clear();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function probeAgain(): void {
|
|
145
|
+
if (disposed) return;
|
|
146
|
+
resetBackoff(backoff);
|
|
147
|
+
setStatus(SIDECAR_STATUS_REVIVING);
|
|
148
|
+
void read.refresh();
|
|
112
149
|
}
|
|
113
150
|
|
|
114
151
|
return {
|
|
115
152
|
init: async (): Promise<boolean> => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (info.disabled) return
|
|
119
|
-
|
|
120
|
-
return
|
|
153
|
+
await read.execute();
|
|
154
|
+
const info = readInfo(read);
|
|
155
|
+
if (info !== null && !info.disabled) return true;
|
|
156
|
+
dispose();
|
|
157
|
+
return false;
|
|
121
158
|
},
|
|
122
159
|
getStatus: () => status,
|
|
123
160
|
subscribe: (cb) => {
|
|
@@ -128,29 +165,14 @@ export function createSidecarStatusController(
|
|
|
128
165
|
};
|
|
129
166
|
},
|
|
130
167
|
reportChannelDown: () => {
|
|
131
|
-
if (disposed) return;
|
|
132
168
|
if (status !== SIDECAR_STATUS_CONNECTED) return;
|
|
133
169
|
// Surface the drop right away (amber dot + spinner) instead of waiting on
|
|
134
170
|
// the re-probe: that probe awaits a respawn and can take a second or two,
|
|
135
|
-
// during which the icon would otherwise look healthy.
|
|
136
|
-
// to connected once the sidecar answers again.
|
|
137
|
-
|
|
138
|
-
setStatus(SIDECAR_STATUS_REVIVING);
|
|
139
|
-
void poll();
|
|
140
|
-
},
|
|
141
|
-
retry: () => {
|
|
142
|
-
if (disposed) return;
|
|
143
|
-
resetBackoff(backoff);
|
|
144
|
-
setStatus(SIDECAR_STATUS_REVIVING);
|
|
145
|
-
void poll();
|
|
146
|
-
},
|
|
147
|
-
dispose: () => {
|
|
148
|
-
disposed = true;
|
|
149
|
-
if (pollTimer !== null) {
|
|
150
|
-
clearTimeout(pollTimer);
|
|
151
|
-
pollTimer = null;
|
|
152
|
-
}
|
|
153
|
-
subscribers.clear();
|
|
171
|
+
// during which the icon would otherwise look healthy. The answer flips
|
|
172
|
+
// back to connected once the sidecar answers again.
|
|
173
|
+
probeAgain();
|
|
154
174
|
},
|
|
175
|
+
retry: probeAgain,
|
|
176
|
+
dispose,
|
|
155
177
|
};
|
|
156
178
|
}
|