@yadsh/dsh-qa-surface 0.1.1 → 0.2.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 +68 -0
- package/README.md +162 -14
- package/SPEC-dsh-qa-surface.md +600 -96
- package/capability-policy.json +5 -0
- package/compatibility.json +7 -1
- package/cordis.patch.yml +11 -0
- package/docs/ARCHITECTURE.md +49 -7
- package/docs/COMPATIBILITY.md +5 -4
- package/docs/CONFIGURATION.md +96 -2
- package/lib/attestation.js +10 -0
- package/lib/attestation.js.map +1 -0
- package/lib/client/QaConfigController.js +111 -4
- package/lib/client/QaConfigController.js.map +1 -1
- package/lib/client/QaSessionController.js +501 -119
- package/lib/client/QaSessionController.js.map +1 -1
- package/lib/client/QaSurface.js +137 -20
- package/lib/client/QaSurface.js.map +1 -1
- package/lib/client/QaTranscriptAdapter.js +534 -45
- package/lib/client/QaTranscriptAdapter.js.map +1 -1
- package/lib/client/attestation.js +60 -0
- package/lib/client/attestation.js.map +1 -0
- package/lib/client/chat-index.js +86 -0
- package/lib/client/chat-index.js.map +1 -0
- package/lib/client/components/Markdown.js +75 -5
- package/lib/client/components/Markdown.js.map +1 -1
- package/lib/client/components/QaAgentsDrawer.js +40 -0
- package/lib/client/components/QaAgentsDrawer.js.map +1 -0
- package/lib/client/components/QaComposer.js +132 -15
- package/lib/client/components/QaComposer.js.map +1 -1
- package/lib/client/components/QaMessage.js +184 -10
- package/lib/client/components/QaMessage.js.map +1 -1
- package/lib/client/components/QaSidebar.js +124 -0
- package/lib/client/components/QaSidebar.js.map +1 -0
- package/lib/client/components/QaSourcesDrawer.js +46 -0
- package/lib/client/components/QaSourcesDrawer.js.map +1 -0
- package/lib/client/components/QaWorkGroup.js +135 -0
- package/lib/client/components/QaWorkGroup.js.map +1 -0
- package/lib/client/components/VariantSwitcher.js +29 -0
- package/lib/client/components/VariantSwitcher.js.map +1 -0
- package/lib/client/components/format.js +48 -0
- package/lib/client/components/format.js.map +1 -0
- package/lib/client/index.js +77 -34
- package/lib/client/index.js.map +1 -1
- package/lib/client/styles.js +308 -43
- package/lib/client/styles.js.map +1 -1
- package/lib/client/types.js +13 -0
- package/lib/client/types.js.map +1 -0
- package/lib/client/wait-for.js +49 -0
- package/lib/client/wait-for.js.map +1 -0
- package/lib/client.js +8228 -383
- package/lib/client.js.map +1 -1
- package/lib/config.js +61 -9
- package/lib/config.js.map +1 -1
- package/lib/index.js +166 -70
- package/lib/index.js.map +1 -1
- package/lib/lockdown-policy.js +20 -0
- package/lib/lockdown-policy.js.map +1 -0
- package/lib/resolve-config.js +122 -10
- package/lib/resolve-config.js.map +1 -1
- package/lib/secure-session.js +170 -0
- package/lib/secure-session.js.map +1 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +191 -0
- package/lib/typert.remote-client.d.ts +24 -0
- package/lib/typert.remote-client.js +129 -0
- package/lib/types/attestation.d.ts +17 -0
- package/lib/types/client/QaConfigController.d.ts +34 -2
- package/lib/types/client/QaSessionController.d.ts +106 -19
- package/lib/types/client/QaSurface.d.ts +5 -4
- package/lib/types/client/QaTranscriptAdapter.d.ts +17 -2
- package/lib/types/client/attestation.d.ts +25 -0
- package/lib/types/client/chat-index.d.ts +28 -0
- package/lib/types/client/components/Markdown.d.ts +7 -3
- package/lib/types/client/components/QaAgentsDrawer.d.ts +25 -0
- package/lib/types/client/components/QaComposer.d.ts +12 -2
- package/lib/types/client/components/QaMessage.d.ts +14 -1
- package/lib/types/client/components/QaSidebar.d.ts +41 -0
- package/lib/types/client/components/QaSourcesDrawer.d.ts +8 -0
- package/lib/types/client/components/QaWorkGroup.d.ts +19 -0
- package/lib/types/client/components/VariantSwitcher.d.ts +19 -0
- package/lib/types/client/components/format.d.ts +9 -0
- package/lib/types/client/index.d.ts +1 -5
- package/lib/types/client/types.d.ts +29 -0
- package/lib/types/client/wait-for.d.ts +11 -0
- package/lib/types/index.d.ts +22 -6
- package/lib/types/lockdown-policy.d.ts +18 -0
- package/lib/types/secure-session.d.ts +22 -0
- package/lib/types/types.d.ts +148 -1
- package/package.json +29 -3
|
@@ -1,80 +1,59 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
});
|
|
1
|
+
function bytesToBase64(bytes) {
|
|
2
|
+
let binary = "";
|
|
3
|
+
for (let index = 0; index < bytes.length; index += 0x8000) {
|
|
4
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
|
5
|
+
}
|
|
6
|
+
return btoa(binary);
|
|
51
7
|
}
|
|
52
|
-
|
|
8
|
+
import { QaPolicyAttestationError, attestationHint, attestationReasonOf, proofMatchesConfig, } from "./attestation.js";
|
|
9
|
+
import { QaChatIndex } from "./chat-index.js";
|
|
10
|
+
import { QA_SESSION_IDLE_STATE } from "./types.js";
|
|
11
|
+
import { waitFor } from "./wait-for.js";
|
|
12
|
+
import { projectSources } from "./QaTranscriptAdapter.js";
|
|
13
|
+
import { projectTranscript, QA_REGENERATE_MARKER, } from "./QaTranscriptAdapter.js";
|
|
14
|
+
const CONFIGURATION_ERROR = "Настройки помощника недоступны.";
|
|
15
|
+
/**
|
|
16
|
+
* The only module that couples QA behavior to DSH Session/client APIs. It owns
|
|
17
|
+
* the chat lifecycle state machine; the observable wait, browser-local chat
|
|
18
|
+
* bookkeeping and policy-attestation details live in their own modules.
|
|
19
|
+
*/
|
|
53
20
|
export class QaSessionController {
|
|
54
21
|
listeners = new Set();
|
|
55
22
|
sessions;
|
|
56
23
|
api;
|
|
57
24
|
connection;
|
|
58
25
|
config;
|
|
59
|
-
|
|
26
|
+
secureSessionRemote;
|
|
27
|
+
chats;
|
|
60
28
|
timeoutMs;
|
|
61
|
-
|
|
29
|
+
streamIntervalMs;
|
|
30
|
+
state = QA_SESSION_IDLE_STATE;
|
|
62
31
|
session;
|
|
63
32
|
unsubscribeSession;
|
|
64
33
|
unsubscribeConnection;
|
|
65
34
|
ensuring;
|
|
35
|
+
materializing;
|
|
66
36
|
operationError = null;
|
|
67
37
|
admissionPending = false;
|
|
38
|
+
policyReady = false;
|
|
39
|
+
drafting = false;
|
|
40
|
+
streamFlushTimer;
|
|
41
|
+
/** The chat session to return to when a subagent view closes. */
|
|
42
|
+
chatSessionId = null;
|
|
43
|
+
viewingSubagent = null;
|
|
68
44
|
connectedOnce;
|
|
69
45
|
disposed = false;
|
|
70
46
|
generation = 0;
|
|
47
|
+
chatsRevision = 0;
|
|
71
48
|
constructor(options) {
|
|
72
49
|
this.sessions = options.sessions;
|
|
73
50
|
this.api = options.api;
|
|
74
51
|
this.connection = options.connection;
|
|
75
52
|
this.config = options.config;
|
|
76
|
-
this.
|
|
53
|
+
this.secureSessionRemote = options.secureSession;
|
|
54
|
+
this.chats = new QaChatIndex(options.storage, `${options.config.session.storageKey}:v1:${options.config.route.path}`);
|
|
77
55
|
this.timeoutMs = options.timeoutMs ?? 15_000;
|
|
56
|
+
this.streamIntervalMs = options.streamIntervalMs ?? 66;
|
|
78
57
|
this.connectedOnce = this.connection.getSnapshot() !== undefined;
|
|
79
58
|
this.unsubscribeConnection = this.connection.subscribe(() => {
|
|
80
59
|
if (this.connection.getSnapshot() !== undefined)
|
|
@@ -97,25 +76,48 @@ export class QaSessionController {
|
|
|
97
76
|
this.ensuring = operation;
|
|
98
77
|
return operation;
|
|
99
78
|
}
|
|
100
|
-
async send(text) {
|
|
79
|
+
async send(text, images = []) {
|
|
101
80
|
const prompt = text.trim();
|
|
102
|
-
if (prompt === ""
|
|
81
|
+
if ((prompt === "" && images.length === 0) || !this.state.canSend) {
|
|
103
82
|
return false;
|
|
104
83
|
}
|
|
105
84
|
if (prompt.startsWith("/")) {
|
|
106
|
-
this.operationError =
|
|
107
|
-
"Slash commands are not available in this assistant view.";
|
|
85
|
+
this.operationError = "Команды со слешем недоступны в режиме помощника.";
|
|
108
86
|
this.publish();
|
|
109
87
|
return false;
|
|
110
88
|
}
|
|
89
|
+
if (this.session === undefined) {
|
|
90
|
+
// A draft chat materializes its session only now: nothing was created
|
|
91
|
+
// when the user pressed "New chat", so the first prompt pays for it.
|
|
92
|
+
if (!this.drafting || this.materializing !== undefined)
|
|
93
|
+
return false;
|
|
94
|
+
if (!(await this.materializeDraft()) || this.session === undefined) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
111
98
|
this.operationError = null;
|
|
99
|
+
let attested = await this.attestPolicy();
|
|
100
|
+
if (!attested) {
|
|
101
|
+
attested = await this.recoverForSend();
|
|
102
|
+
if (!attested)
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
112
105
|
this.admissionPending = true;
|
|
113
106
|
this.publish();
|
|
114
107
|
try {
|
|
115
|
-
const
|
|
108
|
+
const content = [
|
|
109
|
+
...(prompt === "" ? [] : [{ type: "text", text: prompt }]),
|
|
110
|
+
...images.map((image) => ({
|
|
111
|
+
type: "image",
|
|
112
|
+
mediaType: image.mediaType,
|
|
113
|
+
data: image.data,
|
|
114
|
+
name: image.name,
|
|
115
|
+
})),
|
|
116
|
+
];
|
|
117
|
+
const result = await this.session.prompt(content, "queue");
|
|
116
118
|
if (!result.ok) {
|
|
117
119
|
this.admissionPending = false;
|
|
118
|
-
this.operationError = "
|
|
120
|
+
this.operationError = "Не удалось отправить сообщение.";
|
|
119
121
|
this.publish();
|
|
120
122
|
return false;
|
|
121
123
|
}
|
|
@@ -125,27 +127,45 @@ export class QaSessionController {
|
|
|
125
127
|
catch (error) {
|
|
126
128
|
this.admissionPending = false;
|
|
127
129
|
console.error("dsh-qa-surface: prompt failed", error);
|
|
128
|
-
this.operationError = "
|
|
130
|
+
this.operationError = "Не удалось отправить сообщение.";
|
|
129
131
|
this.publish();
|
|
130
132
|
return false;
|
|
131
133
|
}
|
|
132
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Ask for a fresh variant of the previous answer. The session has no
|
|
137
|
+
* truncation seam, so this sends the hidden regeneration instruction as an
|
|
138
|
+
* ordinary prompt: the model produces a follow-up turn, the projection
|
|
139
|
+
* hides the marker message, and the consecutive turns read as variants of
|
|
140
|
+
* the original question (switched in the surface).
|
|
141
|
+
*/
|
|
142
|
+
async regenerate() {
|
|
143
|
+
return this.send(QA_REGENERATE_MARKER);
|
|
144
|
+
}
|
|
133
145
|
async stop() {
|
|
134
146
|
if (this.session === undefined || !this.state.canStop)
|
|
135
147
|
return;
|
|
136
148
|
try {
|
|
137
149
|
const result = await this.session.cancel();
|
|
138
150
|
if (!result.ok)
|
|
139
|
-
this.operationError = "
|
|
151
|
+
this.operationError = "Не удалось остановить ответ.";
|
|
140
152
|
}
|
|
141
153
|
catch (error) {
|
|
142
154
|
console.error("dsh-qa-surface: stop failed", error);
|
|
143
|
-
this.operationError = "
|
|
155
|
+
this.operationError = "Не удалось остановить ответ.";
|
|
144
156
|
}
|
|
145
157
|
this.publish();
|
|
146
158
|
}
|
|
147
|
-
|
|
148
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Enter the "new chat" draft: show an empty composer without creating any
|
|
161
|
+
* session. Nothing appears in the chat list and no Host session is spent
|
|
162
|
+
* until the first prompt is actually sent, which materializes the session
|
|
163
|
+
* lazily ({@link materializeDraft}). Fixed-policy deployments cannot draft.
|
|
164
|
+
*/
|
|
165
|
+
async startDraft() {
|
|
166
|
+
if (this.disposed ||
|
|
167
|
+
this.config.session.policy === "fixed" ||
|
|
168
|
+
(this.config.lockdown.enabled && !this.config.lockdown.allowSessionReset))
|
|
149
169
|
return;
|
|
150
170
|
const previous = this.session;
|
|
151
171
|
if (previous?.getSnapshot().running === true) {
|
|
@@ -153,44 +173,226 @@ export class QaSessionController {
|
|
|
153
173
|
await previous.cancel();
|
|
154
174
|
}
|
|
155
175
|
catch (error) {
|
|
156
|
-
console.error("dsh-qa-surface: stop before
|
|
176
|
+
console.error("dsh-qa-surface: stop before draft failed", error);
|
|
157
177
|
}
|
|
158
178
|
}
|
|
179
|
+
this.drafting = true;
|
|
180
|
+
this.unbind();
|
|
181
|
+
this.operationError = null;
|
|
182
|
+
this.admissionPending = false;
|
|
183
|
+
this.policyReady = false;
|
|
184
|
+
this.state = {
|
|
185
|
+
...QA_SESSION_IDLE_STATE,
|
|
186
|
+
chatsRevision: this.chatsRevision,
|
|
187
|
+
};
|
|
188
|
+
this.publish();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Turn the current draft into a real attested session. Runs inside the
|
|
192
|
+
* send flow; concurrent sends share one materialization. A refusal here is
|
|
193
|
+
* reported at once — a fresh session's first attestation is the
|
|
194
|
+
* deployment's only chance to learn the reason.
|
|
195
|
+
*/
|
|
196
|
+
materializeDraft() {
|
|
197
|
+
if (this.materializing !== undefined)
|
|
198
|
+
return this.materializing;
|
|
159
199
|
const operation = ++this.generation;
|
|
200
|
+
const attempt = (async () => {
|
|
201
|
+
try {
|
|
202
|
+
await this.waitForConnection();
|
|
203
|
+
const id = await this.createSession();
|
|
204
|
+
if (this.disposed || operation !== this.generation)
|
|
205
|
+
return false;
|
|
206
|
+
await this.bind(id);
|
|
207
|
+
if (this.disposed || operation !== this.generation)
|
|
208
|
+
return false;
|
|
209
|
+
this.drafting = false;
|
|
210
|
+
if (this.config.session.policy === "browser-persistent") {
|
|
211
|
+
this.chats.saveActive(id);
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
this.fail(this.operationError === CONFIGURATION_ERROR
|
|
217
|
+
? this.operationError
|
|
218
|
+
: "Не удалось начать чат.", error);
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
})();
|
|
222
|
+
this.materializing = attempt;
|
|
223
|
+
this.publish();
|
|
224
|
+
// `attempt` never rejects: every failure path is caught and reports false.
|
|
225
|
+
void attempt.then(() => {
|
|
226
|
+
if (this.materializing === attempt)
|
|
227
|
+
this.materializing = undefined;
|
|
228
|
+
});
|
|
229
|
+
return attempt;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Open one of this browser's indexed chats. The id must be present in the
|
|
233
|
+
* host session list and pass policy attestation; an unknown id is forgotten
|
|
234
|
+
* from the index. Fixed-policy deployments cannot switch.
|
|
235
|
+
*/
|
|
236
|
+
async switchTo(sessionId) {
|
|
237
|
+
if (this.disposed || this.config.session.policy === "fixed")
|
|
238
|
+
return;
|
|
239
|
+
if (this.session !== undefined &&
|
|
240
|
+
String(this.session.sessionId) === sessionId)
|
|
241
|
+
return;
|
|
242
|
+
const operation = ++this.generation;
|
|
243
|
+
this.drafting = false;
|
|
244
|
+
this.viewingSubagent = null;
|
|
160
245
|
this.unbind();
|
|
161
246
|
this.operationError = null;
|
|
162
247
|
this.admissionPending = false;
|
|
163
|
-
this.
|
|
248
|
+
this.policyReady = false;
|
|
249
|
+
this.state = {
|
|
250
|
+
...QA_SESSION_IDLE_STATE,
|
|
251
|
+
chatsRevision: this.chatsRevision,
|
|
252
|
+
phase: "creating",
|
|
253
|
+
};
|
|
164
254
|
this.emit();
|
|
165
255
|
try {
|
|
166
256
|
await this.waitForConnection();
|
|
167
|
-
const
|
|
257
|
+
const list = await waitFor(this.sessions.list, (snapshot) => snapshot.phase === "ready", this.timeoutMs);
|
|
168
258
|
if (this.disposed || operation !== this.generation)
|
|
169
259
|
return;
|
|
170
|
-
|
|
171
|
-
|
|
260
|
+
if (!Object.hasOwn(list.byId, sessionId)) {
|
|
261
|
+
this.forgetChat(sessionId);
|
|
262
|
+
throw new Error("Этот чат больше недоступен.");
|
|
263
|
+
}
|
|
264
|
+
await this.bind(sessionId);
|
|
265
|
+
if (this.disposed || operation !== this.generation)
|
|
266
|
+
return;
|
|
267
|
+
this.chats.saveActive(sessionId);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
this.fail(this.operationError === CONFIGURATION_ERROR
|
|
271
|
+
? this.operationError
|
|
272
|
+
: "Не удалось открыть этот чат.", error);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Watch a subagent's transcript live. The binding is read-only by
|
|
277
|
+
* construction: no attestation runs (attestation admits sending, and the
|
|
278
|
+
* composer stays disabled here), the chat index is untouched, and
|
|
279
|
+
* {@link closeSubagent} returns to the chat the panel was opened from.
|
|
280
|
+
*/
|
|
281
|
+
async viewSubagent(id, title) {
|
|
282
|
+
if (this.disposed)
|
|
283
|
+
return;
|
|
284
|
+
if (this.viewingSubagent !== null &&
|
|
285
|
+
this.viewingSubagent.id === id &&
|
|
286
|
+
this.session !== undefined) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const operation = ++this.generation;
|
|
290
|
+
this.drafting = false;
|
|
291
|
+
this.viewingSubagent = { id, title };
|
|
292
|
+
this.unbind();
|
|
293
|
+
this.operationError = null;
|
|
294
|
+
this.admissionPending = false;
|
|
295
|
+
this.policyReady = false;
|
|
296
|
+
this.state = {
|
|
297
|
+
...QA_SESSION_IDLE_STATE,
|
|
298
|
+
chatsRevision: this.chatsRevision,
|
|
299
|
+
viewingSubagent: this.viewingSubagent,
|
|
300
|
+
phase: "creating",
|
|
301
|
+
};
|
|
302
|
+
this.emit();
|
|
303
|
+
try {
|
|
304
|
+
await this.waitForConnection();
|
|
305
|
+
await this.bind(id, { attest: false, track: false, report: false });
|
|
172
306
|
}
|
|
173
307
|
catch (error) {
|
|
174
|
-
this.
|
|
308
|
+
this.viewingSubagent = null;
|
|
309
|
+
this.fail("Не удалось открыть транскрипт субагента.", error);
|
|
310
|
+
}
|
|
311
|
+
if (this.disposed || operation !== this.generation)
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
/** Leave the subagent transcript and re-open the chat it was launched from. */
|
|
315
|
+
async closeSubagent() {
|
|
316
|
+
const chat = this.chatSessionId;
|
|
317
|
+
if (chat === null)
|
|
318
|
+
return;
|
|
319
|
+
await this.switchTo(chat);
|
|
320
|
+
}
|
|
321
|
+
/** This browser's indexed chat ids, most recently used first. */
|
|
322
|
+
chatIds() {
|
|
323
|
+
return this.chats.chatIds();
|
|
324
|
+
}
|
|
325
|
+
/** The bound session id, or null while no chat is bound. */
|
|
326
|
+
activeSessionId() {
|
|
327
|
+
return this.session === undefined ? null : String(this.session.sessionId);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Resolve one durable image attachment of the bound session into a
|
|
331
|
+
* browser-usable URL. The caller caches; failures surface as broken views.
|
|
332
|
+
*/
|
|
333
|
+
async readImage(attachmentId) {
|
|
334
|
+
if (this.session === undefined)
|
|
335
|
+
throw new Error("no bound session");
|
|
336
|
+
const result = await this.session.readAttachment(attachmentId);
|
|
337
|
+
if (!result.ok) {
|
|
338
|
+
throw new Error(`${result.error.code}: ${result.error.message}`);
|
|
175
339
|
}
|
|
340
|
+
const { attachment, data } = result.value;
|
|
341
|
+
if (typeof URL.createObjectURL !== "function") {
|
|
342
|
+
return `data:${attachment.mediaType};base64,${bytesToBase64(data)}`;
|
|
343
|
+
}
|
|
344
|
+
return URL.createObjectURL(new Blob([Uint8Array.from(data).buffer], {
|
|
345
|
+
type: attachment.mediaType,
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Remove one chat from this browser's index. Deleting the chat that is
|
|
350
|
+
* currently open also forgets the persisted id and starts a fresh attested
|
|
351
|
+
* session (when the deployment allows session resets); the Host-side
|
|
352
|
+
* session itself is not touched — no host deletion seam exists.
|
|
353
|
+
*/
|
|
354
|
+
async deleteChat(id) {
|
|
355
|
+
this.forgetChat(id);
|
|
356
|
+
this.chatsRevision += 1;
|
|
357
|
+
if (this.activeSessionId() === id &&
|
|
358
|
+
this.config.session.policy !== "fixed") {
|
|
359
|
+
// No replacement session is created: deleting the open chat falls back
|
|
360
|
+
// to a draft, and the next prompt materializes a fresh one. The stale
|
|
361
|
+
// persisted id is dropped so a reload cannot resurrect the deleted chat.
|
|
362
|
+
this.chats.clearActive();
|
|
363
|
+
await this.startDraft();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
this.publish();
|
|
367
|
+
}
|
|
368
|
+
/** Drop one chat id from this browser's index. */
|
|
369
|
+
forgetChat(sessionId) {
|
|
370
|
+
this.chats.forgetChat(sessionId);
|
|
176
371
|
}
|
|
177
372
|
dispose() {
|
|
178
373
|
if (this.disposed)
|
|
179
374
|
return;
|
|
180
375
|
this.disposed = true;
|
|
181
376
|
this.generation += 1;
|
|
377
|
+
this.clearStreamFlush();
|
|
182
378
|
this.unsubscribeConnection();
|
|
183
379
|
this.unbind();
|
|
184
380
|
this.listeners.clear();
|
|
185
381
|
}
|
|
186
382
|
async ensureSessionNow() {
|
|
187
383
|
const operation = ++this.generation;
|
|
188
|
-
this.
|
|
384
|
+
this.drafting = false;
|
|
385
|
+
this.state = {
|
|
386
|
+
...QA_SESSION_IDLE_STATE,
|
|
387
|
+
chatsRevision: this.chatsRevision,
|
|
388
|
+
phase: "creating",
|
|
389
|
+
};
|
|
189
390
|
this.emit();
|
|
190
391
|
try {
|
|
191
392
|
await this.waitForConnection();
|
|
192
393
|
const list = await waitFor(this.sessions.list, (snapshot) => snapshot.phase === "ready", this.timeoutMs);
|
|
193
394
|
let id = null;
|
|
395
|
+
let restored = false;
|
|
194
396
|
if (this.config.session.policy === "fixed") {
|
|
195
397
|
id = this.config.session.fixedSessionId;
|
|
196
398
|
if (id === null || !Object.hasOwn(list.byId, id)) {
|
|
@@ -198,40 +400,133 @@ export class QaSessionController {
|
|
|
198
400
|
}
|
|
199
401
|
}
|
|
200
402
|
else if (this.config.session.policy === "browser-persistent") {
|
|
201
|
-
const stored = this.
|
|
403
|
+
const stored = this.chats.activeId();
|
|
202
404
|
if (stored !== null && Object.hasOwn(list.byId, stored)) {
|
|
203
405
|
id = stored;
|
|
406
|
+
restored = true;
|
|
204
407
|
}
|
|
205
408
|
else if (stored !== null) {
|
|
206
|
-
this.
|
|
409
|
+
this.chats.clearActive();
|
|
207
410
|
}
|
|
208
411
|
}
|
|
209
412
|
if (id === null) {
|
|
210
413
|
id = await this.createSession();
|
|
211
|
-
if (this.config.session.policy === "browser-persistent")
|
|
212
|
-
this.persist(id);
|
|
213
414
|
}
|
|
214
415
|
if (this.disposed || operation !== this.generation)
|
|
215
416
|
return;
|
|
216
|
-
|
|
417
|
+
// A restored id's first bind stays quiet: the recovery path below may
|
|
418
|
+
// replace it. A freshly created session must report a refusal at once —
|
|
419
|
+
// it is the deployment's only chance to learn the reason.
|
|
420
|
+
try {
|
|
421
|
+
await this.bind(id, { report: !restored });
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
if (!restored ||
|
|
425
|
+
this.config.session.policy !== "browser-persistent" ||
|
|
426
|
+
!(error instanceof QaPolicyAttestationError)) {
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
// A stored id is only a hint. If Host refuses that session under the
|
|
430
|
+
// current QA policy (for example after the configured preset changes),
|
|
431
|
+
// forget it and bootstrap one fresh, policy-attested session instead.
|
|
432
|
+
this.unbind();
|
|
433
|
+
this.operationError = null;
|
|
434
|
+
this.chats.clearActive();
|
|
435
|
+
id = await this.createSession();
|
|
436
|
+
if (this.disposed || operation !== this.generation)
|
|
437
|
+
return;
|
|
438
|
+
await this.bind(id);
|
|
439
|
+
}
|
|
440
|
+
if (this.disposed || operation !== this.generation)
|
|
441
|
+
return;
|
|
442
|
+
if (this.config.session.policy === "browser-persistent") {
|
|
443
|
+
this.chats.saveActive(id);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
this.fail(this.operationError === CONFIGURATION_ERROR
|
|
448
|
+
? this.operationError
|
|
449
|
+
: "Не удалось начать чат.", error);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Recovery ladder after a refused send-time attestation. The prompt was
|
|
454
|
+
* never admitted, so nothing here can duplicate it. Step 1 re-opens the
|
|
455
|
+
* session: a Host that idled the agent out dismantles its tool view, and
|
|
456
|
+
* re-opening re-materializes it. Step 2 — when that refused session is
|
|
457
|
+
* still blank (it never received the prompt, so there is nothing to lose) —
|
|
458
|
+
* starts a fresh attested session instead. A session with history is left
|
|
459
|
+
* alone: recreating it would silently drop the conversation.
|
|
460
|
+
*/
|
|
461
|
+
async recoverForSend() {
|
|
462
|
+
const id = this.session === undefined ? null : String(this.session.sessionId);
|
|
463
|
+
if (id === null ||
|
|
464
|
+
this.disposed ||
|
|
465
|
+
this.config.session.policy === "fixed") {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
const operation = ++this.generation;
|
|
469
|
+
this.unbind();
|
|
470
|
+
this.admissionPending = false;
|
|
471
|
+
this.policyReady = false;
|
|
472
|
+
this.state = { ...this.state, phase: "creating" };
|
|
473
|
+
this.emit();
|
|
474
|
+
try {
|
|
475
|
+
await this.waitForConnection();
|
|
476
|
+
try {
|
|
477
|
+
await this.bind(id);
|
|
478
|
+
if (this.disposed || operation !== this.generation)
|
|
479
|
+
return false;
|
|
480
|
+
if (this.policyReady)
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
if (!(error instanceof QaPolicyAttestationError))
|
|
485
|
+
throw error;
|
|
486
|
+
if (this.disposed || operation !== this.generation)
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
if (this.session !== undefined && !this.session.getSnapshot().blank) {
|
|
490
|
+
this.fail(CONFIGURATION_ERROR, new QaPolicyAttestationError());
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
this.unbind();
|
|
494
|
+
this.chats.clearActive();
|
|
495
|
+
const created = await this.createSession();
|
|
496
|
+
if (this.disposed || operation !== this.generation)
|
|
497
|
+
return false;
|
|
498
|
+
await this.bind(created);
|
|
499
|
+
if (this.disposed || operation !== this.generation)
|
|
500
|
+
return false;
|
|
501
|
+
this.chats.saveActive(created);
|
|
502
|
+
return this.policyReady;
|
|
217
503
|
}
|
|
218
504
|
catch (error) {
|
|
219
|
-
this.fail(
|
|
505
|
+
this.fail(this.operationError === CONFIGURATION_ERROR
|
|
506
|
+
? this.operationError
|
|
507
|
+
: "Не удалось открыть этот чат.", error);
|
|
508
|
+
return false;
|
|
220
509
|
}
|
|
221
510
|
}
|
|
222
511
|
async createSession() {
|
|
223
|
-
const
|
|
224
|
-
...(this.config.session.workspaceId
|
|
225
|
-
? {}
|
|
226
|
-
:
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
: { agentPreset: this.config.session.agentPreset }),
|
|
512
|
+
const created = await this.sessions.create({
|
|
513
|
+
...(this.config.session.workspaceId !== null
|
|
514
|
+
? { workspaceId: this.config.session.workspaceId }
|
|
515
|
+
: this.config.session.cwd !== null
|
|
516
|
+
? { cwd: this.config.session.cwd }
|
|
517
|
+
: {}),
|
|
230
518
|
});
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
519
|
+
const id = String(created);
|
|
520
|
+
if (this.config.session.agentPreset !== null) {
|
|
521
|
+
const selectedPreset = await this.api.selectAgentPreset({
|
|
522
|
+
sessionId: id,
|
|
523
|
+
agentPreset: this.config.session.agentPreset,
|
|
524
|
+
});
|
|
525
|
+
if (!selectedPreset.result.ok) {
|
|
526
|
+
throw new Error(selectedPreset.result.error.code);
|
|
527
|
+
}
|
|
528
|
+
this.sessions.noteAgentPreset(id, this.config.session.agentPreset);
|
|
529
|
+
}
|
|
235
530
|
if (this.config.session.provider !== null &&
|
|
236
531
|
this.config.session.model !== null) {
|
|
237
532
|
const selected = await this.api.selectModel({
|
|
@@ -247,7 +542,8 @@ export class QaSessionController {
|
|
|
247
542
|
}
|
|
248
543
|
return id;
|
|
249
544
|
}
|
|
250
|
-
async bind(id) {
|
|
545
|
+
async bind(id, options = {}) {
|
|
546
|
+
const { report = true, attest = true, track = true } = options;
|
|
251
547
|
this.sessions.open(id);
|
|
252
548
|
let binding = this.sessions.binding(id);
|
|
253
549
|
if (binding === undefined) {
|
|
@@ -258,26 +554,88 @@ export class QaSessionController {
|
|
|
258
554
|
throw new Error("Session binding is unavailable.");
|
|
259
555
|
this.unbind();
|
|
260
556
|
this.session = binding.session;
|
|
557
|
+
this.policyReady = false;
|
|
261
558
|
this.unsubscribeSession = binding.session.subscribe(() => {
|
|
262
559
|
this.admissionPending = false;
|
|
263
560
|
this.operationError = null;
|
|
264
|
-
this.
|
|
561
|
+
this.publishSessionUpdate();
|
|
265
562
|
});
|
|
563
|
+
await waitFor(binding.session, (snapshot) => snapshot.openState === "open" || snapshot.openState === "error", this.timeoutMs);
|
|
564
|
+
if (binding.session.getSnapshot().openState !== "open") {
|
|
565
|
+
throw new Error("Session binding could not be opened.");
|
|
566
|
+
}
|
|
567
|
+
if (attest && !(await this.attestPolicy(report))) {
|
|
568
|
+
throw new QaPolicyAttestationError();
|
|
569
|
+
}
|
|
570
|
+
if (track) {
|
|
571
|
+
this.chatSessionId = String(this.session.sessionId);
|
|
572
|
+
this.viewingSubagent = null;
|
|
573
|
+
this.chats.addChat(String(this.session.sessionId));
|
|
574
|
+
}
|
|
266
575
|
this.publish();
|
|
267
576
|
}
|
|
268
577
|
unbind() {
|
|
578
|
+
this.clearStreamFlush();
|
|
269
579
|
this.unsubscribeSession?.();
|
|
270
580
|
this.unsubscribeSession = undefined;
|
|
271
581
|
this.session = undefined;
|
|
272
582
|
this.admissionPending = false;
|
|
583
|
+
this.policyReady = false;
|
|
273
584
|
}
|
|
274
585
|
waitForConnection() {
|
|
275
586
|
return waitFor(this.connection, (description) => description !== undefined, this.timeoutMs);
|
|
276
587
|
}
|
|
588
|
+
/**
|
|
589
|
+
* Project one session notification. A running turn's frames arrive at
|
|
590
|
+
* animation-frame cadence, so re-projections are spaced at least
|
|
591
|
+
* {@link streamIntervalMs} apart: the first frame of a window projects at
|
|
592
|
+
* once and further frames are absorbed (the memoized render path replays
|
|
593
|
+
* nothing, so absorbing a frame only defers it). Everything outside a
|
|
594
|
+
* running turn - phase flips, errors, turn completion - projects
|
|
595
|
+
* immediately.
|
|
596
|
+
*/
|
|
597
|
+
publishSessionUpdate() {
|
|
598
|
+
if (this.streamIntervalMs <= 0 ||
|
|
599
|
+
this.session?.getSnapshot().running !== true) {
|
|
600
|
+
this.clearStreamFlush();
|
|
601
|
+
this.publish();
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (this.streamFlushTimer !== undefined)
|
|
605
|
+
return;
|
|
606
|
+
this.publish();
|
|
607
|
+
this.streamFlushTimer = setTimeout(() => {
|
|
608
|
+
this.streamFlushTimer = undefined;
|
|
609
|
+
}, this.streamIntervalMs);
|
|
610
|
+
}
|
|
611
|
+
clearStreamFlush() {
|
|
612
|
+
if (this.streamFlushTimer === undefined)
|
|
613
|
+
return;
|
|
614
|
+
clearTimeout(this.streamFlushTimer);
|
|
615
|
+
this.streamFlushTimer = undefined;
|
|
616
|
+
}
|
|
277
617
|
publish() {
|
|
278
618
|
if (this.disposed)
|
|
279
619
|
return;
|
|
280
620
|
const connected = this.connection.getSnapshot() !== undefined;
|
|
621
|
+
if (this.drafting && this.session === undefined) {
|
|
622
|
+
// Draft state: an empty writable composer without a bound session. The
|
|
623
|
+
// session list is untouched — nothing exists until the first send.
|
|
624
|
+
const materializing = this.materializing !== undefined;
|
|
625
|
+
this.state = {
|
|
626
|
+
...QA_SESSION_IDLE_STATE,
|
|
627
|
+
phase: materializing
|
|
628
|
+
? "creating"
|
|
629
|
+
: !connected && this.connectedOnce
|
|
630
|
+
? "reconnecting"
|
|
631
|
+
: "idle",
|
|
632
|
+
error: this.operationError,
|
|
633
|
+
canSend: connected && !materializing,
|
|
634
|
+
chatsRevision: this.chatsRevision,
|
|
635
|
+
};
|
|
636
|
+
this.emit();
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
281
639
|
if (this.session === undefined) {
|
|
282
640
|
this.state = {
|
|
283
641
|
...this.state,
|
|
@@ -291,9 +649,9 @@ export class QaSessionController {
|
|
|
291
649
|
const blocked = snapshot.pending.length > 0;
|
|
292
650
|
const error = this.operationError ??
|
|
293
651
|
(blocked
|
|
294
|
-
? "
|
|
652
|
+
? "Для этого запроса нужно действие, недоступное в режиме помощника."
|
|
295
653
|
: snapshot.removed || snapshot.openState === "error"
|
|
296
|
-
? "
|
|
654
|
+
? "Этот чат больше недоступен."
|
|
297
655
|
: null);
|
|
298
656
|
const phase = !connected
|
|
299
657
|
? "reconnecting"
|
|
@@ -311,51 +669,75 @@ export class QaSessionController {
|
|
|
311
669
|
sessionId: String(this.session.sessionId),
|
|
312
670
|
messages: projectTranscript(snapshot, {
|
|
313
671
|
showToolActivity: this.config.ui.showToolActivity,
|
|
672
|
+
showReasoning: this.config.ui.showReasoning,
|
|
314
673
|
}),
|
|
315
674
|
error,
|
|
316
|
-
canSend: connected && phase === "ready",
|
|
675
|
+
canSend: connected && phase === "ready" && this.policyReady,
|
|
317
676
|
canStop: connected && snapshot.running && this.config.ui.showStop && !blocked,
|
|
677
|
+
chatsRevision: this.chatsRevision,
|
|
678
|
+
sources: projectSources(snapshot),
|
|
679
|
+
viewingSubagent: this.viewingSubagent,
|
|
318
680
|
};
|
|
319
681
|
this.emit();
|
|
320
682
|
}
|
|
321
683
|
fail(message, error) {
|
|
322
|
-
|
|
684
|
+
// Policy attestation refusals already logged their precise reason; a
|
|
685
|
+
// second stack trace for the wrapper error is only console noise.
|
|
686
|
+
if (!(error instanceof QaPolicyAttestationError)) {
|
|
687
|
+
console.error("dsh-qa-surface: session operation failed", error);
|
|
688
|
+
}
|
|
323
689
|
if (this.disposed)
|
|
324
690
|
return;
|
|
325
691
|
this.operationError = message;
|
|
326
692
|
this.state = {
|
|
327
|
-
...
|
|
693
|
+
...QA_SESSION_IDLE_STATE,
|
|
328
694
|
phase: "error",
|
|
329
695
|
error: message,
|
|
696
|
+
chatsRevision: this.chatsRevision,
|
|
330
697
|
};
|
|
331
698
|
this.emit();
|
|
332
699
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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.
|
|
700
|
+
async attestPolicy(reportFailure = true) {
|
|
701
|
+
if (this.session === undefined)
|
|
702
|
+
return false;
|
|
703
|
+
if (!this.config.lockdown.enabled) {
|
|
704
|
+
this.policyReady = true;
|
|
705
|
+
return true;
|
|
351
706
|
}
|
|
352
|
-
|
|
353
|
-
|
|
707
|
+
this.policyReady = false;
|
|
708
|
+
this.publish();
|
|
709
|
+
// One precise console diagnostic per refusal: the coarse Host reason code
|
|
710
|
+
// plus an operator hint. The QA-facing message stays generic by design.
|
|
711
|
+
const reject = (reason) => {
|
|
712
|
+
if (reportFailure) {
|
|
713
|
+
console.error(`dsh-qa-surface: policy attestation failed (reason: ${reason ?? "unknown"}). ${attestationHint(reason)}`);
|
|
714
|
+
}
|
|
715
|
+
this.policyReady = false;
|
|
716
|
+
this.operationError = CONFIGURATION_ERROR;
|
|
717
|
+
this.publish();
|
|
718
|
+
return false;
|
|
719
|
+
};
|
|
354
720
|
try {
|
|
355
|
-
this.
|
|
721
|
+
const result = await this.secureSessionRemote(String(this.session.sessionId));
|
|
722
|
+
if (!result.ok) {
|
|
723
|
+
return reject(attestationReasonOf(result.error));
|
|
724
|
+
}
|
|
725
|
+
if (!proofMatchesConfig(result.value, this.config.lockdown, String(this.session.sessionId))) {
|
|
726
|
+
return reject("proof-mismatch");
|
|
727
|
+
}
|
|
728
|
+
this.policyReady = true;
|
|
729
|
+
this.operationError = null;
|
|
730
|
+
this.publish();
|
|
731
|
+
return true;
|
|
356
732
|
}
|
|
357
|
-
catch {
|
|
358
|
-
|
|
733
|
+
catch (error) {
|
|
734
|
+
if (reportFailure) {
|
|
735
|
+
console.error("dsh-qa-surface: policy attestation request failed", error);
|
|
736
|
+
}
|
|
737
|
+
this.policyReady = false;
|
|
738
|
+
this.operationError = CONFIGURATION_ERROR;
|
|
739
|
+
this.publish();
|
|
740
|
+
return false;
|
|
359
741
|
}
|
|
360
742
|
}
|
|
361
743
|
emit() {
|