pikiloom 0.4.22 → 0.4.24
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/dashboard/dist/assets/{AgentTab-G0uiUjBk.js → AgentTab-D3U-vwIw.js} +1 -1
- package/dashboard/dist/assets/{ConnectionModal-BQvV5ok2.js → ConnectionModal-DQHeAsB3.js} +1 -1
- package/dashboard/dist/assets/{DirBrowser-BGacUCRb.js → DirBrowser-AO4JoPpf.js} +1 -1
- package/dashboard/dist/assets/{ExtensionsTab-Bd2rjOVu.js → ExtensionsTab-BCCPKzoi.js} +1 -1
- package/dashboard/dist/assets/{IMAccessTab-BD-ZwAOb.js → IMAccessTab-Bc20xVsq.js} +1 -1
- package/dashboard/dist/assets/{Modal-BwmINu44.js → Modal-Ds_Yj4DN.js} +1 -1
- package/dashboard/dist/assets/{Modals-ncL7g7fW.js → Modals-VhIHAoTS.js} +1 -1
- package/dashboard/dist/assets/{Select-D7Iz4BLf.js → Select-B2e5zNQe.js} +1 -1
- package/dashboard/dist/assets/SessionPanel-DD1k_k6Z.js +1 -0
- package/dashboard/dist/assets/{SystemTab-DZjEZmVB.js → SystemTab--Qlps8hZ.js} +1 -1
- package/dashboard/dist/assets/index-CV4bsimV.js +23 -0
- package/dashboard/dist/assets/{index-DEvh1R9o.js → index-CxKN1UnH.js} +2 -2
- package/dashboard/dist/assets/{shared-D5VHAaFg.js → shared-DTiZXFp_.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dist/pikichannel/server.js +6 -0
- package/dist/pikichannel/transports/webrtc-shared.js +10 -16
- package/dist/pikichannel/turn.js +235 -0
- package/dist/pikichannel/web/sdk.js +4 -1
- package/package.json +1 -1
- package/dashboard/dist/assets/SessionPanel-zxiA8Pfd.js +0 -1
- package/dashboard/dist/assets/index-CTHVuMnd.js +0 -23
|
@@ -9,29 +9,23 @@
|
|
|
9
9
|
* peers dial OUTBOUND — the NAT-traversal path.
|
|
10
10
|
*
|
|
11
11
|
* Once the datachannel opens the bytes are pure P2P (DTLS-encrypted); signaling
|
|
12
|
-
* only brokers the handshake. `getIceServers()` is the STUN/TURN config hook
|
|
13
|
-
* STUN by default,
|
|
12
|
+
* only brokers the handshake. `getIceServers()` is the STUN/TURN config hook —
|
|
13
|
+
* see turn.ts: STUN by default, Cloudflare-minted short-lived TURN (or a manual
|
|
14
|
+
* PIKICHANNEL_ICE_SERVERS override) when configured, for symmetric-NAT / CGNAT
|
|
15
|
+
* relay fallback.
|
|
14
16
|
*/
|
|
15
17
|
import { RTCPeerConnection } from 'werift';
|
|
16
18
|
import { BaseConnection } from '../transport.js';
|
|
19
|
+
import { getCachedIceServers, toWeriftIceServers } from '../turn.js';
|
|
17
20
|
let connCounter = 0;
|
|
18
21
|
/**
|
|
19
|
-
* ICE servers for
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
22
|
+
* ICE servers for the werift answerer, reduced to exactly what werift consumes
|
|
23
|
+
* (one STUN + one UDP TURN — see {@link toWeriftIceServers}). The resolution
|
|
24
|
+
* policy lives in turn.ts: a manual PIKICHANNEL_ICE_SERVERS override, else cached
|
|
25
|
+
* Cloudflare-minted short-lived credentials, else plain STUN.
|
|
23
26
|
*/
|
|
24
27
|
export function getIceServers() {
|
|
25
|
-
|
|
26
|
-
if (raw) {
|
|
27
|
-
try {
|
|
28
|
-
const v = JSON.parse(raw);
|
|
29
|
-
if (Array.isArray(v) && v.length)
|
|
30
|
-
return v;
|
|
31
|
-
}
|
|
32
|
-
catch { /* fall through to default */ }
|
|
33
|
-
}
|
|
34
|
-
return [{ urls: 'stun:stun.l.google.com:19302' }];
|
|
28
|
+
return toWeriftIceServers(getCachedIceServers());
|
|
35
29
|
}
|
|
36
30
|
function coerceFrame(data) {
|
|
37
31
|
if (typeof data === 'string')
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pikichannel/turn.ts — Cloudflare Realtime TURN credential minter (host side).
|
|
3
|
+
*
|
|
4
|
+
* Why host-only is sufficient: the host is the WebRTC *answerer*. A TURN relay
|
|
5
|
+
* candidate it gathers is an allocation ON the TURN server that ANY client can
|
|
6
|
+
* reach OUTBOUND (NAT always allows outbound). So configuring TURN here alone
|
|
7
|
+
* gives relay fallback for EVERY NAT combination — symmetric NAT and CGNAT on
|
|
8
|
+
* either end included — without the browser needing its own TURN credentials.
|
|
9
|
+
* The host trickles its relay candidate to the client; the client just sends to
|
|
10
|
+
* it. (The browser keeps plain STUN; see web/sdk.js.)
|
|
11
|
+
*
|
|
12
|
+
* Security: the long-lived Cloudflare API token NEVER leaves this process. We
|
|
13
|
+
* exchange it for SHORT-LIVED (TTL'd) credentials via Cloudflare's API and feed
|
|
14
|
+
* only those to the peer connection. No secret is ever sent over the wire.
|
|
15
|
+
*
|
|
16
|
+
* Cost: callers must keep `iceTransportPolicy` at its default `'all'` (never
|
|
17
|
+
* force `'relay'`), so direct/STUN paths are always preferred and the relay —
|
|
18
|
+
* the only metered part of Cloudflare Realtime — is a genuine last resort.
|
|
19
|
+
* Credentials are minted once and cached across all connections, refreshed just
|
|
20
|
+
* before expiry, so steady state is zero API calls per connection.
|
|
21
|
+
*
|
|
22
|
+
* Fallback: no Cloudflare config (or any mint failure) → plain STUN, exactly as
|
|
23
|
+
* before. Existing hosts see zero behavior change.
|
|
24
|
+
*
|
|
25
|
+
* See https://developers.cloudflare.com/realtime/turn/generate-credentials/.
|
|
26
|
+
*/
|
|
27
|
+
import { loadUserConfig } from '../core/config/user-config.js';
|
|
28
|
+
import { writeScopedLog } from '../core/logging.js';
|
|
29
|
+
const tlog = (msg) => writeScopedLog('pikichannel', `[turn] ${msg}`, { level: 'info' });
|
|
30
|
+
/** Default credential lifetime (24h). Must exceed a single session's duration. */
|
|
31
|
+
const DEFAULT_TTL = 86_400;
|
|
32
|
+
/** Floor for TTL so a fat-fingered tiny value can't churn the cache. */
|
|
33
|
+
const MIN_TTL = 600;
|
|
34
|
+
/** Re-mint this long before expiry so live connections never use dead creds. */
|
|
35
|
+
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
36
|
+
/** Abort a hung Cloudflare call so connection setup falls back to STUN promptly. */
|
|
37
|
+
const MINT_TIMEOUT_MS = 8_000;
|
|
38
|
+
/** Cloudflare credentials endpoint (the array-returning `generate-ice-servers`). */
|
|
39
|
+
const CF_ENDPOINT = (keyId) => `https://rtc.live.cloudflare.com/v1/turn/keys/${encodeURIComponent(keyId)}/credentials/generate-ice-servers`;
|
|
40
|
+
/** Free public STUN used when no TURN is configured. Google first keeps the
|
|
41
|
+
* no-config host's behavior identical to before; Cloudflare added for redundancy. */
|
|
42
|
+
const DEFAULT_STUN = [
|
|
43
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
44
|
+
{ urls: 'stun:stun.cloudflare.com:3478' },
|
|
45
|
+
];
|
|
46
|
+
let cache = null;
|
|
47
|
+
let pending = null;
|
|
48
|
+
/** Resolve TURN config from env (highest) then ~/.pikiloom/setting.json. */
|
|
49
|
+
export function resolveTurnConfig() {
|
|
50
|
+
const cfg = loadUserConfig();
|
|
51
|
+
const keyId = String(process.env.PIKICHANNEL_TURN_KEY_ID || cfg.pikichannelTurnKeyId || '').trim();
|
|
52
|
+
const apiToken = String(process.env.PIKICHANNEL_TURN_API_TOKEN || cfg.pikichannelTurnApiToken || '').trim();
|
|
53
|
+
const ttlRaw = process.env.PIKICHANNEL_TURN_TTL || (cfg.pikichannelTurnTtl != null ? String(cfg.pikichannelTurnTtl) : '');
|
|
54
|
+
const ttl = Math.max(MIN_TTL, Number.parseInt(ttlRaw, 10) || DEFAULT_TTL);
|
|
55
|
+
return { keyId, apiToken, ttl };
|
|
56
|
+
}
|
|
57
|
+
/** A manual `PIKICHANNEL_ICE_SERVERS` JSON override, flattened — or null. This
|
|
58
|
+
* preserves the original escape hatch and takes precedence over minting. */
|
|
59
|
+
function manualIceServers() {
|
|
60
|
+
const raw = process.env.PIKICHANNEL_ICE_SERVERS;
|
|
61
|
+
if (!raw)
|
|
62
|
+
return null;
|
|
63
|
+
try {
|
|
64
|
+
const v = JSON.parse(raw);
|
|
65
|
+
if (Array.isArray(v) && v.length)
|
|
66
|
+
return flatten(v);
|
|
67
|
+
}
|
|
68
|
+
catch { /* fall through */ }
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/** Flatten any `{ urls: string | string[], ... }[]` into one entry per URL,
|
|
72
|
+
* carrying username/credential onto each. werift treats `urls` as a string. */
|
|
73
|
+
function flatten(servers) {
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const s of servers) {
|
|
76
|
+
if (!s || !s.urls)
|
|
77
|
+
continue;
|
|
78
|
+
const urls = Array.isArray(s.urls) ? s.urls : [s.urls];
|
|
79
|
+
for (const u of urls) {
|
|
80
|
+
if (typeof u !== 'string' || !u)
|
|
81
|
+
continue;
|
|
82
|
+
const entry = { urls: u };
|
|
83
|
+
if (s.username != null)
|
|
84
|
+
entry.username = s.username;
|
|
85
|
+
if (s.credential != null)
|
|
86
|
+
entry.credential = s.credential;
|
|
87
|
+
out.push(entry);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
const flattenCloudflare = flatten;
|
|
93
|
+
function cfgKey(c) {
|
|
94
|
+
return `${c.keyId}::${c.ttl}`;
|
|
95
|
+
}
|
|
96
|
+
/** Mint a fresh set of short-lived ICE servers from Cloudflare. Throws on any
|
|
97
|
+
* non-2xx / network error / timeout so the caller can fall back to STUN. */
|
|
98
|
+
async function mintCloudflare(cfg) {
|
|
99
|
+
const ctrl = new AbortController();
|
|
100
|
+
const timer = setTimeout(() => ctrl.abort(), MINT_TIMEOUT_MS);
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(CF_ENDPOINT(cfg.keyId), {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { Authorization: `Bearer ${cfg.apiToken}`, 'Content-Type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ ttl: cfg.ttl }),
|
|
106
|
+
signal: ctrl.signal,
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok)
|
|
109
|
+
throw new Error(`cloudflare turn responded ${res.status}`);
|
|
110
|
+
const data = (await res.json());
|
|
111
|
+
const flat = flattenCloudflare(data.iceServers || []);
|
|
112
|
+
if (!flat.length)
|
|
113
|
+
throw new Error('cloudflare turn returned no iceServers');
|
|
114
|
+
return flat;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** (Re)mint into the module cache. Single-flight: concurrent callers await one
|
|
121
|
+
* in-flight mint. On failure the last-good cache is kept if still for this key;
|
|
122
|
+
* otherwise cleared so the resolver yields STUN. */
|
|
123
|
+
function refresh(cfg) {
|
|
124
|
+
if (pending)
|
|
125
|
+
return pending;
|
|
126
|
+
const key = cfgKey(cfg);
|
|
127
|
+
pending = (async () => {
|
|
128
|
+
try {
|
|
129
|
+
const servers = await mintCloudflare(cfg);
|
|
130
|
+
cache = { key, servers, expiresAt: Date.now() + cfg.ttl * 1000 };
|
|
131
|
+
tlog(`minted ${servers.length} ice entries (ttl=${cfg.ttl}s)`);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
tlog(`mint failed, falling back to STUN: ${err?.message || err}`);
|
|
135
|
+
if (!cache || cache.key !== key)
|
|
136
|
+
cache = null;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
pending = null;
|
|
140
|
+
}
|
|
141
|
+
})();
|
|
142
|
+
return pending;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Mint TURN credentials ahead of the first connection (call once at mount).
|
|
146
|
+
* Best-effort and non-blocking-by-design — awaiting it is optional. No creds /
|
|
147
|
+
* manual override → no-op.
|
|
148
|
+
*/
|
|
149
|
+
export async function prewarmTurn() {
|
|
150
|
+
if (manualIceServers())
|
|
151
|
+
return;
|
|
152
|
+
const cfg = resolveTurnConfig();
|
|
153
|
+
if (!cfg.keyId || !cfg.apiToken)
|
|
154
|
+
return;
|
|
155
|
+
await refresh(cfg);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The current resolved ICE servers, synchronously, for building a peer
|
|
159
|
+
* connection. Order of precedence: manual override → cached Cloudflare creds →
|
|
160
|
+
* STUN. When the cache is missing/stale it kicks off a background refresh and
|
|
161
|
+
* returns the best value available right now (never blocks, never returns
|
|
162
|
+
* expired creds).
|
|
163
|
+
*/
|
|
164
|
+
export function getCachedIceServers() {
|
|
165
|
+
const manual = manualIceServers();
|
|
166
|
+
if (manual)
|
|
167
|
+
return manual;
|
|
168
|
+
const cfg = resolveTurnConfig();
|
|
169
|
+
if (!cfg.keyId || !cfg.apiToken)
|
|
170
|
+
return DEFAULT_STUN;
|
|
171
|
+
const key = cfgKey(cfg);
|
|
172
|
+
const fresh = !!cache && cache.key === key && Date.now() < cache.expiresAt - REFRESH_MARGIN_MS;
|
|
173
|
+
if (!fresh)
|
|
174
|
+
void refresh(cfg); // background; don't block the handshake
|
|
175
|
+
// Use cached creds only while genuinely unexpired; otherwise STUN.
|
|
176
|
+
if (cache && cache.key === key && Date.now() < cache.expiresAt)
|
|
177
|
+
return cache.servers;
|
|
178
|
+
return DEFAULT_STUN;
|
|
179
|
+
}
|
|
180
|
+
/** Async resolver (mint awaited) — for status, tests, and one-off callers. */
|
|
181
|
+
export async function resolveIceServers() {
|
|
182
|
+
const manual = manualIceServers();
|
|
183
|
+
if (manual)
|
|
184
|
+
return manual;
|
|
185
|
+
const cfg = resolveTurnConfig();
|
|
186
|
+
if (!cfg.keyId || !cfg.apiToken)
|
|
187
|
+
return DEFAULT_STUN;
|
|
188
|
+
const key = cfgKey(cfg);
|
|
189
|
+
const fresh = !!cache && cache.key === key && Date.now() < cache.expiresAt - REFRESH_MARGIN_MS;
|
|
190
|
+
if (!fresh)
|
|
191
|
+
await refresh(cfg);
|
|
192
|
+
if (cache && cache.key === key && Date.now() < cache.expiresAt)
|
|
193
|
+
return cache.servers;
|
|
194
|
+
return DEFAULT_STUN;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Reduce a (flattened) ICE list to what werift's primitive `parseIceServers`
|
|
198
|
+
* actually consumes: it takes the FIRST `stun:` and FIRST `turn:` entry, reads
|
|
199
|
+
* `urls` as a string, and connects to TURN over UDP. So we hand it exactly one
|
|
200
|
+
* STUN and one UDP TURN entry. `turns:` (TLS) is dropped — werift's TURN client
|
|
201
|
+
* speaks only udp/tcp. List order is meaningful: Cloudflare returns :3478/udp
|
|
202
|
+
* first, and DEFAULT_STUN puts Google first, so the right entries win.
|
|
203
|
+
*/
|
|
204
|
+
export function toWeriftIceServers(servers) {
|
|
205
|
+
const stun = servers.find((s) => s.urls.startsWith('stun:'));
|
|
206
|
+
const turn = servers.find((s) => s.urls.startsWith('turn:') && s.urls.includes('transport=udp')) ||
|
|
207
|
+
servers.find((s) => s.urls.startsWith('turn:'));
|
|
208
|
+
const out = [];
|
|
209
|
+
if (stun)
|
|
210
|
+
out.push({ urls: stun.urls });
|
|
211
|
+
if (turn)
|
|
212
|
+
out.push({ urls: turn.urls, username: turn.username, credential: turn.credential });
|
|
213
|
+
return out.length ? out : servers;
|
|
214
|
+
}
|
|
215
|
+
/** Lightweight status for /pikichannel/status (no secrets). */
|
|
216
|
+
export function turnStatus() {
|
|
217
|
+
if (manualIceServers()) {
|
|
218
|
+
const hasTurn = (manualIceServers() || []).some((s) => s.urls.startsWith('turn:') || s.urls.startsWith('turns:'));
|
|
219
|
+
return { turn: hasTurn, provider: 'manual', relay: hasTurn, expiresAt: null };
|
|
220
|
+
}
|
|
221
|
+
const cfg = resolveTurnConfig();
|
|
222
|
+
const configured = !!cfg.keyId && !!cfg.apiToken;
|
|
223
|
+
const live = !!cache && cache.key === cfgKey(cfg) && Date.now() < cache.expiresAt;
|
|
224
|
+
return {
|
|
225
|
+
turn: configured,
|
|
226
|
+
provider: configured ? 'cloudflare' : null,
|
|
227
|
+
relay: live,
|
|
228
|
+
expiresAt: live && cache ? cache.expiresAt : null,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Test-only: drop the module cache + in-flight mint so cases start clean. */
|
|
232
|
+
export function __resetTurnCacheForTest() {
|
|
233
|
+
cache = null;
|
|
234
|
+
pending = null;
|
|
235
|
+
}
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
|
|
25
25
|
export const PROTOCOL_VERSION = 1;
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// The browser keeps plain STUN (no secrets). Relay fallback rides the host's
|
|
28
|
+
// TURN: the host (answerer) trickles its Cloudflare relay candidate, which the
|
|
29
|
+
// browser reaches outbound — so no browser-side TURN credential is needed.
|
|
30
|
+
const ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun.cloudflare.com:3478' }];
|
|
28
31
|
|
|
29
32
|
// Resolve the ws(s):// base for a target host. `host` may be a bare authority
|
|
30
33
|
// ("192.168.1.5:3940"), a full ws(s)/http(s) URL, or empty (same origin). This
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{r as t,j as n}from"./react-vendor-C7Sl8SE7.js";import{c as ht,I as pt,S as ke,m as Se,a as j,u as We,d as xt,g as kt,l as St,e as bt,j as vt,f as yt,s as It,X as Tt}from"./index-DEvh1R9o.js";import{l as jt,n as Xe,m as Rt,a as wt,b as Nt,p as Ot,T as Lt,R as Mt,U as Ve,c as Ct,d as Ge,e as Et,L as At,I as Pt}from"./index-CTHVuMnd.js";import{M as Je,a as Ze}from"./Modal-BwmINu44.js";import"./router-DHISdpPk.js";import"./Select-D7Iz4BLf.js";import"./DirBrowser-BGacUCRb.js";import"./markdown-DxQYQFeH.js";import"./ExtensionsTab-Bd2rjOVu.js";function Ft({snapshot:f}){const[r,h]=t.useState(f.currentIndex??0),[C,R]=t.useState(""),[x,p]=t.useState(!1),[W,k]=t.useState(null);t.useEffect(()=>{h(f.currentIndex??0),R(""),k(null)},[f.promptId,f.currentIndex]);const w=f.questions||[],v=w[r]||null,X=w.length,N=!!(v?.options&&v.options.length),d=N?!!v?.allowFreeform:!0,F=o=>{o&&(h(g=>g+1),R(""))},ne=async o=>{if(!x){p(!0),k(null);try{const g=await j.interactionSelectOption(f.promptId,o);if(!g.ok){k(g.error||"Failed to submit selection.");return}F(g.advanced)}catch(g){k(g?.message||"Network error.")}finally{p(!1)}}},O=async()=>{if(x)return;const o=C.trim();if(!o&&!v?.allowEmpty){k("Please enter a response.");return}p(!0),k(null);try{const g=await j.interactionSubmitText(f.promptId,o);if(!g.ok){k(g.error||"Failed to submit answer.");return}F(g.advanced)}catch(g){k(g?.message||"Network error.")}finally{p(!1)}},m=async()=>{if(!x){p(!0),k(null);try{const o=await j.interactionSkip(f.promptId);if(!o.ok){k(o.error||"Failed to skip.");return}F(o.advanced)}catch(o){k(o?.message||"Network error.")}finally{p(!1)}}},H=async()=>{if(!x){p(!0);try{await j.interactionCancel(f.promptId)}catch{}}},V=t.useMemo(()=>{const o=[];return f.hint&&o.push(f.hint),X>1&&o.push(`Question ${r+1} of ${X}`),o.join(" · ")||void 0},[f.hint,r,X]);return n.jsxs(Je,{open:!0,onClose:H,wide:N&&(v?.options?.length||0)>3,children:[n.jsx(Ze,{title:f.title||"Pikiloom needs your input",description:V,onClose:H}),v?n.jsxs("div",{className:"space-y-4",children:[n.jsxs("div",{children:[n.jsx("div",{className:"text-xs font-medium uppercase tracking-wide text-fg-5",children:v.header||"Question"}),n.jsx("div",{className:"mt-1 whitespace-pre-wrap text-sm leading-relaxed text-fg",children:v.prompt})]}),N&&n.jsx("div",{className:"grid grid-cols-1 gap-2 sm:grid-cols-2",children:(v.options||[]).map(o=>n.jsxs("button",{type:"button",disabled:x,onClick:()=>ne(o.value||o.label),className:ht("group rounded-lg border border-edge bg-panel-alt px-3 py-2 text-left text-sm transition","hover:border-control-border-h hover:bg-control-h hover:shadow-sm","focus:outline-none focus:ring-2 focus:ring-[var(--th-glow-a)]","disabled:cursor-not-allowed disabled:opacity-50"),children:[n.jsx("div",{className:"font-medium text-fg group-hover:text-fg",children:o.label}),o.description&&n.jsx("div",{className:"mt-0.5 text-xs leading-snug text-fg-4",children:o.description})]},o.value||o.label))}),d&&n.jsx("div",{children:n.jsx(pt,{value:C,onChange:o=>R(o.target.value),onKeyDown:o=>{o.key==="Enter"&&!o.shiftKey&&!x&&(o.preventDefault(),O())},placeholder:N?"Or type a custom answer…":"Type your answer…",disabled:x,autoFocus:!N})}),W&&n.jsx("div",{className:"rounded-md border border-red-300/40 bg-red-500/10 px-3 py-2 text-xs text-red-600",children:W}),n.jsxs("div",{className:"flex items-center justify-between gap-3",children:[n.jsx("div",{className:"text-xs text-fg-5",children:x?n.jsxs("span",{className:"inline-flex items-center gap-2",children:[n.jsx(ke,{})," Submitting…"]}):n.jsxs("span",{children:["Press ",n.jsx("kbd",{className:"rounded border border-edge bg-panel-alt px-1.5 py-0.5 text-[10px] uppercase",children:"Enter"})," to send"]})}),n.jsxs("div",{className:"flex items-center gap-2",children:[n.jsx(Se,{variant:"ghost",size:"sm",onClick:m,disabled:x,children:"Skip"}),d&&n.jsx(Se,{variant:"primary",size:"sm",onClick:O,disabled:x||!C.trim()&&!v.allowEmpty,children:"Submit"})]})]})]}):n.jsxs("div",{className:"py-6 text-center text-sm text-fg-5",children:[n.jsx(ke,{className:"mr-2 inline-block"})," Waiting for the agent…"]})]})}const Pe=12,Ye=160,Ht=96,qt=20,z=new Map;function Dt(f,r){return`${f}:${r}`}function Ut(f,r){for(z.delete(f),z.set(f,r);z.size>qt;)z.delete(z.keys().next().value)}const Gt=t.memo(function({session:r,workdir:h,active:C=!0,onSessionChange:R,initialPendingPrompt:x,initialPendingImageUrls:p,onPendingPromptConsumed:W}){const k=We(e=>e.locale),w=We(e=>e.agentStatus?.agents?.find(s=>s.agent===r.agent)??null),v=w?.selectedEffort??null,X=w?.selectedModel??null,N=w?.byokProviderName??null,d=t.useMemo(()=>xt(k),[k]),F=kt(r.agent||""),ne=St(r),O=!!x||!!(p&&p.length),[m,H]=t.useState(null),[V,o]=t.useState(!O),[g,Fe]=t.useState(!1),[i,q]=t.useState(null),[G,Y]=t.useState(!1),[se,be]=t.useState(null),[et,He]=t.useState(0),[tt,ve]=t.useState(null),[le,ae]=t.useState([]),[rt,oe]=t.useState([]),[J,ye]=t.useState([]),[S,ue]=t.useState(x||null),[L,ce]=t.useState(p||[]),[nt,ie]=t.useState(null),Ie=t.useRef(null);Ie.current=nt;const[qe,D]=t.useState([]),De=t.useRef([]);De.current=qe;const Z=t.useRef(null),[st,Ue]=t.useState(null),[de,ee]=t.useState(null),[te,Te]=t.useState(""),[E,je]=t.useState(!1),lt=!!w?.capabilities?.fork,Re=t.useRef(null),U=t.useRef(p||[]),M=t.useRef(i),$e=t.useRef(G);M.current=i,$e.current=G;const A=t.useRef(null),fe=t.useRef(null),$=t.useRef(!0),B=t.useRef(!1),me=t.useRef(null),we=t.useRef(!1),Q=t.useRef(O),re=t.useRef(!1),P=t.useRef(!1),Ne=t.useRef(!1),Oe=t.useRef(!1),Be=t.useRef({model:null,effort:null}),at=t.useCallback(e=>{Be.current=e},[]);t.useEffect(()=>{Ne.current||!O||(Ne.current=!0,x&&!S&&ue(x),p&&p.length&&!L.length&&(ce(p),U.current=p),o(!1),He(e=>e+1),W?.())},[O,W]);const I=t.useCallback(()=>{ue(null),ce(e=>{for(const s of e)URL.revokeObjectURL(s);return[]}),U.current=[],ie(null)},[]),_=t.useCallback(()=>{D(e=>{if(!e.length)return e;for(const s of e)for(const l of s.imageUrls)URL.revokeObjectURL(l);return[]}),Z.current=null},[]),Qe=t.useCallback((e,s)=>{const l=!!M.current||$e.current,a=s||[];if(l){const u=`local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`;Z.current=u,D(c=>[...c,{localId:u,taskId:null,prompt:e||"",imageUrls:a}]);return}for(const u of U.current)URL.revokeObjectURL(u);Z.current=null,ue(e||null),ce(a),U.current=a,ie(null)},[]),ot=t.useCallback(e=>{const s=Z.current;if(s){Z.current=null,D(l=>{const a=l.findIndex(c=>c.localId===s);if(a<0)return l;const u=l.slice();return u[a]={...u[a],taskId:e},u});return}ie(e)},[]),ut=t.useCallback(async()=>{if(!de)return;const e=te.trim();if(e){je(!0);try{const s=await j.forkSession(h,r.agent||"",r.sessionId,de.atTurn,e,{});if(!s.ok||!s.sessionKey){je(!1);return}const[l,a]=s.sessionKey.split(":");ee(null),Te(""),R?.({agent:l,sessionId:a,workdir:h})}finally{je(!1)}}},[de,te,h,r.agent,r.sessionId,R]);Re.current=ut;const ge=t.useCallback(async(e,s={})=>{try{const l=await jt({workdir:h,agent:r.agent||"",sessionId:r.sessionId,rich:!0,turnOffset:e.turnOffset,turnLimit:e.turnLimit,lastNTurns:e.lastNTurns},{force:s.force});return l.ok?Xe(l):null}catch{return null}},[h,r.agent,r.sessionId]),T=t.useCallback(async({keepOlder:e,force:s=!1,scrollToBottom:l=!1})=>{const a=r.sessionId;if(me.current===a)return!1;me.current=a;try{const u=await ge({turnOffset:0,turnLimit:Pe},{force:s});if(!u||r.sessionId!==a)return!1;if(l&&(B.current=!0),H(c=>!c||!e?u:Rt(c,u)),o(!1),re.current&&(re.current=!1,I()),P.current){const c=P.current;P.current=!1;const b=c!==!0?c.taskId:null;M.current&&(c===!0||M.current.taskId===b)&&q(null)}return!0}finally{me.current===a&&(me.current=null)}},[ge,I,r.sessionId]),he=t.useCallback(async()=>{if(!m?.hasOlder||we.current)return;const e=A.current;e&&(fe.current={scrollHeight:e.scrollHeight,scrollTop:e.scrollTop}),we.current=!0,Fe(!0);try{const s=await ge({turnOffset:Math.max(0,m.totalTurns-m.startTurn),turnLimit:Pe});s?H(l=>l?wt(l,s):s):fe.current=null}finally{we.current=!1,Fe(!1)}},[ge,m]),pe=t.useRef(null),K=t.useCallback(e=>{if(e?.sessionId&&e.sessionId!==r.sessionId&&(Oe.current=!0,Le.current=`${r.agent}:${e.sessionId}`,R?.({agent:r.agent||"",sessionId:e.sessionId,workdir:h})),!e){const l=pe.current;Y(!1),l==="streaming"?(re.current=!0,P.current=!0,T({keepOlder:!0,force:!0,scrollToBottom:$.current})):q(null),l==="done"?(I(),_()):l===null&&Q.current&&T({keepOlder:!0,force:!0}),l!==null&&(Q.current=!1),ve(null),be(null),ae([]),oe([]),ye([]),pe.current=null;return}if(be(e.phase),ve(e.taskId||null),ae(e.queuedTaskIds&&e.queuedTaskIds.length?e.queuedTaskIds:[]),oe(e.queuedTasks&&e.queuedTasks.length?e.queuedTasks:[]),ye(Array.isArray(e.interactions)&&e.interactions.length?e.interactions:[]),e.phase==="streaming"){if(P.current&&M.current&&M.current.taskId!==null&&M.current.taskId!==(e.taskId||null)&&!(e.text||"").trim()||q({taskId:e.taskId||null,phase:"streaming",text:e.text||"",thinking:e.thinking||"",activity:e.activity,plan:e.plan??null,model:e.model??null,effort:e.effort??null,previewMeta:e.previewMeta??null,subAgents:e.previewMeta?.subAgents??null,generatingImages:e.previewMeta?.generatingImages??0,artifacts:e.artifacts??null,startedAt:typeof e.startedAt=="number"?e.startedAt:null,error:null,question:e.question??null}),Y(!0),e.taskId&&e.taskId!==Ie.current){const a=De.current,u=a.findIndex(c=>c.taskId===e.taskId);if(u>=0){const c=a[u];for(const b of U.current)URL.revokeObjectURL(b);ue(c.prompt||null),ce(c.imageUrls),U.current=c.imageUrls,ie(e.taskId),D(b=>b.filter((mt,gt)=>gt!==u))}}$.current&&(B.current=!0)}else if(e.phase==="queued")q(null),Y(!1);else if(e.phase==="done"){Y(!1),q(b=>b?{...b,phase:"done",error:e.error??null}:e.error?{taskId:e.taskId||null,phase:"done",text:"",thinking:"",activity:"",plan:null,model:e.model??null,effort:e.effort??null,previewMeta:e.previewMeta??null,subAgents:e.previewMeta?.subAgents??null,generatingImages:e.previewMeta?.generatingImages??0,artifacts:e.artifacts??null,error:e.error}:b);const l=!!e.queuedTaskIds?.length,a=M.current,u=!!a&&Nt(a),c=!!e.incomplete&&u&&!l;pe.current!=="done"&&(l||(re.current=!0),P.current=c?!1:{taskId:e.taskId||null},T({keepOlder:!0,force:!0,scrollToBottom:$.current})),l||(Q.current=!1)}const s=new Set;if(e.taskId&&s.add(e.taskId),Array.isArray(e.queuedTaskIds))for(const l of e.queuedTaskIds)s.add(l);D(l=>{let a=!1;const u=[];for(const c of l)if(!c.taskId||s.has(c.taskId))u.push(c);else{for(const b of c.imageUrls)URL.revokeObjectURL(b);a=!0}return a?u:l}),pe.current=e.phase},[I,_,T,r.sessionId,r.agent,R,h]),_e=t.useCallback(()=>{Q.current=!0,He(e=>e+1)},[]),ct=t.useCallback(async e=>{try{await j.recallSessionMessage(e),Ie.current===e&&I(),D(s=>{let l=!1;const a=[];for(const u of s)if(u.taskId===e){for(const c of u.imageUrls)URL.revokeObjectURL(c);l=!0}else a.push(u);return l?a:s}),ae(s=>s.filter(l=>l!==e)),oe(s=>s.filter(l=>l.taskId!==e)),ve(s=>s===e?null:s)}catch{}},[I]),it=t.useCallback(async e=>{try{await j.steerSession(e)}catch{}},[]),dt=t.useCallback(async()=>{try{await j.stopSession(r.agent||"",r.sessionId)}catch{}},[r.agent,r.sessionId]),xe=Dt(r.agent||"",r.sessionId);t.useEffect(()=>{if(Oe.current){Oe.current=!1;let u=!1;return T({keepOlder:!0,force:!0}).finally(()=>{u||o(!1)}),()=>{u=!0}}let e=!1;const s=Ot({workdir:h,agent:r.agent||"",sessionId:r.sessionId,rich:!0,turnOffset:0,turnLimit:Pe},{allowStale:!0}),l=O&&!Ne.current,a=s?.ok?Xe(s):z.get(xe)||null;return o(l?!1:!a),H(a),q(null),Y(!1),be(null),ae([]),oe([]),ye([]),l||(I(),_(),Q.current=!1,re.current=!1,P.current=!1),$.current=!0,B.current=!0,l||T({keepOlder:!1,force:!0}).finally(()=>{e||o(!1)}),()=>{e=!0}},[T,r.agent,r.sessionId,h,xe,I,_]),t.useEffect(()=>{m&&m.turns.length>0&&Ut(xe,m)},[xe,m]),t.useEffect(()=>{C&&T({keepOlder:!0,force:!0})},[C,T]);const Le=t.useRef(`${r.agent}:${r.sessionId}`);Le.current=`${r.agent}:${r.sessionId}`,bt("stream-update",t.useCallback(e=>{e.key===Le.current&&K(e.snapshot??null)},[K])),t.useEffect(()=>{let e=!0;return j.getSessionStreamState(r.agent||"",r.sessionId).then(s=>{e&&K(s.state)}).catch(()=>{}),()=>{e=!1}},[K,r.agent,r.sessionId,et]),vt(t.useCallback(()=>{j.getSessionStreamState(r.agent||"",r.sessionId).then(e=>{K(e.state)}).catch(()=>{}),T({keepOlder:!0,force:!0})},[K,r.agent,r.sessionId,T])),t.useEffect(()=>{!Q.current&&ne!=="running"&&!G&&!i&&!se&&le.length===0&&(I(),_())},[ne,G,i,se,le.length,I,_]),t.useLayoutEffect(()=>{const e=fe.current,s=A.current;!e||!s||(fe.current=null,s.scrollTop=e.scrollTop+(s.scrollHeight-e.scrollHeight))},[m?.turns.length]),t.useLayoutEffect(()=>{if(!B.current)return;const e=A.current;e&&(B.current=!1,e.scrollTop=e.scrollHeight,requestAnimationFrame(()=>{$.current&&(e.scrollTop=e.scrollHeight)}))},[m,i]),t.useLayoutEffect(()=>{if(!S)return;const e=A.current;e&&(e.scrollTop=e.scrollHeight)},[S]),t.useEffect(()=>{if(!m?.hasOlder||V||g)return;const e=A.current;e&&e.scrollHeight<=e.clientHeight+Ye&&he()},[m?.hasOlder,m?.turns.length,he,V,g]);const ft=t.useCallback(()=>{const e=A.current;if(!e)return;const s=e.scrollHeight-e.scrollTop-e.clientHeight;$.current=s<=Ht,e.scrollTop<=Ye&&he()},[he]),Me=i?.model||r.model||X||null,Ce=yt(r.agent||"",i?.effort||r.thinkingEffort||v||null,r.workflowEnabled??w?.workflowEnabled)||null,Ke=Me?It(Me):null,Ee=Tt(r,{streaming:G,hasLiveStream:!!i,streamPhase:se,queuedTaskCount:le.length}),y=m?.turns||[],Ae=t.useMemo(()=>{if(!L.length||!y.length)return!1;const e=y[y.length-1];return!e.user||(e.user.text?.trim()||"")!==(S||"").trim()?!1:e.user.blocks.filter(l=>l.type==="image").length<L.length},[y,S,L.length]),ze=t.useMemo(()=>{let e=y;if(Ae){const b=e[e.length-1];e=[...e.slice(0,-1),{...b,user:null}]}if(!i||!e.length)return e;const s=e[e.length-1];if(!s.assistant)return e;const l=S??(i.question||null),a=(i.text||"").trim(),u=s.assistant.text?.trim()||"";return(l!=null?s.user?.text?.trim()===l.trim():!!u&&!!a&&(a.startsWith(u)||u.startsWith(a)))?[...e.slice(0,-1),{...s,assistant:null}]:e},[y,i,S,Ae]);return n.jsxs("div",{className:"flex flex-col h-full overflow-hidden",children:[n.jsx("div",{ref:A,onScroll:ft,className:"flex-1 overflow-y-auto overscroll-contain",children:V&&!S&&!L.length&&!i?n.jsx("div",{className:"flex items-center justify-center py-20",children:n.jsx(ke,{className:"h-5 w-5 text-fg-4"})}):ze.length===0&&!S&&!L.length&&!i&&!Ee?n.jsx("div",{className:"py-20 text-center text-[13px] text-fg-5",children:d("hub.noMessages")}):n.jsxs("div",{className:"max-w-[900px] mx-auto px-6 py-6 space-y-0",children:[(m?.hasOlder||g)&&n.jsxs("div",{className:"mb-4 flex items-center justify-center gap-2 text-[11px] text-fg-5",children:[g?n.jsx(ke,{className:"h-3 w-3 text-fg-5"}):n.jsx("span",{className:"h-1.5 w-1.5 rounded-full bg-fg-5/35"}),n.jsx("span",{children:d(g?"hub.loadingOlderTurns":"hub.loadOlderTurnsHint")})]}),r.migratedFrom?.kind==="fork"&&r.migratedFrom.sessionId&&n.jsxs("button",{type:"button",onClick:()=>R?.({agent:r.migratedFrom.agent||r.agent||"",sessionId:r.migratedFrom.sessionId,workdir:h}),className:"mb-4 inline-flex items-center gap-1.5 rounded-md border border-edge bg-panel-alt px-2.5 py-1 text-[11px] text-fg-5 transition hover:border-edge-h hover:text-fg-2",title:`#${r.migratedFrom.sessionId.slice(0,8)}`,children:[n.jsxs("svg",{width:"10",height:"10",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",children:[n.jsx("circle",{cx:"6",cy:"6",r:"2"}),n.jsx("circle",{cx:"18",cy:"6",r:"2"}),n.jsx("circle",{cx:"12",cy:"20",r:"2"}),n.jsx("path",{d:"M6 8v3a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V8"}),n.jsx("path",{d:"M12 14v4"})]}),n.jsx("span",{children:d("hub.forkBadge")}),n.jsxs("span",{className:"font-mono",children:["#",r.migratedFrom.sessionId.slice(0,8)]}),typeof r.migratedFrom.forkedAtTurn=="number"&&n.jsxs("span",{className:"text-fg-5/70",children:["· ",d("hub.forkBadgeAt").replace("{turn}",String(r.migratedFrom.forkedAtTurn+1))]})]}),ze.map((e,s)=>{const l=(m?.startTurn||0)+s;return n.jsx(Lt,{turn:e,turnIndex:l,agent:r.agent||"",meta:F,model:Ke,effort:Ce,providerName:N,t:d,workdir:h,onResend:a=>{B.current=!0,Qe(a);const u=Be.current;j.sendSessionMessage(h,r.agent||"",r.sessionId,a,{model:u.model||Me||void 0,effort:u.effort||Ce||void 0}).then(c=>{c.ok&&_e()}).catch(()=>{I()})},onEdit:a=>Ue(a),onFork:lt?a=>{Te(""),ee({atTurn:a})}:void 0},`${m?.startTurn||0}:${s}`)}),Ee&&n.jsx("div",{className:"mb-5 animate-in",children:n.jsx(Mt,{detail:Ee,t:d})}),(S||L.length>0)&&(Ae||!(S&&y.length>0&&y[y.length-1]?.user?.text?.trim()===S.trim()))&&n.jsxs("div",{className:"session-turn",children:[n.jsx(Ve,{text:S||"",blocks:L.map(e=>({type:"image",content:e})),t:d}),!i&&n.jsx("div",{className:"mt-3 mb-5 animate-in",children:n.jsx(Ct,{className:"text-fg-5"})})]}),i&&Ge(i)&&!S&&i.question&&!(y.length>0&&y[y.length-1]?.user?.text?.trim()===i.question.trim())&&n.jsx("div",{className:"session-turn",children:n.jsx(Ve,{text:i.question,t:d})}),i&&Ge(i)&&n.jsxs("div",{className:"mb-6",children:[n.jsx(Et,{agent:r.agent||"",meta:F,model:Ke,effort:Ce,providerName:N,previewMeta:i.previewMeta,liveStartedAt:i.phase==="streaming"?i.startedAt??null:null}),n.jsx(At,{stream:i,t:d,workdir:h})]}),n.jsx("div",{className:"h-4"})]})}),n.jsx(Pt,{session:r,workdir:h,onStreamQueued:_e,onSendStart:Qe,onSendTaskAssigned:ot,onSessionChange:R,t:d,streamPhase:se,streamTaskId:tt,queuedTaskIds:le,queuedTasks:rt,pendingQueuedSends:qe,onRecall:ct,onSteer:it,onStopAll:dt,editDraft:st,onEditDraftConsumed:()=>Ue(null),onSelectionChange:at}),de&&n.jsxs(Je,{open:!0,onClose:()=>{E||ee(null)},children:[n.jsx(Ze,{title:d("hub.forkPromptTitle"),description:d("hub.forkPromptHint"),onClose:()=>{E||ee(null)}}),n.jsx("textarea",{autoFocus:!0,value:te,disabled:E,onChange:e=>Te(e.target.value),onKeyDown:e=>{e.key==="Enter"&&(e.metaKey||e.ctrlKey)&&te.trim()&&!E&&(e.preventDefault(),Re.current?.())},placeholder:d("hub.forkPromptPlaceholder"),className:"w-full min-h-[120px] resize-y rounded-md border border-edge bg-panel-alt px-3 py-2 text-[13px] leading-relaxed text-fg outline-none focus:border-edge-h"}),n.jsxs("div",{className:"mt-4 flex items-center justify-end gap-2",children:[n.jsx(Se,{variant:"ghost",disabled:E,onClick:()=>ee(null),children:d("modal.cancel")}),n.jsx(Se,{variant:"primary",disabled:E||!te.trim(),onClick:()=>{Re.current?.()},children:d(E?"hub.forkSubmitting":"hub.forkSubmit")})]})]}),C&&J.length>0&&n.jsx(Ft,{snapshot:J[J.length-1]},J[J.length-1].promptId)]})});export{Gt as SessionPanel};
|