@ahggg/dsh-side-chat 0.7.3 → 1.0.1
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 +52 -20
- package/README.zh-CN.md +52 -20
- package/lib/client/apply.js +48 -32
- package/lib/client/contracts.d.ts +3 -58
- package/lib/client/dsh/ReadOnlyConversation.d.ts +10 -0
- package/lib/client/dsh/ReadOnlyConversation.js +99 -0
- package/lib/client/dsh/SideChatOverlay.d.ts +7 -0
- package/lib/client/{rc6/Rc6SideChatOverlay.js → dsh/SideChatOverlay.js} +26 -56
- package/lib/client/{rc6 → dsh}/context.d.ts +4 -6
- package/lib/client/dsh/conversation-store.d.ts +22 -0
- package/lib/client/dsh/conversation-store.js +19 -0
- package/lib/client/dsh/primitive-labels.d.ts +2 -0
- package/lib/client/dsh/primitive-labels.js +4 -0
- package/lib/client/{rc6 → dsh}/remote-adapter.d.ts +2 -2
- package/lib/client/dsh/remote-adapter.js +22 -0
- package/lib/client/dsh/sessions-adapter.d.ts +56 -0
- package/lib/client/dsh/sessions-adapter.js +185 -0
- package/lib/client/index.d.ts +2 -2
- package/lib/client/panel/SelectionQuote.js +8 -1
- package/lib/client/panel/SideChatErrorState.js +1 -1
- package/lib/client/panel/SideChatModelSelect.d.ts +2 -1
- package/lib/client/panel/SideChatModelSelect.js +43 -24
- package/lib/client/panel/SideChatPanel.js +12 -5
- package/lib/client/panel/messages.d.ts +1 -1
- package/lib/client/panel/messages.js +8 -8
- package/lib/client/parent-composer/ParentConversationAnnotations.d.ts +3 -2
- package/lib/client/parent-composer/ParentConversationAnnotations.js +50 -30
- package/lib/client/parent-composer/add-to-conversation.d.ts +14 -28
- package/lib/client/parent-composer/add-to-conversation.js +131 -545
- package/lib/client/parent-composer/annotation-persistence.d.ts +8 -8
- package/lib/client/parent-composer/annotation-persistence.js +106 -102
- package/lib/client/parent-composer/composer-reference.d.ts +17 -55
- package/lib/client/parent-composer/composer-reference.js +20 -72
- package/lib/client/parent-composer/referenced-conversation.d.ts +5 -7
- package/lib/client/parent-composer/referenced-conversation.js +21 -73
- package/lib/client/selection/ConversationAnnotationMarkers.js +5 -1
- package/lib/client/selection/SelectionActions.js +2 -1
- package/lib/client/selection/conversation-dom.d.ts +5 -0
- package/lib/client/selection/conversation-dom.js +24 -0
- package/lib/client/selection/selection-controller.d.ts +1 -1
- package/lib/client/selection/selection-controller.js +7 -3
- package/lib/client/selection/selection-normalizer.js +1 -1
- package/lib/client/side-chat-controller.d.ts +28 -41
- package/lib/client/side-chat-controller.js +247 -337
- package/lib/client.js +84 -54
- package/lib/host/index.d.ts +1 -1
- package/lib/host/index.js +1 -1
- package/lib/host/read-only-chat-service.d.ts +30 -0
- package/lib/host/read-only-chat-service.js +315 -0
- package/lib/index.d.ts +16 -8
- package/lib/index.js +34 -13
- package/lib/remote.d.ts +23 -14
- package/lib/remote.js +2 -5
- package/lib/shared/constants.d.ts +0 -13
- package/lib/shared/constants.js +0 -13
- package/lib/shared/contracts.d.ts +52 -19
- package/lib/shared/contracts.js +2 -3
- package/lib/shared/error-codes.d.ts +1 -1
- package/lib/shared/error-codes.js +2 -2
- package/lib/shared/{archived-wire.d.ts → side-chat-wire.d.ts} +109 -20
- package/lib/shared/side-chat-wire.js +34 -0
- package/lib/typert.d.ts +3 -68
- package/lib/typert.js +60 -4
- package/package.json +76 -56
- package/lib/client/rc6/ArchivedConversation.d.ts +0 -14
- package/lib/client/rc6/ArchivedConversation.js +0 -231
- package/lib/client/rc6/Rc6SideChatOverlay.d.ts +0 -7
- package/lib/client/rc6/SideChatTool.d.ts +0 -22
- package/lib/client/rc6/SideChatTool.js +0 -404
- package/lib/client/rc6/primitive-labels.d.ts +0 -7
- package/lib/client/rc6/primitive-labels.js +0 -60
- package/lib/client/rc6/remote-adapter.js +0 -34
- package/lib/client/rc6/runtime-compat.d.ts +0 -120
- package/lib/client/rc6/runtime-compat.js +0 -187
- package/lib/client/rc6/sessions-adapter.d.ts +0 -64
- package/lib/client/rc6/sessions-adapter.js +0 -387
- package/lib/host/archived-fork-service.d.ts +0 -28
- package/lib/host/archived-fork-service.js +0 -309
- package/lib/shared/archived-wire.js +0 -47
- /package/lib/client/{rc6 → dsh}/context.js +0 -0
|
@@ -1,37 +1,26 @@
|
|
|
1
1
|
import { ObservableValue } from '../shared/observable.js';
|
|
2
|
-
import { buildSideChatPrompt } from './parent-composer/add-to-conversation.js';
|
|
3
2
|
import { assertSelectionCurrent } from './selection/selection-normalizer.js';
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
function failure(error) {
|
|
9
|
-
return { ok: false, error };
|
|
10
|
-
}
|
|
11
|
-
function localError(code, message, recoverable = true) {
|
|
3
|
+
const INITIAL = Object.freeze({ phase: 'closed', draft: '', messages: [] });
|
|
4
|
+
const success = (value) => ({ ok: true, value });
|
|
5
|
+
const failure = (error) => ({ ok: false, error });
|
|
6
|
+
function localError(message, code = 'invalid_request', recoverable = false) {
|
|
12
7
|
return { code, message, recoverable };
|
|
13
8
|
}
|
|
14
|
-
|
|
15
|
-
switch (snapshot.status) {
|
|
16
|
-
case 'idle': return 'ready';
|
|
17
|
-
case 'running': return 'running';
|
|
18
|
-
case 'needs-input': return 'needs-input';
|
|
19
|
-
case 'needs-approval': return 'needs-approval';
|
|
20
|
-
case 'failed': return 'error';
|
|
21
|
-
case 'interrupted': return 'ready';
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
/** Small controller for the stock rc.6 archived-fork path. */
|
|
9
|
+
/** Owns only plugin UI state and a cancellable model stream, never a child Session. */
|
|
25
10
|
export class SideChatController {
|
|
26
11
|
remote;
|
|
27
12
|
sessions;
|
|
28
|
-
observable = new ObservableValue(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
closing;
|
|
33
|
-
closeRequested = false;
|
|
13
|
+
observable = new ObservableValue(INITIAL, 'dsh-side-chat');
|
|
14
|
+
generation = 0;
|
|
15
|
+
modelGeneration = 0;
|
|
16
|
+
requestSequence = 0;
|
|
34
17
|
disposed = false;
|
|
18
|
+
releaseParent;
|
|
19
|
+
creating;
|
|
20
|
+
closing;
|
|
21
|
+
running;
|
|
22
|
+
cancelling = false;
|
|
23
|
+
pendingRequest;
|
|
35
24
|
constructor(remote, sessions) {
|
|
36
25
|
this.remote = remote;
|
|
37
26
|
this.sessions = sessions;
|
|
@@ -40,306 +29,159 @@ export class SideChatController {
|
|
|
40
29
|
subscribe = (listener) => this.observable.subscribe(listener);
|
|
41
30
|
openDraft(input = {}) {
|
|
42
31
|
if (this.disposed)
|
|
43
|
-
return failure(localError('
|
|
44
|
-
if (this.getSnapshot().phase !== 'closed')
|
|
45
|
-
return failure(localError('
|
|
46
|
-
}
|
|
32
|
+
return failure(localError('Side Chat has been disposed.'));
|
|
33
|
+
if (this.getSnapshot().phase !== 'closed')
|
|
34
|
+
return failure(localError('Close the current Side Chat first.', 'side_chat_already_open'));
|
|
47
35
|
const parentSessionId = input.parentSessionId ?? this.sessions.currentSessionId();
|
|
48
|
-
if (parentSessionId === undefined)
|
|
49
|
-
return failure(localError('
|
|
50
|
-
}
|
|
36
|
+
if (parentSessionId === undefined)
|
|
37
|
+
return failure(localError('Start the main conversation first.', 'parent_session_missing'));
|
|
51
38
|
try {
|
|
52
39
|
if (input.selection !== undefined)
|
|
53
40
|
assertSelectionCurrent(input.selection, parentSessionId);
|
|
41
|
+
this.releaseParent = this.sessions.retainParent(parentSessionId);
|
|
54
42
|
}
|
|
55
|
-
catch (
|
|
56
|
-
return failure(localError(
|
|
43
|
+
catch (cause) {
|
|
44
|
+
return failure(localError(cause instanceof Error ? cause.message : 'The parent conversation is unavailable.', 'selection_stale'));
|
|
57
45
|
}
|
|
58
|
-
|
|
59
|
-
this.publish({
|
|
60
|
-
|
|
61
|
-
parentSessionId,
|
|
62
|
-
...(input.selection === undefined ? {} : { selection: input.selection }),
|
|
63
|
-
...(modelSelection === undefined ? {} : { modelSelection }),
|
|
64
|
-
draft: input.draft ?? '',
|
|
65
|
-
});
|
|
46
|
+
++this.generation;
|
|
47
|
+
this.observable.publish({ phase: 'draft', parentSessionId, selection: input.selection,
|
|
48
|
+
modelSelection: this.sessions.sideChatModelPreference(), draft: input.draft ?? '', messages: [] });
|
|
66
49
|
return success(undefined);
|
|
67
50
|
}
|
|
68
51
|
setDraft(draft) {
|
|
69
52
|
const state = this.getSnapshot();
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
return failure(localError('invalid_request', 'The draft is not editable right now.', false));
|
|
53
|
+
if (!['draft', 'error', 'ready'].includes(state.phase) || state.messages.length > 0 || state.error?.operation === 'close') {
|
|
54
|
+
return failure(localError('The draft is not editable right now.'));
|
|
73
55
|
}
|
|
74
|
-
this.publish({ ...state, draft });
|
|
56
|
+
this.observable.publish({ ...state, draft });
|
|
75
57
|
return success(undefined);
|
|
76
58
|
}
|
|
77
59
|
clearSelection() {
|
|
78
60
|
const state = this.getSnapshot();
|
|
79
|
-
if (state.
|
|
80
|
-
return
|
|
81
|
-
|
|
82
|
-
return failure(localError('invalid_request', 'The selected passage has already been sent.', false));
|
|
83
|
-
}
|
|
84
|
-
this.publish({ ...state, selection: undefined });
|
|
61
|
+
if (state.chatId !== undefined || !['draft', 'error'].includes(state.phase))
|
|
62
|
+
return failure(localError('The selected context is already captured.'));
|
|
63
|
+
this.observable.publish({ ...state, selection: undefined });
|
|
85
64
|
return success(undefined);
|
|
86
65
|
}
|
|
87
|
-
initializeModel(
|
|
66
|
+
initializeModel(model) {
|
|
88
67
|
const state = this.getSnapshot();
|
|
89
|
-
if (state.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
const selected = { ...selection };
|
|
94
|
-
this.publish({ ...state, modelSelection: selected });
|
|
95
|
-
return success(selected);
|
|
68
|
+
if (state.chatId !== undefined || !['draft', 'error'].includes(state.phase))
|
|
69
|
+
return failure(localError('The model cannot be initialized now.'));
|
|
70
|
+
this.observable.publish({ ...state, modelSelection: { ...model } });
|
|
71
|
+
return success({ ...model });
|
|
96
72
|
}
|
|
97
|
-
async selectModel(
|
|
73
|
+
async selectModel(model) {
|
|
98
74
|
const state = this.getSnapshot();
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
const result = await this.invoke(() => this.remote.selectModel({
|
|
112
|
-
childSessionId,
|
|
113
|
-
...selected,
|
|
114
|
-
}));
|
|
75
|
+
if (this.cancelling || !['draft', 'error', 'ready', 'running'].includes(state.phase) || state.error?.operation === 'close')
|
|
76
|
+
return failure(localError('The model cannot be changed while Side Chat is opening, stopping, or closing.'));
|
|
77
|
+
if (state.chatId === undefined) {
|
|
78
|
+
const result = this.initializeModel(model);
|
|
79
|
+
if (result.ok)
|
|
80
|
+
this.sessions.rememberSideChatModelPreference(result.value);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
const generation = this.generation;
|
|
84
|
+
const version = ++this.modelGeneration;
|
|
85
|
+
const result = await this.invoke(() => this.remote.selectModel({ chatId: state.chatId, ...model }));
|
|
115
86
|
if (!result.ok)
|
|
116
|
-
return
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
this.publish({ ...latest, modelSelection: result.value.selected });
|
|
87
|
+
return result;
|
|
88
|
+
if (generation === this.generation && version === this.modelGeneration) {
|
|
89
|
+
this.observable.publish({ ...this.getSnapshot(), modelSelection: result.value.selected });
|
|
120
90
|
this.sessions.rememberSideChatModelPreference(result.value.selected);
|
|
121
91
|
}
|
|
122
92
|
return success(result.value.selected);
|
|
123
93
|
}
|
|
124
94
|
async sendFirst(question) {
|
|
125
|
-
if (this.opening !== undefined) {
|
|
126
|
-
return failure(localError('invalid_request', 'The Side Chat is already opening.', false));
|
|
127
|
-
}
|
|
128
95
|
const state = this.getSnapshot();
|
|
129
|
-
const
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
this.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
96
|
+
const text = question.trim();
|
|
97
|
+
if (text.length === 0 || state.parentSessionId === undefined || state.messages.length > 0
|
|
98
|
+
|| !['draft', 'error', 'ready'].includes(state.phase) || state.error?.operation === 'close') {
|
|
99
|
+
return failure(localError('Enter a question in an open Side Chat draft.'));
|
|
100
|
+
}
|
|
101
|
+
if (state.chatId === undefined) {
|
|
102
|
+
if (state.selection !== undefined && !this.sessions.selectionIsCurrent(state.selection)) {
|
|
103
|
+
return this.fail(localError('Select the passage again before sending.', 'selection_stale'), 'create');
|
|
104
|
+
}
|
|
105
|
+
const atSeq = state.selection?.atSeq ?? this.sessions.lastCompletedSeq(state.parentSessionId);
|
|
106
|
+
if (atSeq === undefined)
|
|
107
|
+
return this.fail(localError('Wait for a completed parent turn.', 'parent_session_not_ready', true), 'create');
|
|
108
|
+
const generation = this.generation;
|
|
109
|
+
this.observable.publish({ ...state, phase: 'creating', draft: text, error: undefined });
|
|
110
|
+
const operation = this.invoke(() => this.remote.create({
|
|
111
|
+
parentSessionId: state.parentSessionId, atSeq,
|
|
112
|
+
selectedText: state.selection?.text, modelSelection: state.modelSelection,
|
|
113
|
+
}));
|
|
114
|
+
this.creating = operation;
|
|
115
|
+
const result = await operation;
|
|
116
|
+
if (this.creating === operation)
|
|
117
|
+
this.creating = undefined;
|
|
118
|
+
if (generation !== this.generation)
|
|
119
|
+
return failure(localError('The Side Chat was closed.'));
|
|
120
|
+
if (!result.ok)
|
|
121
|
+
return this.fail(result.error, 'create');
|
|
122
|
+
this.observable.publish({ ...this.getSnapshot(), phase: 'ready', chatId: result.value.chatId,
|
|
123
|
+
boundarySeq: result.value.boundarySeq, modelSelection: result.value.modelSelection, error: undefined });
|
|
124
|
+
this.sessions.rememberSideChatModelPreference(result.value.modelSelection);
|
|
125
|
+
}
|
|
126
|
+
return await this.send(text);
|
|
127
|
+
}
|
|
128
|
+
/** Resolves at admission; the independently consumed stream continues to update the transcript. */
|
|
129
|
+
async send(question) {
|
|
130
|
+
const state = this.getSnapshot();
|
|
131
|
+
const text = question.trim();
|
|
132
|
+
if (text.length === 0 || state.chatId === undefined || this.running !== undefined || this.cancelling
|
|
133
|
+
|| !['ready', 'error'].includes(state.phase) || state.error?.operation === 'close') {
|
|
134
|
+
return failure(localError('Wait for the current reply or stop it before sending.'));
|
|
135
|
+
}
|
|
136
|
+
const request = this.pendingRequest?.chatId === state.chatId && this.pendingRequest.text === text
|
|
137
|
+
? this.pendingRequest : { chatId: state.chatId, requestId: `${state.chatId}:${++this.requestSequence}`, text };
|
|
138
|
+
this.pendingRequest = request;
|
|
139
|
+
let stream;
|
|
152
140
|
try {
|
|
153
|
-
|
|
141
|
+
stream = this.remote.stream(request);
|
|
154
142
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
this.opening = undefined;
|
|
143
|
+
catch (cause) {
|
|
144
|
+
return this.fail(localError(String(cause), 'transport_error', true), 'prompt');
|
|
158
145
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return await this.sendParts([{ type: 'text', text: trimmed }], mode);
|
|
165
|
-
}
|
|
166
|
-
async sendParts(content, mode = 'queue') {
|
|
167
|
-
if (content.length === 0)
|
|
168
|
-
return failure(localError('invalid_request', 'Enter a message.', false));
|
|
169
|
-
const binding = this.bindingTarget();
|
|
170
|
-
if (!binding.ok)
|
|
171
|
-
return binding;
|
|
172
|
-
const result = await this.invoke(() => binding.value.prompt(content, mode));
|
|
173
|
-
if (this.getSnapshot().phase === 'closing' || this.getSnapshot().phase === 'closed') {
|
|
174
|
-
return failure(localError('transport_error', 'The Side Chat was closed.', false));
|
|
175
|
-
}
|
|
176
|
-
if (!result.ok)
|
|
177
|
-
this.fail(result.error, 'prompt');
|
|
178
|
-
return result.ok ? success(undefined) : failure(result.error);
|
|
179
|
-
}
|
|
180
|
-
async updateQueue(itemId, action) {
|
|
181
|
-
const binding = this.bindingTarget();
|
|
182
|
-
if (!binding.ok)
|
|
183
|
-
return binding;
|
|
184
|
-
const result = await this.invoke(() => binding.value.updateQueue(itemId, action));
|
|
185
|
-
return result.ok ? success(undefined) : failure(result.error);
|
|
146
|
+
const active = { stream, generation: this.generation, request, cancelled: false };
|
|
147
|
+
this.running = active;
|
|
148
|
+
this.observable.publish({ ...state, phase: state.messages.length === 0 ? 'creating' : 'running', error: undefined });
|
|
149
|
+
const admission = new Promise(resolve => { active.done = this.consume(active, resolve); });
|
|
150
|
+
return await admission;
|
|
186
151
|
}
|
|
187
152
|
async cancel() {
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
190
|
-
return binding;
|
|
191
|
-
const result = await this.invoke(() => binding.value.cancel());
|
|
192
|
-
return result.ok ? success(undefined) : failure(result.error);
|
|
193
|
-
}
|
|
194
|
-
async respondApproval(interactionId, decision) {
|
|
195
|
-
const binding = this.bindingTarget();
|
|
196
|
-
if (!binding.ok)
|
|
197
|
-
return binding;
|
|
198
|
-
const result = await this.invoke(() => binding.value.respondApproval(interactionId, decision));
|
|
199
|
-
return result.ok ? success(undefined) : failure(result.error);
|
|
200
|
-
}
|
|
201
|
-
async respondQuestion(interactionId, answer) {
|
|
202
|
-
const binding = this.bindingTarget();
|
|
203
|
-
if (!binding.ok)
|
|
204
|
-
return binding;
|
|
205
|
-
const result = await this.invoke(() => binding.value.respondQuestion(interactionId, answer));
|
|
206
|
-
return result.ok ? success(undefined) : failure(result.error);
|
|
207
|
-
}
|
|
208
|
-
async close() {
|
|
209
|
-
const state = this.getSnapshot();
|
|
210
|
-
if (state.phase === 'closed')
|
|
211
|
-
return success(undefined);
|
|
212
|
-
if (state.childSessionId === undefined && this.opening === undefined) {
|
|
213
|
-
this.reset();
|
|
153
|
+
const chatId = this.getSnapshot().chatId;
|
|
154
|
+
if (chatId === undefined || this.running === undefined)
|
|
214
155
|
return success(undefined);
|
|
156
|
+
this.cancelling = true;
|
|
157
|
+
const active = this.running;
|
|
158
|
+
active.cancelled = true;
|
|
159
|
+
this.disposeStream(active.stream);
|
|
160
|
+
try {
|
|
161
|
+
const result = await this.invoke(() => this.remote.cancel({ chatId }));
|
|
162
|
+
await active.done;
|
|
163
|
+
return result.ok ? success(undefined) : result;
|
|
215
164
|
}
|
|
216
|
-
|
|
217
|
-
this.
|
|
218
|
-
this.publish({ ...state, phase: 'closing', error: undefined });
|
|
219
|
-
await this.opening;
|
|
220
|
-
const after = this.getSnapshot();
|
|
221
|
-
if (after.phase === 'closed')
|
|
222
|
-
return success(undefined);
|
|
223
|
-
if (after.error?.operation === 'close')
|
|
224
|
-
return failure(after.error);
|
|
225
|
-
if (after.childSessionId === undefined) {
|
|
226
|
-
this.reset();
|
|
227
|
-
return success(undefined);
|
|
228
|
-
}
|
|
229
|
-
return await this.closeChild(after.childSessionId);
|
|
230
|
-
}
|
|
231
|
-
if (state.childSessionId === undefined) {
|
|
232
|
-
this.reset();
|
|
233
|
-
return success(undefined);
|
|
165
|
+
finally {
|
|
166
|
+
this.cancelling = false;
|
|
234
167
|
}
|
|
235
|
-
return await this.closeChild(state.childSessionId);
|
|
236
168
|
}
|
|
237
169
|
async retry() {
|
|
238
170
|
const state = this.getSnapshot();
|
|
239
171
|
if (state.error?.operation === 'close')
|
|
240
172
|
return await this.close();
|
|
241
|
-
if (state.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return await this.
|
|
245
|
-
|
|
246
|
-
return failure(localError('invalid_request', 'There is no failed operation to retry.', false));
|
|
247
|
-
}
|
|
248
|
-
async dispose() {
|
|
249
|
-
if (this.disposed)
|
|
250
|
-
return;
|
|
251
|
-
if (this.getSnapshot().phase !== 'closed') {
|
|
252
|
-
const result = await this.close();
|
|
253
|
-
if (!result.ok) {
|
|
254
|
-
this.sessions.notify({ kind: 'warning', text: 'The Side Chat could not be closed cleanly.' });
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
this.disposed = true;
|
|
258
|
-
this.detachLease();
|
|
259
|
-
this.observable.dispose();
|
|
260
|
-
}
|
|
261
|
-
async createOpenAndPrompt(parentSessionId, atSeq, question) {
|
|
262
|
-
const original = this.getSnapshot();
|
|
263
|
-
let childSessionId = original.childSessionId;
|
|
264
|
-
if (childSessionId === undefined) {
|
|
265
|
-
this.publish({ ...original, phase: 'creating', draft: question, firstQuestion: question, error: undefined });
|
|
266
|
-
const created = await this.invoke(() => this.remote.create({
|
|
267
|
-
parentSessionId,
|
|
268
|
-
atSeq,
|
|
269
|
-
...(original.modelSelection === undefined ? {} : { modelSelection: original.modelSelection }),
|
|
270
|
-
}));
|
|
271
|
-
if (!created.ok) {
|
|
272
|
-
if (this.closeRequested)
|
|
273
|
-
this.reset();
|
|
274
|
-
else
|
|
275
|
-
this.fail(created.error, 'create', { draft: question, firstQuestion: question });
|
|
276
|
-
return failure(created.error);
|
|
277
|
-
}
|
|
278
|
-
childSessionId = created.value.childSessionId;
|
|
279
|
-
this.publishCreated(created.value, question);
|
|
280
|
-
if (this.closeRequested || this.disposed) {
|
|
281
|
-
const closed = await this.closeChild(childSessionId);
|
|
282
|
-
return closed.ok
|
|
283
|
-
? failure(localError('transport_error', 'The Side Chat was closed.', false))
|
|
284
|
-
: closed;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
if (this.lease === undefined) {
|
|
288
|
-
this.publish({ ...this.getSnapshot(), phase: 'opening', error: undefined });
|
|
289
|
-
try {
|
|
290
|
-
const lease = await this.sessions.retain(childSessionId);
|
|
291
|
-
if (this.closeRequested || this.disposed) {
|
|
292
|
-
lease.release();
|
|
293
|
-
const closed = await this.closeChild(childSessionId);
|
|
294
|
-
return closed.ok
|
|
295
|
-
? failure(localError('transport_error', 'The Side Chat was closed.', false))
|
|
296
|
-
: closed;
|
|
297
|
-
}
|
|
298
|
-
this.attachLease(lease, childSessionId);
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
const error = localError('side_chat_open_failed', 'The child Session could not be opened.');
|
|
302
|
-
this.fail(error, 'open', { firstQuestion: question, draft: question });
|
|
303
|
-
return failure(error);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
const binding = this.lease?.binding;
|
|
307
|
-
if (binding === undefined) {
|
|
308
|
-
const error = localError('side_chat_open_failed', 'The child Session is unavailable.');
|
|
309
|
-
this.fail(error, 'open', { firstQuestion: question, draft: question });
|
|
310
|
-
return failure(error);
|
|
311
|
-
}
|
|
312
|
-
const prompted = await this.invoke(() => binding.prompt(buildSideChatPrompt(original.selection, question), 'queue'));
|
|
313
|
-
if (!prompted.ok) {
|
|
314
|
-
this.fail(prompted.error, 'prompt', { firstQuestion: question, draft: question });
|
|
315
|
-
return failure(prompted.error);
|
|
316
|
-
}
|
|
317
|
-
this.publish({
|
|
318
|
-
...this.getSnapshot(),
|
|
319
|
-
phase: phaseOf(binding.getSnapshot()),
|
|
320
|
-
draft: '',
|
|
321
|
-
firstQuestion: undefined,
|
|
322
|
-
error: undefined,
|
|
323
|
-
});
|
|
324
|
-
return success(undefined);
|
|
325
|
-
}
|
|
326
|
-
publishCreated(created, question) {
|
|
327
|
-
this.publish({
|
|
328
|
-
...this.getSnapshot(),
|
|
329
|
-
phase: 'opening',
|
|
330
|
-
parentSessionId: created.parentSessionId,
|
|
331
|
-
childSessionId: created.childSessionId,
|
|
332
|
-
boundarySeq: created.boundarySeq,
|
|
333
|
-
inheritedThroughSeq: created.inheritedThroughSeq,
|
|
334
|
-
...(created.modelSelection === undefined ? {} : { modelSelection: created.modelSelection }),
|
|
335
|
-
firstQuestion: question,
|
|
336
|
-
error: undefined,
|
|
337
|
-
});
|
|
173
|
+
if (state.messages.length === 0)
|
|
174
|
+
return await this.sendFirst(state.draft);
|
|
175
|
+
if (this.pendingRequest !== undefined)
|
|
176
|
+
return await this.send(this.pendingRequest.text);
|
|
177
|
+
return failure(localError('An admitted question is never replayed automatically. Send a follow-up to continue.'));
|
|
338
178
|
}
|
|
339
|
-
async
|
|
179
|
+
async close() {
|
|
340
180
|
if (this.closing !== undefined)
|
|
341
181
|
return await this.closing;
|
|
342
|
-
|
|
182
|
+
if (this.getSnapshot().phase === 'closed')
|
|
183
|
+
return success(undefined);
|
|
184
|
+
const operation = this.closeCurrent();
|
|
343
185
|
this.closing = operation;
|
|
344
186
|
try {
|
|
345
187
|
return await operation;
|
|
@@ -349,74 +191,142 @@ export class SideChatController {
|
|
|
349
191
|
this.closing = undefined;
|
|
350
192
|
}
|
|
351
193
|
}
|
|
352
|
-
async
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
194
|
+
async dispose() {
|
|
195
|
+
if (this.disposed)
|
|
196
|
+
return;
|
|
197
|
+
this.disposed = true;
|
|
198
|
+
try {
|
|
199
|
+
const result = await this.close();
|
|
200
|
+
if (!result.ok)
|
|
201
|
+
this.sessions.notify({ kind: 'warning', text: result.error.message });
|
|
358
202
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if (binding === undefined || state.childSessionId !== binding.sessionId
|
|
366
|
-
|| !['ready', 'running', 'needs-input', 'needs-approval'].includes(state.phase)) {
|
|
367
|
-
return failure(localError('invalid_request', 'The Side Chat is not accepting messages.', false));
|
|
203
|
+
finally {
|
|
204
|
+
if (this.running !== undefined)
|
|
205
|
+
this.disposeStream(this.running.stream);
|
|
206
|
+
this.releaseParent?.();
|
|
207
|
+
this.releaseParent = undefined;
|
|
208
|
+
this.observable.dispose();
|
|
368
209
|
}
|
|
369
|
-
return success(binding);
|
|
370
210
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
211
|
+
async consume(active, resolve) {
|
|
212
|
+
let admitted = false;
|
|
213
|
+
let terminal = false;
|
|
214
|
+
const current = () => active.generation === this.generation && this.running === active;
|
|
215
|
+
const assistantId = `${active.request.requestId}:assistant`;
|
|
216
|
+
try {
|
|
217
|
+
for await (const event of active.stream) {
|
|
218
|
+
if (!current())
|
|
219
|
+
break;
|
|
220
|
+
const state = this.getSnapshot();
|
|
221
|
+
if (event.type === 'started') {
|
|
222
|
+
if (admitted || event.requestId !== active.request.requestId)
|
|
223
|
+
throw new Error('Invalid Side Chat admission response.');
|
|
224
|
+
admitted = true;
|
|
225
|
+
this.pendingRequest = undefined;
|
|
226
|
+
// Admission describes this turn, not the model/effort chosen for the
|
|
227
|
+
// next turn. A delayed admission or cached replay must not undo it.
|
|
228
|
+
this.observable.publish({ ...state, phase: 'running', draft: '',
|
|
229
|
+
messages: [...state.messages,
|
|
230
|
+
{ id: `${active.request.requestId}:user`, role: 'user', text: active.request.text, status: 'complete',
|
|
231
|
+
selectedText: state.messages.length === 0 ? state.selection?.text : undefined },
|
|
232
|
+
{ id: assistantId, role: 'assistant', text: '', reasoning: '', status: 'streaming' },
|
|
233
|
+
], error: undefined });
|
|
234
|
+
resolve(success(undefined));
|
|
235
|
+
}
|
|
236
|
+
else if (event.type === 'content') {
|
|
237
|
+
if (!admitted)
|
|
238
|
+
throw new Error('Side Chat content arrived before admission.');
|
|
239
|
+
this.observable.publish({ ...state, messages: state.messages.map(message => message.id === assistantId
|
|
240
|
+
? { ...message, text: event.text, reasoning: event.reasoning } : message) });
|
|
241
|
+
}
|
|
242
|
+
else if (event.type === 'finished') {
|
|
243
|
+
if (!admitted)
|
|
244
|
+
throw new Error('Side Chat ended before admission.');
|
|
245
|
+
terminal = true;
|
|
246
|
+
this.observable.publish({ ...state, phase: 'ready', messages: state.messages.map(message => message.id === assistantId
|
|
247
|
+
? { ...message, status: event.status } : message) });
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
terminal = true;
|
|
252
|
+
const issue = { ...event.error, recoverable: !admitted && event.error.recoverable };
|
|
253
|
+
this.streamFailure(issue, assistantId);
|
|
254
|
+
resolve(failure(issue));
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (current() && !terminal && !active.cancelled)
|
|
259
|
+
throw new Error('The Side Chat connection ended before the reply completed.');
|
|
260
|
+
}
|
|
261
|
+
catch (cause) {
|
|
262
|
+
if (current() && !active.cancelled) {
|
|
263
|
+
const issue = localError(cause instanceof Error ? cause.message : String(cause), 'transport_error', !admitted);
|
|
264
|
+
this.streamFailure(issue, assistantId);
|
|
265
|
+
resolve(failure(issue));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
if (current() && active.cancelled) {
|
|
270
|
+
this.pendingRequest = undefined;
|
|
271
|
+
const state = this.getSnapshot();
|
|
272
|
+
this.observable.publish({ ...state, phase: 'ready', error: undefined,
|
|
273
|
+
messages: state.messages.map(message => message.id === assistantId ? { ...message, status: 'stopped' } : message) });
|
|
274
|
+
}
|
|
275
|
+
this.disposeStream(active.stream);
|
|
276
|
+
if (this.running === active)
|
|
277
|
+
this.running = undefined;
|
|
278
|
+
resolve(failure(localError('The Side Chat was closed.')));
|
|
375
279
|
}
|
|
376
|
-
this.detachLease();
|
|
377
|
-
this.lease = lease;
|
|
378
|
-
this.childUnsubscribe = lease.binding.subscribe(() => { this.updateFromChild(); });
|
|
379
|
-
this.updateFromChild();
|
|
380
280
|
}
|
|
381
|
-
|
|
382
|
-
this.
|
|
383
|
-
this.
|
|
384
|
-
|
|
385
|
-
|
|
281
|
+
streamFailure(issue, assistantId) {
|
|
282
|
+
const state = this.getSnapshot();
|
|
283
|
+
this.observable.publish({ ...state, phase: state.messages.length === 0 ? 'error' : 'ready',
|
|
284
|
+
messages: state.messages.map(message => message.id === assistantId ? { ...message, status: 'error' } : message),
|
|
285
|
+
error: { ...issue, operation: 'prompt' } });
|
|
386
286
|
}
|
|
387
|
-
|
|
287
|
+
async closeCurrent() {
|
|
388
288
|
const state = this.getSnapshot();
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
289
|
+
const creating = this.creating;
|
|
290
|
+
++this.generation;
|
|
291
|
+
if (this.running !== undefined)
|
|
292
|
+
this.disposeStream(this.running.stream);
|
|
293
|
+
this.running = undefined;
|
|
294
|
+
this.observable.publish({ ...state, phase: 'closing', error: undefined });
|
|
295
|
+
let chatId = state.chatId;
|
|
296
|
+
if (creating !== undefined) {
|
|
297
|
+
const created = await creating;
|
|
298
|
+
if (created.ok)
|
|
299
|
+
chatId = created.value.chatId;
|
|
300
|
+
}
|
|
301
|
+
if (chatId !== undefined) {
|
|
302
|
+
const result = await this.invoke(() => this.remote.close({ chatId: chatId }));
|
|
303
|
+
if (!result.ok && result.error.code !== 'side_chat_not_found') {
|
|
304
|
+
this.observable.publish({ ...this.getSnapshot(), phase: 'error', chatId, error: { ...result.error, operation: 'close' } });
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
395
307
|
}
|
|
396
|
-
this.
|
|
308
|
+
this.pendingRequest = undefined;
|
|
309
|
+
this.releaseParent?.();
|
|
310
|
+
this.releaseParent = undefined;
|
|
311
|
+
this.observable.publish(INITIAL);
|
|
312
|
+
return success(undefined);
|
|
397
313
|
}
|
|
398
|
-
fail(
|
|
399
|
-
this.publish({
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
314
|
+
fail(issue, operation) {
|
|
315
|
+
this.observable.publish({ ...this.getSnapshot(), phase: 'error', error: { ...issue, operation } });
|
|
316
|
+
return failure(issue);
|
|
317
|
+
}
|
|
318
|
+
disposeStream(stream) {
|
|
319
|
+
try {
|
|
320
|
+
stream.dispose();
|
|
321
|
+
}
|
|
322
|
+
catch { /* A broken carrier must not prevent the explicit Host cancel/close RPC or parent release. */ }
|
|
405
323
|
}
|
|
406
324
|
async invoke(operation) {
|
|
407
325
|
try {
|
|
408
326
|
return await operation();
|
|
409
327
|
}
|
|
410
|
-
catch {
|
|
411
|
-
return
|
|
328
|
+
catch (cause) {
|
|
329
|
+
return failure(localError(cause instanceof Error ? cause.message : String(cause), 'transport_error', true));
|
|
412
330
|
}
|
|
413
331
|
}
|
|
414
|
-
reset() {
|
|
415
|
-
this.detachLease();
|
|
416
|
-
this.closeRequested = false;
|
|
417
|
-
this.publish(INITIAL_STATE);
|
|
418
|
-
}
|
|
419
|
-
publish(state) {
|
|
420
|
-
this.observable.publish(Object.freeze(state));
|
|
421
|
-
}
|
|
422
332
|
}
|