pi2web 0.2.1 → 0.2.8
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 +53 -6
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +671 -152
- package/dist/auth.js +3 -1
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +45 -10
- package/dist/collab/hub.js +1392 -384
- package/dist/collab/pi-extension.js +322 -65
- package/dist/collab/review-flow.js +197 -48
- package/dist/collab/routes.js +94 -23
- package/dist/collab/schemas.js +69 -13
- package/dist/collab/scoring-flow.js +145 -39
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +467 -112
- package/dist/collab/types.js +94 -10
- package/dist/collab/validate.js +62 -25
- package/dist/drafts.js +26 -6
- package/dist/http/agent-routes.js +222 -0
- package/dist/http/login-limiter.js +53 -0
- package/dist/http/mail-settings-routes.js +53 -0
- package/dist/http/system-prompt-routes.js +52 -0
- package/dist/http/utils.js +47 -0
- package/dist/http/web-assets.js +64 -0
- package/dist/http/workspace-routes.js +58 -0
- package/dist/mail-notifier.js +63 -17
- package/dist/main.js +61 -16
- package/dist/mcp.js +24 -5
- package/dist/metadata-store.js +203 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +249 -45
- package/dist/server.js +488 -614
- package/dist/session-namer.js +66 -17
- package/dist/session-performance.js +137 -0
- package/dist/sotto-stt.js +217 -0
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +153 -35
- package/dist/workspaces.js +91 -26
- package/dist/ws/agent-socket.js +256 -0
- package/dist/ws/terminal-socket.js +46 -0
- package/npm-shrinkwrap.json +3685 -0
- package/package.json +11 -1
- package/packages/protocol/openapi.yaml +24 -2
- package/packages/protocol/websocket.schema.json +58 -15
- package/web/activity-view.js +121 -0
- package/web/app.js +3647 -756
- package/web/backend-state.js +59 -27
- package/web/collab.js +684 -257
- package/web/hid-keyboard.js +173 -140
- package/web/index.html +7 -2
- package/web/styles.css +21 -2
- package/web/voice-pcm.js +36 -0
package/dist/session-namer.js
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import { assistantText, authHeaders, clipSummaryField, deadline, endpoint, prepareSummaryInput } from './voice.js';
|
|
3
|
+
// No `temperature`: current reasoning models (gpt-5.x, gpt-6-luna, mai-code-*) reject the request with
|
|
4
|
+
// "Unsupported parameter: 'temperature'", and naming does not need it.
|
|
3
5
|
export const DEFAULT_SESSION_NAMER = {
|
|
4
|
-
baseUrl: 'http://localhost:8313/',
|
|
5
|
-
|
|
6
|
+
baseUrl: 'http://localhost:8313/',
|
|
7
|
+
model: 'gpt-5.4-nano',
|
|
8
|
+
language: 'zh-CN',
|
|
9
|
+
// Reasoning models (gpt-5.x and friends) burn this budget on hidden reasoning tokens before they
|
|
6
10
|
// emit the name, so a tight cap yields an empty message with finish_reason "length".
|
|
7
|
-
maxInputChars: 8000,
|
|
11
|
+
maxInputChars: 8000,
|
|
12
|
+
maxOutputTokens: 2000,
|
|
13
|
+
requestTimeoutMs: 60000
|
|
8
14
|
};
|
|
9
15
|
export function parseSessionName(text) {
|
|
10
|
-
const trimmed = text
|
|
16
|
+
const trimmed = text
|
|
17
|
+
.trim()
|
|
18
|
+
.replace(/^```(?:json)?\s*/i, '')
|
|
19
|
+
.replace(/\s*```$/, '');
|
|
11
20
|
let candidate = trimmed;
|
|
12
21
|
const start = trimmed.indexOf('{'), end = trimmed.lastIndexOf('}');
|
|
13
22
|
if (start >= 0 && end > start) {
|
|
@@ -16,9 +25,17 @@ export function parseSessionName(text) {
|
|
|
16
25
|
if (typeof value?.sessionName === 'string')
|
|
17
26
|
candidate = value.sessionName;
|
|
18
27
|
}
|
|
19
|
-
catch {
|
|
28
|
+
catch {
|
|
29
|
+
/* fall back to the raw text below */
|
|
30
|
+
}
|
|
20
31
|
}
|
|
21
|
-
const name = candidate
|
|
32
|
+
const name = candidate
|
|
33
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
34
|
+
.replace(/\s{2,}/g, ' ')
|
|
35
|
+
.replace(/^["'“”‘’]|["'“”‘’]$/g, '')
|
|
36
|
+
.trim()
|
|
37
|
+
.slice(0, 60)
|
|
38
|
+
.trim();
|
|
22
39
|
return name || undefined;
|
|
23
40
|
}
|
|
24
41
|
export class SessionNamer {
|
|
@@ -33,11 +50,24 @@ export class SessionNamer {
|
|
|
33
50
|
this.fetcher = fetcher;
|
|
34
51
|
this.config = { apiKey: '', ...DEFAULT_SESSION_NAMER, ...strip(config) };
|
|
35
52
|
}
|
|
36
|
-
subscribe(listener) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
53
|
+
subscribe(listener) {
|
|
54
|
+
this.emitter.on('event', listener);
|
|
55
|
+
return () => this.emitter.off('event', listener);
|
|
56
|
+
}
|
|
57
|
+
close() {
|
|
58
|
+
this.closed = true;
|
|
59
|
+
this.emitter.removeAllListeners();
|
|
60
|
+
this.lastAssistant.clear();
|
|
61
|
+
this.lastPrompt.clear();
|
|
62
|
+
}
|
|
63
|
+
recordUserPrompt(agentId, prompt) {
|
|
64
|
+
const value = prompt.trim();
|
|
65
|
+
if (value)
|
|
66
|
+
this.lastPrompt.set(agentId, value);
|
|
67
|
+
}
|
|
68
|
+
emit(agentId, event) {
|
|
69
|
+
this.emitter.emit('event', agentId, event);
|
|
70
|
+
}
|
|
41
71
|
handleAgentEvent(agentId, event, context = {}) {
|
|
42
72
|
if (this.closed)
|
|
43
73
|
return;
|
|
@@ -91,23 +121,42 @@ export class SessionNamer {
|
|
|
91
121
|
piFinalOutput: prepareSummaryInput(finalOutput, Math.max(1, this.config.maxInputChars - promptBudget))
|
|
92
122
|
});
|
|
93
123
|
const response = await this.fetcher(endpoint(this.config.baseUrl, '/chat/completions'), {
|
|
94
|
-
method: 'POST',
|
|
124
|
+
method: 'POST',
|
|
125
|
+
signal: deadline(this.config.requestTimeoutMs),
|
|
95
126
|
headers: { 'content-type': 'application/json', ...authHeaders(this.config.apiKey || undefined) },
|
|
96
|
-
body: JSON.stringify({
|
|
97
|
-
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
model: this.config.model,
|
|
129
|
+
stream: false,
|
|
130
|
+
max_tokens: this.config.maxOutputTokens,
|
|
131
|
+
messages: [
|
|
132
|
+
{ role: 'system', content: this.prompt() },
|
|
133
|
+
{ role: 'user', content: body }
|
|
134
|
+
]
|
|
135
|
+
})
|
|
98
136
|
});
|
|
99
137
|
if (!response.ok)
|
|
100
138
|
throw new Error(`Session naming request failed (${response.status}): ${(await response.text()).slice(0, 300)}`);
|
|
101
139
|
const value = await response.json();
|
|
102
140
|
const choice = value?.choices?.[0];
|
|
103
141
|
const content = choice?.message?.content;
|
|
104
|
-
const text = typeof content === 'string'
|
|
142
|
+
const text = typeof content === 'string'
|
|
143
|
+
? content
|
|
144
|
+
: Array.isArray(content)
|
|
145
|
+
? content
|
|
146
|
+
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
147
|
+
.map((part) => part.text)
|
|
148
|
+
.join('')
|
|
149
|
+
: '';
|
|
105
150
|
if (!text.trim())
|
|
106
|
-
throw new Error(choice?.finish_reason === 'length'
|
|
151
|
+
throw new Error(choice?.finish_reason === 'length'
|
|
152
|
+
? `Naming endpoint returned no content: the model hit the ${this.config.maxOutputTokens}-token output budget (raise --session-name-max-output-tokens)`
|
|
153
|
+
: 'Naming endpoint returned no content');
|
|
107
154
|
return parseSessionName(text);
|
|
108
155
|
}
|
|
109
156
|
prompt() {
|
|
110
|
-
const language = this.config.language.toLowerCase().startsWith('zh')
|
|
157
|
+
const language = this.config.language.toLowerCase().startsWith('zh')
|
|
158
|
+
? '简体中文'
|
|
159
|
+
: '与用户输入相同的主要语言';
|
|
111
160
|
return `你负责为一次 Pi 编程会话生成标题。\n\n输入是一个 JSON 对象:\n- userPrompt:用户本轮真正想完成的任务。\n- piFinalOutput:Pi 最后一次回复。\n\n主要依据 userPrompt 概括任务主题,必要时参考 piFinalOutput。标题使用${language}:中文 6 到 18 个字,英文 3 到 8 个词;具体、便于检索,不写“新会话”“任务总结”等空泛名称,不带句号、引号、Emoji、代码或路径。\n\n只输出一个合法 JSON 对象:{"sessionName":"简短标题"},不要输出 Markdown 代码块、解释或任何额外文字。`;
|
|
112
161
|
}
|
|
113
162
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
export const PERFORMANCE_ENTRY = 'pi2web.performance.v1';
|
|
2
|
+
/** Wall-clock spans, collected on the host running Pi. Parallel tool spans deliberately overlap. */
|
|
3
|
+
export class SessionPerformanceTracker {
|
|
4
|
+
save;
|
|
5
|
+
now;
|
|
6
|
+
run = null;
|
|
7
|
+
model = null;
|
|
8
|
+
tools = new Map();
|
|
9
|
+
other = null;
|
|
10
|
+
constructor(save, now = Date.now) {
|
|
11
|
+
this.save = save;
|
|
12
|
+
this.now = now;
|
|
13
|
+
}
|
|
14
|
+
finish(span, at) {
|
|
15
|
+
if (span)
|
|
16
|
+
span.end = at;
|
|
17
|
+
}
|
|
18
|
+
span(kind, at, name) {
|
|
19
|
+
const span = { kind, start: at, end: at };
|
|
20
|
+
if (name)
|
|
21
|
+
span.name = name;
|
|
22
|
+
this.run.spans.push(span);
|
|
23
|
+
return span;
|
|
24
|
+
}
|
|
25
|
+
apply(event) {
|
|
26
|
+
const at = this.now(), e = event;
|
|
27
|
+
if (event.type === 'agent_start' && !this.run)
|
|
28
|
+
this.run = { start: at, spans: [] };
|
|
29
|
+
if (!this.run)
|
|
30
|
+
return;
|
|
31
|
+
switch (event.type) {
|
|
32
|
+
case 'turn_start':
|
|
33
|
+
if (this.other?.kind === 'retry') {
|
|
34
|
+
this.finish(this.other, at);
|
|
35
|
+
this.other = null;
|
|
36
|
+
}
|
|
37
|
+
this.finish(this.model, at);
|
|
38
|
+
this.model = this.span('model', at);
|
|
39
|
+
break;
|
|
40
|
+
case 'message_update':
|
|
41
|
+
if (this.model && this.model.firstResponse === undefined)
|
|
42
|
+
this.model.firstResponse = at;
|
|
43
|
+
break;
|
|
44
|
+
case 'message_end':
|
|
45
|
+
if (e.message?.role === 'assistant') {
|
|
46
|
+
this.finish(this.model, at);
|
|
47
|
+
this.model = null;
|
|
48
|
+
}
|
|
49
|
+
break;
|
|
50
|
+
case 'tool_execution_start':
|
|
51
|
+
if (e.parentToolCallId)
|
|
52
|
+
break; // nested tools are included in their parent's wall-clock span
|
|
53
|
+
if (this.model) {
|
|
54
|
+
this.finish(this.model, at);
|
|
55
|
+
this.model = null;
|
|
56
|
+
}
|
|
57
|
+
if (e.toolCallId) {
|
|
58
|
+
const span = this.span('tool', at, String(e.toolName ?? 'tool'));
|
|
59
|
+
span.toolCallId = String(e.toolCallId);
|
|
60
|
+
this.tools.set(span.toolCallId, span);
|
|
61
|
+
}
|
|
62
|
+
break;
|
|
63
|
+
case 'tool_execution_end': {
|
|
64
|
+
const span = this.tools.get(String(e.toolCallId));
|
|
65
|
+
if (span) {
|
|
66
|
+
this.finish(span, at);
|
|
67
|
+
this.tools.delete(String(e.toolCallId));
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case 'compaction_start':
|
|
72
|
+
case 'auto_retry_start':
|
|
73
|
+
this.finish(this.other, at);
|
|
74
|
+
this.other = this.span(event.type === 'compaction_start' ? 'compaction' : 'retry', at);
|
|
75
|
+
break;
|
|
76
|
+
case 'compaction_end':
|
|
77
|
+
case 'auto_retry_end':
|
|
78
|
+
this.finish(this.other, at);
|
|
79
|
+
this.other = null;
|
|
80
|
+
if (event.type === 'auto_retry_end' && e.finalError === 'Retry cancelled')
|
|
81
|
+
this.run.incomplete = true;
|
|
82
|
+
break;
|
|
83
|
+
case 'agent_end':
|
|
84
|
+
this.finish(this.model, at);
|
|
85
|
+
this.model = null;
|
|
86
|
+
if (e.aborted)
|
|
87
|
+
this.run.incomplete = true;
|
|
88
|
+
break;
|
|
89
|
+
case 'agent_settled':
|
|
90
|
+
// Unlike agent_end, this also follows post-run compaction or a cancelled retry delay.
|
|
91
|
+
this.finish(this.model, at);
|
|
92
|
+
this.finish(this.other, at);
|
|
93
|
+
for (const span of this.tools.values()) {
|
|
94
|
+
this.finish(span, at);
|
|
95
|
+
span.incomplete = true;
|
|
96
|
+
}
|
|
97
|
+
this.save({
|
|
98
|
+
start: this.run.start,
|
|
99
|
+
end: at,
|
|
100
|
+
spans: this.run.spans,
|
|
101
|
+
...(this.run.incomplete ? { incomplete: true } : {})
|
|
102
|
+
});
|
|
103
|
+
this.run = null;
|
|
104
|
+
this.model = this.other = null;
|
|
105
|
+
this.tools.clear();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Current branch only. Earlier sessions have tool timestamps but no model-call spans. */
|
|
110
|
+
export function sessionPerformance(entries) {
|
|
111
|
+
const runs = [];
|
|
112
|
+
const legacyTools = [];
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
const e = entry;
|
|
115
|
+
if (e.type === 'custom' &&
|
|
116
|
+
e.customType === PERFORMANCE_ENTRY &&
|
|
117
|
+
e.data?.spans &&
|
|
118
|
+
Number.isFinite(e.data.start) &&
|
|
119
|
+
Number.isFinite(e.data.end)) {
|
|
120
|
+
runs.push(e.data);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
const e = entry;
|
|
125
|
+
if (e.type !== 'message' || e.message?.role !== 'toolResult')
|
|
126
|
+
continue;
|
|
127
|
+
const duration = Number(e.message.durationMs);
|
|
128
|
+
const end = Number(e.message.timestamp) || Date.parse(e.timestamp ?? '');
|
|
129
|
+
if (!Number.isFinite(duration) ||
|
|
130
|
+
duration < 0 ||
|
|
131
|
+
!Number.isFinite(end) ||
|
|
132
|
+
runs.some(run => end >= run.start && end <= run.end))
|
|
133
|
+
continue;
|
|
134
|
+
legacyTools.push({ kind: 'tool', start: end - duration, end, name: String(e.message.toolName ?? 'tool') });
|
|
135
|
+
}
|
|
136
|
+
return { runs, legacyTools };
|
|
137
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { WebSocket } from 'ws';
|
|
2
|
+
export const DEFAULT_SOTTO_ENDPOINT = 'http://127.0.0.1:8766';
|
|
3
|
+
const invalid = (message, code = 'INVALID_SOTTO_SETTINGS') => Object.assign(new Error(message), { code });
|
|
4
|
+
/** No arbitrary URLs: the Sotto Web API is local-only, and this server is the authenticated proxy. */
|
|
5
|
+
export function validateSottoEndpoint(value) {
|
|
6
|
+
if (typeof value !== 'string')
|
|
7
|
+
throw invalid('Sotto endpoint must be a loopback HTTP URL');
|
|
8
|
+
let url;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(value);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw invalid('Sotto endpoint must be a valid URL');
|
|
14
|
+
}
|
|
15
|
+
if (url.protocol !== 'http:' ||
|
|
16
|
+
url.hostname !== '127.0.0.1' ||
|
|
17
|
+
!url.port ||
|
|
18
|
+
url.username ||
|
|
19
|
+
url.password ||
|
|
20
|
+
(url.pathname !== '/' && url.pathname !== '') ||
|
|
21
|
+
url.search ||
|
|
22
|
+
url.hash)
|
|
23
|
+
throw invalid('Sotto endpoint must be http://127.0.0.1:<port> with no path or credentials');
|
|
24
|
+
return url.origin;
|
|
25
|
+
}
|
|
26
|
+
/** An authenticated browser socket relays only audio and start/stop, never a Sotto control API. */
|
|
27
|
+
function proxySottoStream(client, url) {
|
|
28
|
+
const upstream = new WebSocket(url, { handshakeTimeout: 5000, maxPayload: 65536 });
|
|
29
|
+
let started = false, finished = false, closed = false;
|
|
30
|
+
let timer;
|
|
31
|
+
const close = () => {
|
|
32
|
+
closed = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
if (upstream.readyState === WebSocket.OPEN)
|
|
35
|
+
upstream.close();
|
|
36
|
+
else if (upstream.readyState === WebSocket.CONNECTING)
|
|
37
|
+
upstream.terminate();
|
|
38
|
+
};
|
|
39
|
+
const fail = (message, code = 1011) => {
|
|
40
|
+
if (closed)
|
|
41
|
+
return;
|
|
42
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
43
|
+
client.send(JSON.stringify({ type: 'error', message }));
|
|
44
|
+
client.close(code);
|
|
45
|
+
}
|
|
46
|
+
close();
|
|
47
|
+
};
|
|
48
|
+
// Silence still contains PCM. Only a stalled capture/connection times out, not a long dictation.
|
|
49
|
+
const touch = () => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
timer = setTimeout(() => fail('Audio stream stalled: no microphone frames received for 30 seconds'), 30000);
|
|
52
|
+
};
|
|
53
|
+
touch();
|
|
54
|
+
client.on('close', close);
|
|
55
|
+
client.on('error', close);
|
|
56
|
+
upstream.on('error', () => fail('Sotto streaming service is unavailable'));
|
|
57
|
+
upstream.on('close', code => {
|
|
58
|
+
if (closed)
|
|
59
|
+
return;
|
|
60
|
+
if (!finished || code !== 1000)
|
|
61
|
+
fail('Sotto streaming connection closed unexpectedly');
|
|
62
|
+
else {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
if (client.readyState === WebSocket.OPEN)
|
|
65
|
+
client.close(1000);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
upstream.on('message', (data, binary) => {
|
|
69
|
+
if (closed || binary || client.readyState !== WebSocket.OPEN)
|
|
70
|
+
return;
|
|
71
|
+
if (client.bufferedAmount > 1_000_000) {
|
|
72
|
+
fail('Transcription receiver is too slow', 1009);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
client.send(data.toString());
|
|
76
|
+
});
|
|
77
|
+
client.on('message', (data, binary) => {
|
|
78
|
+
if (closed || finished || client.readyState !== WebSocket.OPEN)
|
|
79
|
+
return;
|
|
80
|
+
const length = data.byteLength;
|
|
81
|
+
if (upstream.readyState !== WebSocket.OPEN) {
|
|
82
|
+
fail('Sotto streaming service is not ready');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!binary) {
|
|
86
|
+
try {
|
|
87
|
+
const value = JSON.parse(data.toString());
|
|
88
|
+
if (!started &&
|
|
89
|
+
value.type === 'start' &&
|
|
90
|
+
value.format === 'float32' &&
|
|
91
|
+
Number.isInteger(value.sample_rate) &&
|
|
92
|
+
value.sample_rate >= 16000 &&
|
|
93
|
+
value.sample_rate <= 96000) {
|
|
94
|
+
started = true;
|
|
95
|
+
upstream.send(JSON.stringify({ type: 'start', format: 'float32', sample_rate: value.sample_rate }));
|
|
96
|
+
touch();
|
|
97
|
+
}
|
|
98
|
+
else if (started && value.type === 'stop') {
|
|
99
|
+
finished = true;
|
|
100
|
+
upstream.send(JSON.stringify({ type: 'stop' }));
|
|
101
|
+
}
|
|
102
|
+
else
|
|
103
|
+
fail('Invalid audio protocol', 1008);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
fail('Invalid audio protocol', 1008);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else if (started &&
|
|
110
|
+
length > 0 &&
|
|
111
|
+
length % 4 === 0 &&
|
|
112
|
+
length <= 32768 &&
|
|
113
|
+
upstream.bufferedAmount < 1_000_000) {
|
|
114
|
+
upstream.send(data, { binary: true });
|
|
115
|
+
touch();
|
|
116
|
+
}
|
|
117
|
+
else
|
|
118
|
+
fail('Invalid audio frame or Sotto cannot keep up with microphone audio', 1009);
|
|
119
|
+
});
|
|
120
|
+
return fail;
|
|
121
|
+
}
|
|
122
|
+
export class SottoSttService {
|
|
123
|
+
metadata;
|
|
124
|
+
fetcher;
|
|
125
|
+
streams = new Set();
|
|
126
|
+
settings = { enabled: false, endpoint: DEFAULT_SOTTO_ENDPOINT };
|
|
127
|
+
/** Bumped by every update, so a slow enable probe cannot overwrite a newer choice. */
|
|
128
|
+
updates = 0;
|
|
129
|
+
constructor(metadata, fetcher = fetch) {
|
|
130
|
+
this.metadata = metadata;
|
|
131
|
+
this.fetcher = fetcher;
|
|
132
|
+
}
|
|
133
|
+
load() {
|
|
134
|
+
const saved = this.metadata.getSetting('sottoStt');
|
|
135
|
+
if (saved && typeof saved.enabled === 'boolean') {
|
|
136
|
+
try {
|
|
137
|
+
this.settings = { enabled: saved.enabled, endpoint: validateSottoEndpoint(saved.endpoint) };
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* Corrupt or obsolete settings must not enable an untrusted destination. */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
getSettings() {
|
|
145
|
+
return { ...this.settings };
|
|
146
|
+
}
|
|
147
|
+
streamUrl() {
|
|
148
|
+
if (!this.settings.enabled)
|
|
149
|
+
throw invalid('Sotto transcription is disabled', 'SOTTO_DISABLED');
|
|
150
|
+
const port = Number(new URL(this.settings.endpoint).port) + 2;
|
|
151
|
+
if (port > 65535)
|
|
152
|
+
throw invalid('Invalid Sotto streaming port');
|
|
153
|
+
return `ws://127.0.0.1:${port}/asr`;
|
|
154
|
+
}
|
|
155
|
+
stream(client) {
|
|
156
|
+
const stop = proxySottoStream(client, this.streamUrl());
|
|
157
|
+
this.streams.add(stop);
|
|
158
|
+
client.once('close', () => this.streams.delete(stop));
|
|
159
|
+
}
|
|
160
|
+
async request(endpoint, path, init, timeoutMs) {
|
|
161
|
+
const response = await this.fetcher(`${endpoint}${path}`, {
|
|
162
|
+
...init,
|
|
163
|
+
redirect: 'error',
|
|
164
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
165
|
+
}).catch(error => {
|
|
166
|
+
throw invalid(`Cannot reach local Sotto: ${error.message}`, 'SOTTO_UNAVAILABLE');
|
|
167
|
+
});
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const message = (await response.json().catch(() => ({})));
|
|
170
|
+
throw invalid(message.error || `Sotto returned HTTP ${response.status}`, 'SOTTO_UNAVAILABLE');
|
|
171
|
+
}
|
|
172
|
+
return response;
|
|
173
|
+
}
|
|
174
|
+
async update(input) {
|
|
175
|
+
if (!input || typeof input !== 'object')
|
|
176
|
+
throw invalid('enabled and endpoint are required');
|
|
177
|
+
const { enabled, endpoint } = input;
|
|
178
|
+
if (typeof enabled !== 'boolean')
|
|
179
|
+
throw invalid('enabled must be a boolean');
|
|
180
|
+
const next = { enabled, endpoint: validateSottoEndpoint(endpoint) };
|
|
181
|
+
const update = ++this.updates;
|
|
182
|
+
if (enabled) {
|
|
183
|
+
const capabilities = await (await this.request(next.endpoint, '/api/capabilities', { method: 'GET' }, 4000)).json();
|
|
184
|
+
if (capabilities?.stt !== true ||
|
|
185
|
+
capabilities?.format !== 'pcm-f32le' ||
|
|
186
|
+
capabilities?.sample_rate !== 16000 ||
|
|
187
|
+
capabilities?.stream_port !== Number(new URL(next.endpoint).port) + 2)
|
|
188
|
+
throw invalid('Sotto does not support live PCM transcription', 'SOTTO_UNAVAILABLE');
|
|
189
|
+
if (update !== this.updates)
|
|
190
|
+
throw invalid('Sotto settings were changed by a newer request', 'SOTTO_SETTINGS_SUPERSEDED');
|
|
191
|
+
}
|
|
192
|
+
this.metadata.setSetting('sottoStt', next);
|
|
193
|
+
const changed = !next.enabled || next.endpoint !== this.settings.endpoint;
|
|
194
|
+
this.settings = next;
|
|
195
|
+
if (changed) {
|
|
196
|
+
for (const stop of this.streams)
|
|
197
|
+
stop('Sotto 语音输入已关闭或服务地址已更改');
|
|
198
|
+
this.streams.clear();
|
|
199
|
+
}
|
|
200
|
+
return this.getSettings();
|
|
201
|
+
}
|
|
202
|
+
async transcribe(audio) {
|
|
203
|
+
if (!this.settings.enabled)
|
|
204
|
+
throw invalid('Sotto transcription is disabled', 'SOTTO_DISABLED');
|
|
205
|
+
if (!audio.length || audio.length % 4 || audio.length > 16000 * 4 * 60)
|
|
206
|
+
throw invalid('Expected at most 60 seconds of 16 kHz float32 PCM');
|
|
207
|
+
const response = await this.request(this.settings.endpoint, '/api/transcribe', {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
210
|
+
body: new Uint8Array(audio)
|
|
211
|
+
}, 120000);
|
|
212
|
+
const body = (await response.json());
|
|
213
|
+
if (typeof body.text !== 'string')
|
|
214
|
+
throw invalid('Invalid response from Sotto', 'SOTTO_UNAVAILABLE');
|
|
215
|
+
return { text: body.text };
|
|
216
|
+
}
|
|
217
|
+
}
|
package/dist/terminals.js
CHANGED
|
@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
5
5
|
import * as pty from 'node-pty';
|
|
6
6
|
const defaultFactory = (cwd, cols, rows) => {
|
|
7
7
|
const windows = process.platform === 'win32';
|
|
8
|
-
const shell = windows ?
|
|
8
|
+
const shell = windows ? process.env.COMSPEC || 'cmd.exe' : process.env.SHELL || '/bin/sh';
|
|
9
9
|
const env = Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined));
|
|
10
10
|
return pty.spawn(shell, [], { name: 'xterm-256color', cwd, cols, rows, env, useConpty: windows });
|
|
11
11
|
};
|
|
@@ -23,13 +23,23 @@ export class TerminalManager {
|
|
|
23
23
|
this.maxBuffer = maxBuffer;
|
|
24
24
|
this.maxRetained = maxRetained;
|
|
25
25
|
}
|
|
26
|
-
list() {
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
list() {
|
|
27
|
+
return [...this.terminals.values()]
|
|
28
|
+
.map(x => ({ ...x.record }))
|
|
29
|
+
.sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt));
|
|
30
|
+
}
|
|
31
|
+
get(id) {
|
|
32
|
+
const entry = this.terminals.get(id);
|
|
33
|
+
if (!entry)
|
|
34
|
+
throw Object.assign(new Error('Terminal not found'), { code: 'TERMINAL_NOT_FOUND' });
|
|
35
|
+
return entry;
|
|
36
|
+
}
|
|
29
37
|
async create(workspaceId, relativeCwd = '.', cols = 80, rows = 24) {
|
|
30
38
|
this.pruneExited();
|
|
31
39
|
if (this.list().filter(x => x.status === 'running').length >= this.maxRunning)
|
|
32
|
-
throw Object.assign(new Error(`At most ${this.maxRunning} terminals may run at once`), {
|
|
40
|
+
throw Object.assign(new Error(`At most ${this.maxRunning} terminals may run at once`), {
|
|
41
|
+
code: 'TERMINAL_LIMIT'
|
|
42
|
+
});
|
|
33
43
|
const workspace = this.workspaces.get(workspaceId);
|
|
34
44
|
if (!workspace)
|
|
35
45
|
throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
|
|
@@ -37,34 +47,91 @@ export class TerminalManager {
|
|
|
37
47
|
if (!(await stat(cwd)).isDirectory())
|
|
38
48
|
throw Object.assign(new Error('cwd is not a directory'), { code: 'NOT_A_DIRECTORY' });
|
|
39
49
|
const terminalId = `terminal-${randomUUID()}`, now = new Date().toISOString(), process = this.factory(cwd, this.dimension(cols, 80), this.dimension(rows, 24));
|
|
40
|
-
const record = {
|
|
50
|
+
const record = {
|
|
51
|
+
terminalId,
|
|
52
|
+
workspaceId,
|
|
53
|
+
cwd,
|
|
54
|
+
title: path.basename(cwd) || cwd,
|
|
55
|
+
status: 'running',
|
|
56
|
+
createdAt: now,
|
|
57
|
+
lastActiveAt: now
|
|
58
|
+
};
|
|
41
59
|
const entry = { record, process, emitter: new EventEmitter(), buffer: '' };
|
|
42
60
|
this.terminals.set(terminalId, entry);
|
|
43
|
-
entry.dataSubscription = process.onData(data => {
|
|
44
|
-
|
|
61
|
+
entry.dataSubscription = process.onData(data => {
|
|
62
|
+
entry.buffer = (entry.buffer + data).slice(-this.maxBuffer);
|
|
63
|
+
entry.record.lastActiveAt = new Date().toISOString();
|
|
64
|
+
entry.emitter.emit('event', { type: 'output', data });
|
|
65
|
+
});
|
|
66
|
+
const exitSubscription = process.onExit(event => {
|
|
67
|
+
entry.record.status = 'exited';
|
|
68
|
+
entry.record.exitCode = event.exitCode;
|
|
69
|
+
entry.record.lastActiveAt = new Date().toISOString();
|
|
70
|
+
entry.emitter.emit('event', { type: 'exit', exitCode: event.exitCode });
|
|
71
|
+
this.disposeProcessListeners(entry);
|
|
72
|
+
this.pruneExited();
|
|
73
|
+
});
|
|
45
74
|
if (entry.record.status === 'exited')
|
|
46
75
|
exitSubscription.dispose();
|
|
47
76
|
else
|
|
48
77
|
entry.exitSubscription = exitSubscription;
|
|
49
78
|
return { ...record };
|
|
50
79
|
}
|
|
51
|
-
snapshot(id) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
entry
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
snapshot(id) {
|
|
81
|
+
const entry = this.get(id);
|
|
82
|
+
return { record: { ...entry.record }, data: entry.buffer };
|
|
83
|
+
}
|
|
84
|
+
write(id, data) {
|
|
85
|
+
const entry = this.get(id);
|
|
86
|
+
if (entry.record.status !== 'running')
|
|
87
|
+
throw Object.assign(new Error('Terminal has exited'), { code: 'TERMINAL_EXITED' });
|
|
88
|
+
if (typeof data !== 'string' || data.length > 64 * 1024)
|
|
89
|
+
throw Object.assign(new Error('Invalid terminal input'), { code: 'INVALID_REQUEST' });
|
|
90
|
+
entry.process.write(data);
|
|
91
|
+
entry.record.lastActiveAt = new Date().toISOString();
|
|
92
|
+
}
|
|
93
|
+
resize(id, cols, rows) {
|
|
94
|
+
const entry = this.get(id);
|
|
95
|
+
if (entry.record.status === 'running')
|
|
96
|
+
entry.process.resize(this.dimension(cols, 80), this.dimension(rows, 24));
|
|
97
|
+
}
|
|
98
|
+
subscribe(id, listener) {
|
|
99
|
+
const entry = this.get(id);
|
|
100
|
+
entry.emitter.on('event', listener);
|
|
101
|
+
return () => entry.emitter.off('event', listener);
|
|
102
|
+
}
|
|
103
|
+
close(id) {
|
|
104
|
+
const entry = this.get(id);
|
|
105
|
+
this.terminals.delete(id);
|
|
106
|
+
if (entry.record.status === 'running')
|
|
107
|
+
entry.emitter.emit('event', { type: 'exit', exitCode: -1 });
|
|
108
|
+
this.disposeProcessListeners(entry);
|
|
109
|
+
if (entry.record.status === 'running')
|
|
110
|
+
try {
|
|
111
|
+
entry.process.kill();
|
|
112
|
+
}
|
|
113
|
+
catch { }
|
|
114
|
+
entry.emitter.removeAllListeners();
|
|
115
|
+
}
|
|
116
|
+
closeAll() {
|
|
117
|
+
for (const id of [...this.terminals.keys()])
|
|
118
|
+
this.close(id);
|
|
119
|
+
}
|
|
120
|
+
disposeProcessListeners(entry) {
|
|
121
|
+
entry.dataSubscription?.dispose();
|
|
122
|
+
entry.exitSubscription?.dispose();
|
|
123
|
+
entry.dataSubscription = undefined;
|
|
124
|
+
entry.exitSubscription = undefined;
|
|
125
|
+
}
|
|
126
|
+
pruneExited() {
|
|
127
|
+
const exited = [...this.terminals.entries()]
|
|
128
|
+
.reverse()
|
|
129
|
+
.filter(([, entry]) => entry.record.status === 'exited')
|
|
130
|
+
.sort((a, b) => b[1].record.lastActiveAt.localeCompare(a[1].record.lastActiveAt));
|
|
131
|
+
for (const [id] of exited.slice(this.maxRetained))
|
|
132
|
+
this.close(id);
|
|
133
|
+
}
|
|
134
|
+
dimension(value, fallback) {
|
|
135
|
+
return Number.isInteger(value) && value > 0 && value <= 1000 ? value : fallback;
|
|
136
|
+
}
|
|
70
137
|
}
|