@yadsh/dsh-qa-surface 0.1.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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/SPEC-dsh-qa-surface.md +1921 -0
- package/compatibility.json +15 -0
- package/cordis.patch.yml +9 -0
- package/docs/ARCHITECTURE.md +24 -0
- package/docs/COMPATIBILITY.md +13 -0
- package/docs/CONFIGURATION.md +18 -0
- package/docs/EMBEDDING.md +13 -0
- package/lib/client/QaConfigController.js +65 -0
- package/lib/client/QaConfigController.js.map +1 -0
- package/lib/client/QaRouteController.js +109 -0
- package/lib/client/QaRouteController.js.map +1 -0
- package/lib/client/QaSessionController.js +366 -0
- package/lib/client/QaSessionController.js.map +1 -0
- package/lib/client/QaSurface.js +109 -0
- package/lib/client/QaSurface.js.map +1 -0
- package/lib/client/QaTranscriptAdapter.js +92 -0
- package/lib/client/QaTranscriptAdapter.js.map +1 -0
- package/lib/client/components/Markdown.js +102 -0
- package/lib/client/components/Markdown.js.map +1 -0
- package/lib/client/components/QaComposer.js +33 -0
- package/lib/client/components/QaComposer.js.map +1 -0
- package/lib/client/components/QaMessage.js +14 -0
- package/lib/client/components/QaMessage.js.map +1 -0
- package/lib/client/index.js +46 -0
- package/lib/client/index.js.map +1 -0
- package/lib/client/styles.js +58 -0
- package/lib/client/styles.js.map +1 -0
- package/lib/client.js +1131 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +83 -0
- package/lib/config.js.map +1 -0
- package/lib/host-route.js +27 -0
- package/lib/host-route.js.map +1 -0
- package/lib/index.js +83 -0
- package/lib/index.js.map +1 -0
- package/lib/navigation-marker.js +3 -0
- package/lib/navigation-marker.js.map +1 -0
- package/lib/resolve-config.js +143 -0
- package/lib/resolve-config.js.map +1 -0
- package/lib/types/client/QaConfigController.d.ts +21 -0
- package/lib/types/client/QaRouteController.d.ts +43 -0
- package/lib/types/client/QaSessionController.d.ts +59 -0
- package/lib/types/client/QaSurface.d.ts +16 -0
- package/lib/types/client/QaTranscriptAdapter.d.ts +7 -0
- package/lib/types/client/components/Markdown.d.ts +5 -0
- package/lib/types/client/components/QaComposer.d.ts +11 -0
- package/lib/types/client/components/QaMessage.d.ts +8 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/styles.d.ts +2 -0
- package/lib/types/config.d.ts +5 -0
- package/lib/types/host-route.d.ts +9 -0
- package/lib/types/index.d.ts +29 -0
- package/lib/types/navigation-marker.d.ts +3 -0
- package/lib/types/resolve-config.d.ts +6 -0
- package/lib/types/types.d.ts +114 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/package.json +137 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"deepseekHarness": {
|
|
3
|
+
"channel": "next",
|
|
4
|
+
"range": ">=0.1.1-rc.2 <0.2.0",
|
|
5
|
+
"testedReleases": ["0.1.1-rc.2"],
|
|
6
|
+
"requiredClientFeatures": [
|
|
7
|
+
"shell.overlay",
|
|
8
|
+
"connection.api.sessions",
|
|
9
|
+
"sessions.binding",
|
|
10
|
+
"settingsScope",
|
|
11
|
+
"webServer.register"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
15
|
+
}
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
The Host half validates the Loader configuration, registers the `qa-surface`
|
|
4
|
+
settings namespace and owns one narrow GET/HEAD route for the configured QA
|
|
5
|
+
path. Published DSH `0.1.1-rc.2` returns 404 for unknown frontend paths, so the
|
|
6
|
+
route redirects navigation through the canonical `/` index with an encoded
|
|
7
|
+
same-origin path marker. It does not serve HTML, create another server, replace
|
|
8
|
+
the frontend fallback, or touch API/plugin routes. The browser removes the
|
|
9
|
+
marker and restores the requested path with `history.replaceState` before
|
|
10
|
+
route matching. The Host creates no sessions.
|
|
11
|
+
|
|
12
|
+
The browser half registers `QaSurface` in the root-scoped `shell.overlay` list.
|
|
13
|
+
`QaRouteController` keeps that entry dormant outside the configured path and
|
|
14
|
+
reversibly observes `pushState`, `replaceState` and `popstate`.
|
|
15
|
+
|
|
16
|
+
`QaSessionController` is the only DSH session-coupled module. It uses
|
|
17
|
+
`connection.api.sessions.create/selectModel` for creation policy and the public
|
|
18
|
+
`SessionFace` for prompt, cancel and snapshots. `QaTranscriptAdapter` projects
|
|
19
|
+
those snapshots to plugin-local messages, excluding reasoning, tool arguments,
|
|
20
|
+
tool results, system context and raw failures.
|
|
21
|
+
|
|
22
|
+
The overlay locks document scrolling and contains keyboard focus while active.
|
|
23
|
+
It does not locate AppFrame through generated CSS classes and does not dispose
|
|
24
|
+
global DSH runtime services when the route changes.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Compatibility
|
|
2
|
+
|
|
3
|
+
| Plugin version | Tested DSH release | Required public contracts |
|
|
4
|
+
| -------------- | ------------------ | ----------------------------------------------------------------------------------------------------- |
|
|
5
|
+
| 0.1.x | 0.1.1-rc.2 | `shell.overlay`, `settingsScope`, `connection.api.sessions`, `sessions.binding`, `webServer.register` |
|
|
6
|
+
|
|
7
|
+
DSH is developer-preview software. Compatibility-sensitive calls are isolated
|
|
8
|
+
in `QaSessionController`, `QaTranscriptAdapter`, `QaConfigController` and
|
|
9
|
+
`QaRouteController`. The Host navigation adapter also works around this tested
|
|
10
|
+
release's exact-index static serving: `/qa` is redirected through `/`, then the
|
|
11
|
+
client restores the original pathname. Missing settings fall back to default
|
|
12
|
+
browser config; missing required client services prevent the browser plugin
|
|
13
|
+
from being injected rather than patching DSH internals.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Configuration reference
|
|
2
|
+
|
|
3
|
+
Defaults and constraints are documented in the root README. Important runtime
|
|
4
|
+
rules are:
|
|
5
|
+
|
|
6
|
+
- `route.path` starts with `/`, has trailing slashes removed, and cannot claim
|
|
7
|
+
`/`, `/api` or `/plugins`;
|
|
8
|
+
- `fixed` requires `fixedSessionId`;
|
|
9
|
+
- `provider` and `model` are either both absent or both present;
|
|
10
|
+
- `maxContentWidth` is an integer from 480 through 1600;
|
|
11
|
+
- duplicate/blank suggested questions are removed;
|
|
12
|
+
- approval and question policies are fixed to safe blocking behavior;
|
|
13
|
+
- reasoning display cannot be enabled in the 0.1.x MVP.
|
|
14
|
+
|
|
15
|
+
Browser persistence stores only the DSH session id under
|
|
16
|
+
`<storageKey>:v1:<route>:session`. Transcript content, credentials and tool
|
|
17
|
+
results remain in the Host-owned DSH Session and are never copied to browser
|
|
18
|
+
storage.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Embedding and reverse proxies
|
|
2
|
+
|
|
3
|
+
Serve `/qa` through the normal DSH origin so it shares the established browser
|
|
4
|
+
connection, authentication and trust policy. The plugin's narrow navigation
|
|
5
|
+
route redirects GET/HEAD requests through DSH's canonical `/` document; the
|
|
6
|
+
client immediately restores `/qa` before mounting. Reverse proxies must pass
|
|
7
|
+
the configured QA path to DSH instead of replacing it with a separately hosted
|
|
8
|
+
page.
|
|
9
|
+
|
|
10
|
+
Iframe embedding is disabled or allowed by deployment headers, not by this
|
|
11
|
+
plugin. Configure an explicit CSP `frame-ancestors` allowlist at a trusted
|
|
12
|
+
reverse proxy and keep the DSH API same-origin. Do not enable wildcard CORS or
|
|
13
|
+
expose settings/session APIs without the deployment's authentication layer.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { DEFAULT_QA_SURFACE_CONFIG, resolveConfig } from "../resolve-config.js";
|
|
2
|
+
/** Browser projection of the Host-owned qa-surface settings namespace. */
|
|
3
|
+
export class QaConfigController {
|
|
4
|
+
scope;
|
|
5
|
+
listeners = new Set();
|
|
6
|
+
snapshot;
|
|
7
|
+
unsubscribe;
|
|
8
|
+
constructor(scope) {
|
|
9
|
+
this.scope = scope;
|
|
10
|
+
this.snapshot = this.project();
|
|
11
|
+
this.unsubscribe = scope.subscribe(() => this.refresh());
|
|
12
|
+
}
|
|
13
|
+
getSnapshot = () => this.snapshot;
|
|
14
|
+
subscribe = (listener) => {
|
|
15
|
+
this.listeners.add(listener);
|
|
16
|
+
return () => this.listeners.delete(listener);
|
|
17
|
+
};
|
|
18
|
+
dispose() {
|
|
19
|
+
this.unsubscribe();
|
|
20
|
+
this.listeners.clear();
|
|
21
|
+
}
|
|
22
|
+
project() {
|
|
23
|
+
const settings = this.scope.getSnapshot();
|
|
24
|
+
if (settings.status === "loading") {
|
|
25
|
+
return {
|
|
26
|
+
status: "loading",
|
|
27
|
+
config: DEFAULT_QA_SURFACE_CONFIG,
|
|
28
|
+
error: null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (settings.status === "unavailable") {
|
|
32
|
+
return {
|
|
33
|
+
status: "unavailable",
|
|
34
|
+
config: DEFAULT_QA_SURFACE_CONFIG,
|
|
35
|
+
error: null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return {
|
|
40
|
+
status: "ready",
|
|
41
|
+
config: resolveConfig(settings.value ?? {}),
|
|
42
|
+
error: null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return {
|
|
47
|
+
status: "error",
|
|
48
|
+
config: DEFAULT_QA_SURFACE_CONFIG,
|
|
49
|
+
error: "The assistant configuration is unavailable.",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
refresh() {
|
|
54
|
+
const next = this.project();
|
|
55
|
+
if (next.status === this.snapshot.status &&
|
|
56
|
+
next.config === this.snapshot.config &&
|
|
57
|
+
next.error === this.snapshot.error) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.snapshot = next;
|
|
61
|
+
for (const listener of this.listeners)
|
|
62
|
+
listener();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=QaConfigController.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"QaConfigController.js","sourceRoot":"","sources":["../../src/client/QaConfigController.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,yBAAyB,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAShF,0EAA0E;AAC1E,MAAM,OAAO,kBAAkB;IAKA,KAAK;IAJjB,SAAS,GAAG,IAAI,GAAG,EAAc,CAAC;IAC3C,QAAQ,CAAmB;IAClB,WAAW,CAAa;IAEzC,YAA6B,KAAqC;qBAArC,KAAK;QAChC,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QAC/B,IAAI,CAAC,WAAW,GAAG,KAAK,CAAC,SAAS,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC;IAC3D,CAAC;IAED,WAAW,GAAG,GAAqB,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC;IAEpD,SAAS,GAAG,CAAC,QAAoB,EAAgB,EAAE;QACjD,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC7B,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC/C,CAAC,CAAC;IAEF,OAAO;QACL,IAAI,CAAC,WAAW,EAAE,CAAC;QACnB,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC;IACzB,CAAC;IAEO,OAAO;QACb,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;QAC1C,IAAI,QAAQ,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;YAClC,OAAO;gBACL,MAAM,EAAE,SAAS;gBACjB,MAAM,EAAE,yBAAyB;gBACjC,KAAK,EAAE,IAAI;aACZ,CAAC;QACJ,CAAC;QACD,IAAI,QAAQ,CAAC,MAAM,KAAK,aAAa,EAAE,CAAC;YACtC,OAAO;gBACL,MAAM,EAAE,aAAa;gBACrB,MAAM,EAAE,yBAAyB;gBACjC,KAAK,EAAE,IAAI;aACZ,CAAC;QACJ,CAAC;QACD,IAAI,CAAC;YACH,OAAO;gBACL,MAAM,EAAE,OAAO;gBACf,MAAM,EAAE,aAAa,CAAC,QAAQ,CAAC,KAAK,IAAI,EAAE,CAAC;gBAC3C,KAAK,EAAE,IAAI;aACZ,CAAC;QACJ,CAAC;QAAC,MAAM,CAAC;YACP,OAAO;gBACL,MAAM,EAAE,OAAO;gBACf,MAAM,EAAE,yBAAyB;gBACjC,KAAK,EAAE,6CAA6C;aACrD,CAAC;QACJ,CAAC;IACH,CAAC;IAEO,OAAO;QACb,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QAC5B,IACE,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,QAAQ,CAAC,MAAM;YACpC,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,QAAQ,CAAC,MAAM;YACpC,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC,QAAQ,CAAC,KAAK,EAClC,CAAC;YACD,OAAO;QACT,CAAC;QACD,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACrB,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,SAAS;YAAE,QAAQ,EAAE,CAAC;IACpD,CAAC;CACF"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { QA_NAVIGATION_MARKER } from "../navigation-marker.js";
|
|
2
|
+
export function matchesQaRoute(pathname, route) {
|
|
3
|
+
if (pathname === route.path || pathname === `${route.path}/`)
|
|
4
|
+
return true;
|
|
5
|
+
if (route.path === "/")
|
|
6
|
+
return route.matchChildren;
|
|
7
|
+
if (!route.matchChildren)
|
|
8
|
+
return false;
|
|
9
|
+
return pathname.startsWith(`${route.path}/`);
|
|
10
|
+
}
|
|
11
|
+
/** Observable route matcher with reversible History API instrumentation. */
|
|
12
|
+
export class QaRouteController {
|
|
13
|
+
target;
|
|
14
|
+
listeners = new Set();
|
|
15
|
+
route = {
|
|
16
|
+
path: "/qa",
|
|
17
|
+
matchChildren: true,
|
|
18
|
+
};
|
|
19
|
+
enabled = false;
|
|
20
|
+
snapshot;
|
|
21
|
+
originalPushState;
|
|
22
|
+
originalReplaceState;
|
|
23
|
+
wrappedPushState;
|
|
24
|
+
wrappedReplaceState;
|
|
25
|
+
disposed = false;
|
|
26
|
+
constructor(target = window) {
|
|
27
|
+
this.target = target;
|
|
28
|
+
this.originalPushState = target.history.pushState;
|
|
29
|
+
this.originalReplaceState = target.history.replaceState;
|
|
30
|
+
this.restoreRedirectedPath();
|
|
31
|
+
this.snapshot = this.project();
|
|
32
|
+
const refresh = () => this.refresh();
|
|
33
|
+
const history = target.history;
|
|
34
|
+
const originalPushState = this.originalPushState;
|
|
35
|
+
const originalReplaceState = this.originalReplaceState;
|
|
36
|
+
this.wrappedPushState = function (...args) {
|
|
37
|
+
Reflect.apply(originalPushState, history, args);
|
|
38
|
+
refresh();
|
|
39
|
+
};
|
|
40
|
+
this.wrappedReplaceState = function (...args) {
|
|
41
|
+
Reflect.apply(originalReplaceState, history, args);
|
|
42
|
+
refresh();
|
|
43
|
+
};
|
|
44
|
+
target.history.pushState = this.wrappedPushState;
|
|
45
|
+
target.history.replaceState = this.wrappedReplaceState;
|
|
46
|
+
target.addEventListener("popstate", refresh);
|
|
47
|
+
this.removePopstate = () => target.removeEventListener("popstate", refresh);
|
|
48
|
+
}
|
|
49
|
+
removePopstate;
|
|
50
|
+
getSnapshot = () => this.snapshot;
|
|
51
|
+
subscribe = (listener) => {
|
|
52
|
+
this.listeners.add(listener);
|
|
53
|
+
return () => this.listeners.delete(listener);
|
|
54
|
+
};
|
|
55
|
+
configure(config, ready = true) {
|
|
56
|
+
this.route = config.route;
|
|
57
|
+
this.enabled = ready && config.enabled;
|
|
58
|
+
this.refresh();
|
|
59
|
+
}
|
|
60
|
+
dispose() {
|
|
61
|
+
if (this.disposed)
|
|
62
|
+
return;
|
|
63
|
+
this.disposed = true;
|
|
64
|
+
this.removePopstate();
|
|
65
|
+
if (this.target.history.pushState === this.wrappedPushState) {
|
|
66
|
+
this.target.history.pushState = this.originalPushState;
|
|
67
|
+
}
|
|
68
|
+
if (this.target.history.replaceState === this.wrappedReplaceState) {
|
|
69
|
+
this.target.history.replaceState = this.originalReplaceState;
|
|
70
|
+
}
|
|
71
|
+
this.listeners.clear();
|
|
72
|
+
}
|
|
73
|
+
project() {
|
|
74
|
+
const pathname = this.target.location.pathname;
|
|
75
|
+
return {
|
|
76
|
+
pathname,
|
|
77
|
+
active: this.enabled && matchesQaRoute(pathname, this.route),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
restoreRedirectedPath() {
|
|
81
|
+
const search = this.target.location.search;
|
|
82
|
+
if (search === undefined || search === "")
|
|
83
|
+
return;
|
|
84
|
+
const requested = new URLSearchParams(search).get(QA_NAVIGATION_MARKER);
|
|
85
|
+
if (requested === null ||
|
|
86
|
+
!requested.startsWith("/") ||
|
|
87
|
+
requested.startsWith("//")) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
Reflect.apply(this.originalReplaceState, this.target.history, [
|
|
91
|
+
null,
|
|
92
|
+
"",
|
|
93
|
+
requested,
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
refresh() {
|
|
97
|
+
if (this.disposed)
|
|
98
|
+
return;
|
|
99
|
+
const next = this.project();
|
|
100
|
+
if (next.pathname === this.snapshot.pathname &&
|
|
101
|
+
next.active === this.snapshot.active) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
this.snapshot = next;
|
|
105
|
+
for (const listener of this.listeners)
|
|
106
|
+
listener();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=QaRouteController.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"QaRouteController.js","sourceRoot":"","sources":["../../src/client/QaRouteController.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { projectTranscript } from "./QaTranscriptAdapter.js";
|
|
2
|
+
const EMPTY_STATE = Object.freeze({
|
|
3
|
+
phase: "idle",
|
|
4
|
+
sessionId: null,
|
|
5
|
+
messages: Object.freeze([]),
|
|
6
|
+
error: null,
|
|
7
|
+
canSend: false,
|
|
8
|
+
canStop: false,
|
|
9
|
+
});
|
|
10
|
+
function waitFor(source, predicate, timeoutMs) {
|
|
11
|
+
const current = source.getSnapshot();
|
|
12
|
+
if (predicate(current))
|
|
13
|
+
return Promise.resolve(current);
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
let settled = false;
|
|
16
|
+
const timeout = {};
|
|
17
|
+
let unsubscribe = () => undefined;
|
|
18
|
+
const finish = (value) => {
|
|
19
|
+
if (settled)
|
|
20
|
+
return;
|
|
21
|
+
settled = true;
|
|
22
|
+
if (timeout.id !== undefined)
|
|
23
|
+
clearTimeout(timeout.id);
|
|
24
|
+
unsubscribe();
|
|
25
|
+
resolve(value);
|
|
26
|
+
};
|
|
27
|
+
unsubscribe = source.subscribe(() => {
|
|
28
|
+
const next = source.getSnapshot();
|
|
29
|
+
if (predicate(next))
|
|
30
|
+
finish(next);
|
|
31
|
+
});
|
|
32
|
+
// Tolerate observable implementations that notify synchronously while a
|
|
33
|
+
// subscriber is being installed, and close that subscription afterward.
|
|
34
|
+
if (settled) {
|
|
35
|
+
unsubscribe();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const afterSubscribe = source.getSnapshot();
|
|
39
|
+
if (predicate(afterSubscribe)) {
|
|
40
|
+
finish(afterSubscribe);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
timeout.id = setTimeout(() => {
|
|
44
|
+
if (settled)
|
|
45
|
+
return;
|
|
46
|
+
settled = true;
|
|
47
|
+
unsubscribe();
|
|
48
|
+
reject(new Error("Timed out waiting for the DSH runtime."));
|
|
49
|
+
}, timeoutMs);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** The only module that couples QA behavior to DSH Session/client APIs. */
|
|
53
|
+
export class QaSessionController {
|
|
54
|
+
listeners = new Set();
|
|
55
|
+
sessions;
|
|
56
|
+
api;
|
|
57
|
+
connection;
|
|
58
|
+
config;
|
|
59
|
+
storage;
|
|
60
|
+
timeoutMs;
|
|
61
|
+
state = EMPTY_STATE;
|
|
62
|
+
session;
|
|
63
|
+
unsubscribeSession;
|
|
64
|
+
unsubscribeConnection;
|
|
65
|
+
ensuring;
|
|
66
|
+
operationError = null;
|
|
67
|
+
admissionPending = false;
|
|
68
|
+
connectedOnce;
|
|
69
|
+
disposed = false;
|
|
70
|
+
generation = 0;
|
|
71
|
+
constructor(options) {
|
|
72
|
+
this.sessions = options.sessions;
|
|
73
|
+
this.api = options.api;
|
|
74
|
+
this.connection = options.connection;
|
|
75
|
+
this.config = options.config;
|
|
76
|
+
this.storage = options.storage;
|
|
77
|
+
this.timeoutMs = options.timeoutMs ?? 15_000;
|
|
78
|
+
this.connectedOnce = this.connection.getSnapshot() !== undefined;
|
|
79
|
+
this.unsubscribeConnection = this.connection.subscribe(() => {
|
|
80
|
+
if (this.connection.getSnapshot() !== undefined)
|
|
81
|
+
this.connectedOnce = true;
|
|
82
|
+
this.publish();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
getSnapshot = () => this.state;
|
|
86
|
+
subscribe = (listener) => {
|
|
87
|
+
this.listeners.add(listener);
|
|
88
|
+
return () => this.listeners.delete(listener);
|
|
89
|
+
};
|
|
90
|
+
ensureSession() {
|
|
91
|
+
if (this.ensuring !== undefined)
|
|
92
|
+
return this.ensuring;
|
|
93
|
+
const operation = this.ensureSessionNow().finally(() => {
|
|
94
|
+
if (this.ensuring === operation)
|
|
95
|
+
this.ensuring = undefined;
|
|
96
|
+
});
|
|
97
|
+
this.ensuring = operation;
|
|
98
|
+
return operation;
|
|
99
|
+
}
|
|
100
|
+
async send(text) {
|
|
101
|
+
const prompt = text.trim();
|
|
102
|
+
if (prompt === "" || this.session === undefined || !this.state.canSend) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
if (prompt.startsWith("/")) {
|
|
106
|
+
this.operationError =
|
|
107
|
+
"Slash commands are not available in this assistant view.";
|
|
108
|
+
this.publish();
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
this.operationError = null;
|
|
112
|
+
this.admissionPending = true;
|
|
113
|
+
this.publish();
|
|
114
|
+
try {
|
|
115
|
+
const result = await this.session.prompt([{ type: "text", text: prompt }], "queue");
|
|
116
|
+
if (!result.ok) {
|
|
117
|
+
this.admissionPending = false;
|
|
118
|
+
this.operationError = "Your message could not be sent.";
|
|
119
|
+
this.publish();
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
this.publish();
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
this.admissionPending = false;
|
|
127
|
+
console.error("dsh-qa-surface: prompt failed", error);
|
|
128
|
+
this.operationError = "Your message could not be sent.";
|
|
129
|
+
this.publish();
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async stop() {
|
|
134
|
+
if (this.session === undefined || !this.state.canStop)
|
|
135
|
+
return;
|
|
136
|
+
try {
|
|
137
|
+
const result = await this.session.cancel();
|
|
138
|
+
if (!result.ok)
|
|
139
|
+
this.operationError = "The response could not be stopped.";
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
console.error("dsh-qa-surface: stop failed", error);
|
|
143
|
+
this.operationError = "The response could not be stopped.";
|
|
144
|
+
}
|
|
145
|
+
this.publish();
|
|
146
|
+
}
|
|
147
|
+
async reset() {
|
|
148
|
+
if (this.config.session.policy === "fixed")
|
|
149
|
+
return;
|
|
150
|
+
const previous = this.session;
|
|
151
|
+
if (previous?.getSnapshot().running === true) {
|
|
152
|
+
try {
|
|
153
|
+
await previous.cancel();
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
console.error("dsh-qa-surface: stop before reset failed", error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const operation = ++this.generation;
|
|
160
|
+
this.unbind();
|
|
161
|
+
this.operationError = null;
|
|
162
|
+
this.admissionPending = false;
|
|
163
|
+
this.state = { ...EMPTY_STATE, phase: "creating" };
|
|
164
|
+
this.emit();
|
|
165
|
+
try {
|
|
166
|
+
await this.waitForConnection();
|
|
167
|
+
const id = await this.createSession();
|
|
168
|
+
if (this.disposed || operation !== this.generation)
|
|
169
|
+
return;
|
|
170
|
+
this.persist(id);
|
|
171
|
+
await this.bind(id);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
this.fail("Unable to start a new chat.", error);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
dispose() {
|
|
178
|
+
if (this.disposed)
|
|
179
|
+
return;
|
|
180
|
+
this.disposed = true;
|
|
181
|
+
this.generation += 1;
|
|
182
|
+
this.unsubscribeConnection();
|
|
183
|
+
this.unbind();
|
|
184
|
+
this.listeners.clear();
|
|
185
|
+
}
|
|
186
|
+
async ensureSessionNow() {
|
|
187
|
+
const operation = ++this.generation;
|
|
188
|
+
this.state = { ...EMPTY_STATE, phase: "creating" };
|
|
189
|
+
this.emit();
|
|
190
|
+
try {
|
|
191
|
+
await this.waitForConnection();
|
|
192
|
+
const list = await waitFor(this.sessions.list, (snapshot) => snapshot.phase === "ready", this.timeoutMs);
|
|
193
|
+
let id = null;
|
|
194
|
+
if (this.config.session.policy === "fixed") {
|
|
195
|
+
id = this.config.session.fixedSessionId;
|
|
196
|
+
if (id === null || !Object.hasOwn(list.byId, id)) {
|
|
197
|
+
throw new Error("Configured fixed session is unavailable.");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else if (this.config.session.policy === "browser-persistent") {
|
|
201
|
+
const stored = this.readPersisted();
|
|
202
|
+
if (stored !== null && Object.hasOwn(list.byId, stored)) {
|
|
203
|
+
id = stored;
|
|
204
|
+
}
|
|
205
|
+
else if (stored !== null) {
|
|
206
|
+
this.clearPersisted();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (id === null) {
|
|
210
|
+
id = await this.createSession();
|
|
211
|
+
if (this.config.session.policy === "browser-persistent")
|
|
212
|
+
this.persist(id);
|
|
213
|
+
}
|
|
214
|
+
if (this.disposed || operation !== this.generation)
|
|
215
|
+
return;
|
|
216
|
+
await this.bind(id);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
this.fail("Unable to start a chat.", error);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async createSession() {
|
|
223
|
+
const response = await this.api.create({
|
|
224
|
+
...(this.config.session.workspaceId === null
|
|
225
|
+
? {}
|
|
226
|
+
: { workspaceId: this.config.session.workspaceId }),
|
|
227
|
+
...(this.config.session.agentPreset === null
|
|
228
|
+
? {}
|
|
229
|
+
: { agentPreset: this.config.session.agentPreset }),
|
|
230
|
+
});
|
|
231
|
+
if (!response.result.ok)
|
|
232
|
+
throw new Error(response.result.error.code);
|
|
233
|
+
const id = String(response.result.value.sessionId);
|
|
234
|
+
await waitFor(this.sessions.list, (snapshot) => Object.hasOwn(snapshot.byId, id), this.timeoutMs);
|
|
235
|
+
if (this.config.session.provider !== null &&
|
|
236
|
+
this.config.session.model !== null) {
|
|
237
|
+
const selected = await this.api.selectModel({
|
|
238
|
+
sessionId: id,
|
|
239
|
+
provider: this.config.session.provider,
|
|
240
|
+
model: this.config.session.model,
|
|
241
|
+
...(this.config.session.reasoningEffort === null
|
|
242
|
+
? {}
|
|
243
|
+
: { reasoningEffort: this.config.session.reasoningEffort }),
|
|
244
|
+
});
|
|
245
|
+
if (!selected.result.ok)
|
|
246
|
+
throw new Error(selected.result.error.code);
|
|
247
|
+
}
|
|
248
|
+
return id;
|
|
249
|
+
}
|
|
250
|
+
async bind(id) {
|
|
251
|
+
this.sessions.open(id);
|
|
252
|
+
let binding = this.sessions.binding(id);
|
|
253
|
+
if (binding === undefined) {
|
|
254
|
+
await waitFor(this.sessions.list, (snapshot) => Object.hasOwn(snapshot.byId, id), this.timeoutMs);
|
|
255
|
+
binding = this.sessions.binding(id);
|
|
256
|
+
}
|
|
257
|
+
if (binding === undefined)
|
|
258
|
+
throw new Error("Session binding is unavailable.");
|
|
259
|
+
this.unbind();
|
|
260
|
+
this.session = binding.session;
|
|
261
|
+
this.unsubscribeSession = binding.session.subscribe(() => {
|
|
262
|
+
this.admissionPending = false;
|
|
263
|
+
this.operationError = null;
|
|
264
|
+
this.publish();
|
|
265
|
+
});
|
|
266
|
+
this.publish();
|
|
267
|
+
}
|
|
268
|
+
unbind() {
|
|
269
|
+
this.unsubscribeSession?.();
|
|
270
|
+
this.unsubscribeSession = undefined;
|
|
271
|
+
this.session = undefined;
|
|
272
|
+
this.admissionPending = false;
|
|
273
|
+
}
|
|
274
|
+
waitForConnection() {
|
|
275
|
+
return waitFor(this.connection, (description) => description !== undefined, this.timeoutMs);
|
|
276
|
+
}
|
|
277
|
+
publish() {
|
|
278
|
+
if (this.disposed)
|
|
279
|
+
return;
|
|
280
|
+
const connected = this.connection.getSnapshot() !== undefined;
|
|
281
|
+
if (this.session === undefined) {
|
|
282
|
+
this.state = {
|
|
283
|
+
...this.state,
|
|
284
|
+
phase: !connected && this.connectedOnce ? "reconnecting" : this.state.phase,
|
|
285
|
+
error: this.operationError ?? this.state.error,
|
|
286
|
+
};
|
|
287
|
+
this.emit();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const snapshot = this.session.getSnapshot();
|
|
291
|
+
const blocked = snapshot.pending.length > 0;
|
|
292
|
+
const error = this.operationError ??
|
|
293
|
+
(blocked
|
|
294
|
+
? "This request requires an interaction that is not available in this assistant view."
|
|
295
|
+
: snapshot.removed || snapshot.openState === "error"
|
|
296
|
+
? "This chat is no longer available."
|
|
297
|
+
: null);
|
|
298
|
+
const phase = !connected
|
|
299
|
+
? "reconnecting"
|
|
300
|
+
: blocked
|
|
301
|
+
? "blocked"
|
|
302
|
+
: snapshot.removed || snapshot.openState === "error"
|
|
303
|
+
? "error"
|
|
304
|
+
: snapshot.openState !== "open"
|
|
305
|
+
? "creating"
|
|
306
|
+
: snapshot.running || this.admissionPending
|
|
307
|
+
? "running"
|
|
308
|
+
: "ready";
|
|
309
|
+
this.state = {
|
|
310
|
+
phase,
|
|
311
|
+
sessionId: String(this.session.sessionId),
|
|
312
|
+
messages: projectTranscript(snapshot, {
|
|
313
|
+
showToolActivity: this.config.ui.showToolActivity,
|
|
314
|
+
}),
|
|
315
|
+
error,
|
|
316
|
+
canSend: connected && phase === "ready",
|
|
317
|
+
canStop: connected && snapshot.running && this.config.ui.showStop && !blocked,
|
|
318
|
+
};
|
|
319
|
+
this.emit();
|
|
320
|
+
}
|
|
321
|
+
fail(message, error) {
|
|
322
|
+
console.error("dsh-qa-surface: session operation failed", error);
|
|
323
|
+
if (this.disposed)
|
|
324
|
+
return;
|
|
325
|
+
this.operationError = message;
|
|
326
|
+
this.state = {
|
|
327
|
+
...EMPTY_STATE,
|
|
328
|
+
phase: "error",
|
|
329
|
+
error: message,
|
|
330
|
+
};
|
|
331
|
+
this.emit();
|
|
332
|
+
}
|
|
333
|
+
storageKey() {
|
|
334
|
+
return `${this.config.session.storageKey}:v1:${this.config.route.path}:session`;
|
|
335
|
+
}
|
|
336
|
+
readPersisted() {
|
|
337
|
+
try {
|
|
338
|
+
const value = this.storage?.getItem(this.storageKey())?.trim() ?? "";
|
|
339
|
+
return value === "" ? null : value;
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
persist(id) {
|
|
346
|
+
try {
|
|
347
|
+
this.storage?.setItem(this.storageKey(), id);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// A denied localStorage write must not prevent a real DSH session.
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
clearPersisted() {
|
|
354
|
+
try {
|
|
355
|
+
this.storage?.removeItem(this.storageKey());
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// A denied localStorage removal is harmless; the id is revalidated later.
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
emit() {
|
|
362
|
+
for (const listener of this.listeners)
|
|
363
|
+
listener();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
//# sourceMappingURL=QaSessionController.js.map
|