plazbot-cli 0.3.3 → 0.3.6
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/dist/studio/components/App.js +38 -31
- package/dist/studio/components/ChatLog.js +49 -21
- package/dist/studio/components/ConnectCard.js +85 -0
- package/dist/studio/components/Footer.js +1 -1
- package/dist/studio/components/Input.js +143 -11
- package/dist/studio/connect/api.js +66 -0
- package/dist/studio/connect/polling.js +76 -0
- package/dist/studio/connect/types.js +72 -0
- package/dist/studio/runRepl.js +6 -37
- package/dist/studio/state/store.js +13 -12
- package/package.json +1 -1
- package/src/studio/components/App.tsx +41 -33
- package/src/studio/components/ChatLog.tsx +58 -32
- package/src/studio/components/{WhatsappConnectCard.tsx → ConnectCard.tsx} +54 -22
- package/src/studio/components/Footer.tsx +1 -1
- package/src/studio/components/Input.tsx +159 -15
- package/src/studio/{whatsapp → connect}/api.ts +11 -9
- package/src/studio/{whatsapp → connect}/polling.ts +26 -25
- package/src/studio/connect/types.ts +141 -0
- package/src/studio/runRepl.tsx +6 -37
- package/src/studio/state/store.ts +26 -20
- package/dist/studio/components/WhatsappConnectCard.js +0 -57
- package/src/studio/whatsapp/types.ts +0 -80
|
@@ -12,25 +12,26 @@ import '../slash/handlers.js'; // side effect: registra slashes
|
|
|
12
12
|
import { slashRegistry } from '../slash/registry.js';
|
|
13
13
|
import { streamStudio } from '../api/sseClient.js';
|
|
14
14
|
import { StudioHttpError } from '../api/types.js';
|
|
15
|
-
import {
|
|
16
|
-
import { workspaceOptsFromStream } from '../
|
|
15
|
+
import { startConnectPolling } from '../connect/polling.js';
|
|
16
|
+
import { workspaceOptsFromStream } from '../connect/api.js';
|
|
17
|
+
import { channelFromToolResultType } from '../connect/types.js';
|
|
17
18
|
export function App({ version, stream, initialAgentId, dev, supportMode }) {
|
|
18
19
|
const { exit } = useApp();
|
|
19
20
|
const messages = useStudioStore((s) => s.messages);
|
|
20
21
|
const usage = useStudioStore((s) => s.usage);
|
|
21
22
|
const streaming = useStudioStore((s) => s.streaming);
|
|
22
23
|
const agentId = useStudioStore((s) => s.agentId);
|
|
23
|
-
// Active
|
|
24
|
-
const
|
|
24
|
+
// Active channel-connect polling handle (cancelled on Esc / unmount / new stream).
|
|
25
|
+
const connectPollingRef = useRef(null);
|
|
25
26
|
const streamingRef = useRef(streaming);
|
|
26
27
|
useEffect(() => { streamingRef.current = streaming; }, [streaming]);
|
|
27
28
|
// Ref so the polling onConnected callback can trigger sendToBackend from the
|
|
28
29
|
// most recent closure without resubscribing every render.
|
|
29
30
|
const sendBackendRef = useRef(async () => { });
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const stopConnectPolling = useCallback(() => {
|
|
32
|
+
if (connectPollingRef.current) {
|
|
33
|
+
connectPollingRef.current.cancel();
|
|
34
|
+
connectPollingRef.current = null;
|
|
34
35
|
}
|
|
35
36
|
}, []);
|
|
36
37
|
// Cargar agente inicial si se pasó por flag.
|
|
@@ -42,22 +43,22 @@ export function App({ version, stream, initialAgentId, dev, supportMode }) {
|
|
|
42
43
|
}, []);
|
|
43
44
|
// Cleanup on unmount.
|
|
44
45
|
useEffect(() => {
|
|
45
|
-
return () => {
|
|
46
|
-
}, [
|
|
46
|
+
return () => { stopConnectPolling(); };
|
|
47
|
+
}, [stopConnectPolling]);
|
|
47
48
|
// Atajos de teclado globales.
|
|
48
49
|
useInput((input, key) => {
|
|
49
50
|
if (key.escape) {
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
useStudioStore.getState().
|
|
51
|
+
const cc = useStudioStore.getState().connectCard;
|
|
52
|
+
if (cc && (cc.status === 'waiting' || cc.status === 'activating')) {
|
|
53
|
+
stopConnectPolling();
|
|
54
|
+
useStudioStore.getState().setConnectStatus('cancelled');
|
|
54
55
|
return;
|
|
55
56
|
}
|
|
56
57
|
useStudioStore.getState().abortStream();
|
|
57
58
|
return;
|
|
58
59
|
}
|
|
59
60
|
if (key.ctrl && input === 'c') {
|
|
60
|
-
|
|
61
|
+
stopConnectPolling();
|
|
61
62
|
useStudioStore.getState().abortStream();
|
|
62
63
|
exit();
|
|
63
64
|
return;
|
|
@@ -99,37 +100,43 @@ export function App({ version, stream, initialAgentId, dev, supportMode }) {
|
|
|
99
100
|
s.setAgentId(idCandidate);
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
|
-
//
|
|
103
|
-
|
|
103
|
+
// Channel connect tool (whatsapp / instagram / messenger): spin up the
|
|
104
|
+
// card + polling. The chunk discriminator (`whatsapp_link`,
|
|
105
|
+
// `instagram_link`, `messenger_link`) tells us which channel.
|
|
106
|
+
const channel = channelFromToolResultType(tr?.type);
|
|
107
|
+
if (channel && data) {
|
|
104
108
|
const linkData = data;
|
|
105
109
|
// No link to show (e.g. alreadyConnected without a new url) → skip card.
|
|
106
110
|
if (linkData.shortUrl) {
|
|
107
111
|
// Cancel any previous active polling before starting a new one.
|
|
108
|
-
|
|
109
|
-
s.
|
|
110
|
-
|
|
112
|
+
stopConnectPolling();
|
|
113
|
+
s.startConnectCard(channel, linkData, assistantId);
|
|
114
|
+
connectPollingRef.current = startConnectPolling(workspaceOptsFromStream(stream), channel, linkData, {
|
|
111
115
|
onAttempt(attempt) {
|
|
112
|
-
useStudioStore.getState().
|
|
116
|
+
useStudioStore.getState().updateConnectCard({ attempt });
|
|
113
117
|
},
|
|
114
118
|
onActivating() {
|
|
115
|
-
useStudioStore.getState().
|
|
119
|
+
useStudioStore.getState().setConnectStatus('activating');
|
|
116
120
|
},
|
|
117
|
-
onConnected(
|
|
118
|
-
useStudioStore.getState().
|
|
119
|
-
|
|
121
|
+
onConnected(label) {
|
|
122
|
+
useStudioStore.getState().setConnectStatus('connected', { connectedLabel: label });
|
|
123
|
+
connectPollingRef.current = null;
|
|
120
124
|
if (!streamingRef.current) {
|
|
121
|
-
const
|
|
125
|
+
const channelName = channel === 'whatsapp' ? 'WhatsApp'
|
|
126
|
+
: channel === 'instagram' ? 'Instagram'
|
|
127
|
+
: 'Messenger';
|
|
128
|
+
const followUp = `My ${channelName} is now connected. ${label ? `Label: ${label}.` : ''} ` +
|
|
122
129
|
`Continue with the agent setup.`;
|
|
123
130
|
void sendBackendRef.current(followUp);
|
|
124
131
|
}
|
|
125
132
|
},
|
|
126
133
|
onTimeout() {
|
|
127
|
-
useStudioStore.getState().
|
|
128
|
-
|
|
134
|
+
useStudioStore.getState().setConnectStatus('timeout');
|
|
135
|
+
connectPollingRef.current = null;
|
|
129
136
|
},
|
|
130
137
|
onError(message) {
|
|
131
|
-
useStudioStore.getState().
|
|
132
|
-
|
|
138
|
+
useStudioStore.getState().setConnectStatus('error', { errorMessage: message });
|
|
139
|
+
connectPollingRef.current = null;
|
|
133
140
|
},
|
|
134
141
|
});
|
|
135
142
|
}
|
|
@@ -169,7 +176,7 @@ export function App({ version, stream, initialAgentId, dev, supportMode }) {
|
|
|
169
176
|
useStudioStore.getState().compactInto(assistantId);
|
|
170
177
|
}
|
|
171
178
|
}
|
|
172
|
-
}, [stream, dev,
|
|
179
|
+
}, [stream, dev, stopConnectPolling]);
|
|
173
180
|
useEffect(() => { sendBackendRef.current = sendToBackend; }, [sendToBackend]);
|
|
174
181
|
const handleSubmit = useCallback(async (raw) => {
|
|
175
182
|
const parsed = parseSlash(raw);
|
|
@@ -4,35 +4,38 @@ import { Box, Static } from 'ink';
|
|
|
4
4
|
import { useStudioStore } from '../state/store.js';
|
|
5
5
|
import { Message } from './Message.js';
|
|
6
6
|
import { ToolCall } from './ToolCall.js';
|
|
7
|
-
import {
|
|
7
|
+
import { ConnectCard } from './ConnectCard.js';
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* Renderiza el log de la conversacion.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* This is what fixes the "Header echoed multiple times" effect you see when
|
|
15
|
-
* dragging the terminal window while the dynamic frame is taller than the
|
|
16
|
-
* viewport.
|
|
11
|
+
* Items estables (mensajes finalizados + tool steps resueltos que no estan
|
|
12
|
+
* anclados a una tarjeta de conexion activa) van dentro de <Static> para que
|
|
13
|
+
* Ink los escriba al scrollback una sola vez y no los re-renderice en resize.
|
|
17
14
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* El bloque dinamico al final contiene el mensaje en streaming, los steps que
|
|
16
|
+
* siguen en `running`, y la tarjeta de conexion (whatsapp/instagram/messenger)
|
|
17
|
+
* mientras esta activa.
|
|
20
18
|
*/
|
|
21
19
|
export function ChatLog() {
|
|
22
20
|
const messages = useStudioStore((s) => s.messages);
|
|
23
21
|
const steps = useStudioStore((s) => s.steps);
|
|
24
|
-
const
|
|
25
|
-
// Group steps by their anchor message for quick lookup.
|
|
22
|
+
const connectCard = useStudioStore((s) => s.connectCard);
|
|
26
23
|
const stepsByMsg = new Map();
|
|
27
24
|
for (const step of steps) {
|
|
28
25
|
const arr = stepsByMsg.get(step.afterMessageId) ?? [];
|
|
29
26
|
arr.push(step);
|
|
30
27
|
stepsByMsg.set(step.afterMessageId, arr);
|
|
31
28
|
}
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
29
|
+
// La tarjeta solo permanece en el dynamic frame mientras esta "viva"
|
|
30
|
+
// (polling activo o activando). En estados terminales
|
|
31
|
+
// (connected/cancelled/timeout/error) migra a <Static> junto a su mensaje
|
|
32
|
+
// ancla, de modo que los mensajes nuevos fluyen debajo.
|
|
33
|
+
const cardActive = !!connectCard && (connectCard.status === 'waiting' || connectCard.status === 'activating');
|
|
34
|
+
const cardInScrollback = !!connectCard && !cardActive;
|
|
35
|
+
// Mensajes que deben quedar en el dynamic frame:
|
|
36
|
+
// - el ultimo si sigue streameando
|
|
37
|
+
// - cualquiera con tool steps en running
|
|
38
|
+
// - el ancla de una tarjeta de conexion ACTIVA
|
|
36
39
|
const dynamicMsgIds = new Set();
|
|
37
40
|
const last = messages[messages.length - 1];
|
|
38
41
|
if (last?.streaming)
|
|
@@ -41,11 +44,10 @@ export function ChatLog() {
|
|
|
41
44
|
if (step.status === 'running')
|
|
42
45
|
dynamicMsgIds.add(step.afterMessageId);
|
|
43
46
|
}
|
|
44
|
-
if (
|
|
45
|
-
dynamicMsgIds.add(
|
|
46
|
-
// Build the static (scrollback) list. Each message and step gets a unique
|
|
47
|
-
// key so React/Ink only writes it once.
|
|
47
|
+
if (cardActive && connectCard)
|
|
48
|
+
dynamicMsgIds.add(connectCard.anchorMessageId);
|
|
48
49
|
const staticItems = [];
|
|
50
|
+
let cardInjected = false;
|
|
49
51
|
for (const m of messages) {
|
|
50
52
|
if (dynamicMsgIds.has(m.id))
|
|
51
53
|
continue;
|
|
@@ -53,7 +55,33 @@ export function ChatLog() {
|
|
|
53
55
|
for (const s of stepsByMsg.get(m.id) ?? []) {
|
|
54
56
|
staticItems.push({ kind: 'step', key: `s-${s.id}`, step: s });
|
|
55
57
|
}
|
|
58
|
+
if (cardInScrollback && connectCard && connectCard.anchorMessageId === m.id) {
|
|
59
|
+
staticItems.push({
|
|
60
|
+
kind: 'connectCard',
|
|
61
|
+
key: `cc-${connectCard.id}-${connectCard.status}`,
|
|
62
|
+
state: connectCard,
|
|
63
|
+
});
|
|
64
|
+
cardInjected = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Fallback: la tarjeta terminal quedo huerfana porque su mensaje ancla ya no
|
|
68
|
+
// esta (p.ej. tras /compact). La empujamos igual al scrollback para no perder
|
|
69
|
+
// el registro visual.
|
|
70
|
+
if (cardInScrollback && connectCard && !cardInjected) {
|
|
71
|
+
staticItems.push({
|
|
72
|
+
kind: 'connectCard',
|
|
73
|
+
key: `cc-${connectCard.id}-${connectCard.status}-orphan`,
|
|
74
|
+
state: connectCard,
|
|
75
|
+
});
|
|
56
76
|
}
|
|
57
77
|
const dynamicMessages = messages.filter((m) => dynamicMsgIds.has(m.id));
|
|
58
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: (item) =>
|
|
78
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: (item) => {
|
|
79
|
+
if (item.kind === 'message') {
|
|
80
|
+
return _jsx(Message, { message: item.message }, item.key);
|
|
81
|
+
}
|
|
82
|
+
if (item.kind === 'step') {
|
|
83
|
+
return _jsx(ToolCall, { step: item.step }, item.key);
|
|
84
|
+
}
|
|
85
|
+
return _jsx(ConnectCard, { state: item.state }, item.key);
|
|
86
|
+
} }), _jsxs(Box, { flexDirection: "column", children: [dynamicMessages.map((m) => (_jsxs(React.Fragment, { children: [_jsx(Message, { message: m }), (stepsByMsg.get(m.id) ?? []).map((s) => (_jsx(ToolCall, { step: s }, s.id))), cardActive && connectCard && connectCard.anchorMessageId === m.id ? (_jsx(ConnectCard, { state: connectCard })) : null] }, m.id))), cardActive && connectCard && !messages.some((m) => m.id === connectCard.anchorMessageId) ? (_jsx(ConnectCard, { state: connectCard })) : null] })] }));
|
|
59
87
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
import { CONNECT_POLL_MAX_ATTEMPTS } from '../connect/types.js';
|
|
5
|
+
function formatExpiry(iso) {
|
|
6
|
+
if (!iso)
|
|
7
|
+
return '';
|
|
8
|
+
const d = new Date(iso);
|
|
9
|
+
if (Number.isNaN(d.getTime()))
|
|
10
|
+
return '';
|
|
11
|
+
return d.toLocaleString('en-US', {
|
|
12
|
+
day: 'numeric',
|
|
13
|
+
month: 'short',
|
|
14
|
+
hour: '2-digit',
|
|
15
|
+
minute: '2-digit',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Envuelve una URL en un hyperlink OSC 8. Terminales que no lo soportan
|
|
20
|
+
* imprimen solo el label visible — iTerm2, kitty, Wezterm, Alacritty (reciente),
|
|
21
|
+
* VS Code/Hyper modernos si lo soportan.
|
|
22
|
+
*/
|
|
23
|
+
function osc8(url, label) {
|
|
24
|
+
return `\u001b]8;;${url}\u001b\\${label}\u001b]8;;\u001b\\`;
|
|
25
|
+
}
|
|
26
|
+
function copyFor(channel) {
|
|
27
|
+
switch (channel) {
|
|
28
|
+
case 'whatsapp':
|
|
29
|
+
return {
|
|
30
|
+
headerWaiting: 'Connect your WhatsApp',
|
|
31
|
+
headerConnected: 'WhatsApp connected',
|
|
32
|
+
typeLabel: (lt) => (lt === 'whatsapp_business' ? 'WhatsApp Business' : 'WhatsApp Cloud API'),
|
|
33
|
+
hint: "Open the link from a phone with WhatsApp installed. We'll keep watching for the new number.",
|
|
34
|
+
connectedTpl: (n) => `Number ${n || '(connected)'} activated successfully.`,
|
|
35
|
+
};
|
|
36
|
+
case 'instagram':
|
|
37
|
+
return {
|
|
38
|
+
headerWaiting: 'Connect your Instagram',
|
|
39
|
+
headerConnected: 'Instagram connected',
|
|
40
|
+
typeLabel: () => 'Instagram Business',
|
|
41
|
+
hint: "Open the link in your browser and authorize the Instagram Business account linked to a Facebook Page. We'll keep watching for the connection.",
|
|
42
|
+
connectedTpl: (n) => `Account ${n || '(connected)'} activated successfully.`,
|
|
43
|
+
};
|
|
44
|
+
case 'messenger':
|
|
45
|
+
return {
|
|
46
|
+
headerWaiting: 'Connect your Messenger',
|
|
47
|
+
headerConnected: 'Messenger connected',
|
|
48
|
+
typeLabel: () => 'Facebook Page',
|
|
49
|
+
hint: "Open the link in your browser and select the Facebook Page(s) you want to use with Messenger. We'll keep watching for the connection.",
|
|
50
|
+
connectedTpl: (n) => `Page ${n || '(connected)'} activated successfully.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function ConnectCard({ state }) {
|
|
55
|
+
const { channel, linkData, status, connectedLabel, errorMessage, attempt } = state;
|
|
56
|
+
const shortUrl = linkData.shortUrl || '';
|
|
57
|
+
const expiry = formatExpiry(linkData.expiresAt);
|
|
58
|
+
const copy = copyFor(channel);
|
|
59
|
+
const typeLabel = copy.typeLabel(linkData.linkType);
|
|
60
|
+
let headerIcon = '●';
|
|
61
|
+
let headerColor = 'green';
|
|
62
|
+
let headerText = copy.headerWaiting;
|
|
63
|
+
if (status === 'connected') {
|
|
64
|
+
headerIcon = '✓';
|
|
65
|
+
headerColor = 'green';
|
|
66
|
+
headerText = copy.headerConnected;
|
|
67
|
+
}
|
|
68
|
+
else if (status === 'error') {
|
|
69
|
+
headerIcon = '✖';
|
|
70
|
+
headerColor = 'red';
|
|
71
|
+
headerText = 'Could not activate the integration';
|
|
72
|
+
}
|
|
73
|
+
else if (status === 'timeout') {
|
|
74
|
+
headerIcon = '○';
|
|
75
|
+
headerColor = 'yellow';
|
|
76
|
+
headerText = 'No connection detected';
|
|
77
|
+
}
|
|
78
|
+
else if (status === 'cancelled') {
|
|
79
|
+
headerIcon = '○';
|
|
80
|
+
headerColor = 'yellow';
|
|
81
|
+
headerText = 'Connection cancelled';
|
|
82
|
+
}
|
|
83
|
+
const showLink = status !== 'connected' && status !== 'cancelled' && !!shortUrl;
|
|
84
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === 'error' ? 'red' : status === 'connected' ? 'green' : 'gray', paddingX: 1, marginY: 1, marginLeft: 2, children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, color: headerColor, children: [headerIcon, " "] }), _jsx(Text, { bold: true, children: headerText }), status !== 'connected' && status !== 'error' && status !== 'cancelled' ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsxs(Text, { dimColor: true, children: ["\u00B7 ", typeLabel] })] })) : null] }), showLink ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "link: " }), _jsx(Text, { color: "cyan", underline: true, children: osc8(shortUrl, shortUrl) })] }), expiry ? (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: ["expires: ", expiry] }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: copy.hint }) })] })) : null, _jsxs(Box, { marginTop: 1, children: [status === 'waiting' ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Waiting for connection\u2026 " }), _jsxs(Text, { dimColor: true, children: ["(", attempt, "/", CONNECT_POLL_MAX_ATTEMPTS, ")"] }), _jsx(Text, { dimColor: true, children: " \u00B7 press Esc to cancel" })] })) : null, status === 'activating' ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Connection detected, activating\u2026" })] })) : null, status === 'connected' ? (_jsx(Text, { color: "green", children: copy.connectedTpl(connectedLabel || '') })) : null, status === 'timeout' ? (_jsx(Text, { dimColor: true, children: "We didn't detect the connection within 5 minutes. Ask the agent to try again." })) : null, status === 'cancelled' ? (_jsx(Text, { dimColor: true, children: "Polling cancelled by user." })) : null, status === 'error' ? (_jsx(Text, { color: "red", children: errorMessage || 'An error occurred while activating the integration.' })) : null] })] }));
|
|
85
|
+
}
|
|
@@ -7,5 +7,5 @@ function fmt(n) {
|
|
|
7
7
|
return String(n);
|
|
8
8
|
}
|
|
9
9
|
export function Footer({ inputTokens, outputTokens, streaming }) {
|
|
10
|
-
return (_jsxs(Box, { paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "tokens: " }), _jsx(Text, { children: fmt(inputTokens) }), _jsx(Text, { dimColor: true, children: " in / " }), _jsx(Text, { children: fmt(outputTokens) }), _jsx(Text, { dimColor: true, children: " out" }), streaming ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: "yellow", children: " streaming\u2026" })] })) : null] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "/help \u00B7 Esc cancel \u00B7 Ctrl+C quit" }) })] }));
|
|
10
|
+
return (_jsxs(Box, { paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "tokens: " }), _jsx(Text, { children: fmt(inputTokens) }), _jsx(Text, { dimColor: true, children: " in / " }), _jsx(Text, { children: fmt(outputTokens) }), _jsx(Text, { dimColor: true, children: " out" }), streaming ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: "yellow", children: " streaming\u2026" })] })) : null] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "/help \u00B7 \u2191\u2193 history \u00B7 Tab accept \u00B7 Esc cancel \u00B7 Ctrl+C quit" }) })] }));
|
|
11
11
|
}
|
|
@@ -1,15 +1,147 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from 'react';
|
|
3
|
-
import { Box, Text } from 'ink';
|
|
4
|
-
import
|
|
2
|
+
import { useMemo, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { useStudioStore } from '../state/store.js';
|
|
5
|
+
/**
|
|
6
|
+
* Caja de texto del REPL. Implementación custom (sin ink-text-input) para poder
|
|
7
|
+
* soportar:
|
|
8
|
+
*
|
|
9
|
+
* - Historial estilo bash: ↑/↓ navega por mensajes previos del usuario.
|
|
10
|
+
* - Ghost text estilo zsh autosuggestions: muestra en gris la coincidencia
|
|
11
|
+
* más reciente del historial cuyo prefijo es lo que el usuario ya tipeó.
|
|
12
|
+
* Se acepta con Tab o → (solo cuando el cursor está al final del texto).
|
|
13
|
+
*
|
|
14
|
+
* Esc y Ctrl+C no se manejan aquí — burbujean al useInput global de App para
|
|
15
|
+
* que cancele stream / haga exit, sin cambiar el comportamiento existente.
|
|
16
|
+
*/
|
|
5
17
|
export function Input({ disabled, onSubmit }) {
|
|
6
18
|
const [value, setValue] = useState('');
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
const [cursor, setCursor] = useState(0);
|
|
20
|
+
// -1 = no estamos navegando historial (ghost activo, texto editable libre).
|
|
21
|
+
// 0 = mensaje más reciente; aumenta hacia mensajes más viejos.
|
|
22
|
+
const [historyIdx, setHistoryIdx] = useState(-1);
|
|
23
|
+
// Historial derivado del store: solo mensajes del usuario, deduplicados
|
|
24
|
+
// consecutivamente, más recientes primero.
|
|
25
|
+
const history = useStudioStore((s) => {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (let i = s.messages.length - 1; i >= 0; i--) {
|
|
28
|
+
const m = s.messages[i];
|
|
29
|
+
if (m.role !== 'user')
|
|
30
|
+
continue;
|
|
31
|
+
if (out[out.length - 1] === m.content)
|
|
32
|
+
continue;
|
|
33
|
+
out.push(m.content);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
});
|
|
37
|
+
// Ghost: primer match del historial cuyo prefijo es `value`. Solo se muestra
|
|
38
|
+
// mientras NO estemos navegando con flechas (para no confundir al usuario).
|
|
39
|
+
const ghost = useMemo(() => {
|
|
40
|
+
if (!value || historyIdx >= 0)
|
|
41
|
+
return '';
|
|
42
|
+
for (const h of history) {
|
|
43
|
+
if (h.length > value.length && h.startsWith(value)) {
|
|
44
|
+
return h.slice(value.length);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return '';
|
|
48
|
+
}, [value, history, historyIdx]);
|
|
49
|
+
const resetAfterSubmit = () => {
|
|
50
|
+
setValue('');
|
|
51
|
+
setCursor(0);
|
|
52
|
+
setHistoryIdx(-1);
|
|
53
|
+
};
|
|
54
|
+
useInput((input, key) => {
|
|
55
|
+
if (disabled)
|
|
56
|
+
return;
|
|
57
|
+
// Enter → submit
|
|
58
|
+
if (key.return) {
|
|
59
|
+
if (!value.trim())
|
|
60
|
+
return;
|
|
61
|
+
const toSend = value;
|
|
62
|
+
resetAfterSubmit();
|
|
63
|
+
onSubmit(toSend);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// ↑ → mensaje más viejo
|
|
67
|
+
if (key.upArrow) {
|
|
68
|
+
if (history.length === 0)
|
|
69
|
+
return;
|
|
70
|
+
const next = Math.min(historyIdx + 1, history.length - 1);
|
|
71
|
+
const text = history[next];
|
|
72
|
+
setHistoryIdx(next);
|
|
73
|
+
setValue(text);
|
|
74
|
+
setCursor(text.length);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// ↓ → mensaje más reciente / volver al input vacío
|
|
78
|
+
if (key.downArrow) {
|
|
79
|
+
if (historyIdx <= 0) {
|
|
80
|
+
setHistoryIdx(-1);
|
|
81
|
+
setValue('');
|
|
82
|
+
setCursor(0);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const next = historyIdx - 1;
|
|
86
|
+
const text = history[next];
|
|
87
|
+
setHistoryIdx(next);
|
|
88
|
+
setValue(text);
|
|
89
|
+
setCursor(text.length);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Tab → aceptar ghost completo
|
|
93
|
+
if (key.tab) {
|
|
94
|
+
if (ghost) {
|
|
95
|
+
const full = value + ghost;
|
|
96
|
+
setValue(full);
|
|
97
|
+
setCursor(full.length);
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// → → mover cursor; si está al final y hay ghost, acepta el ghost
|
|
102
|
+
if (key.rightArrow) {
|
|
103
|
+
if (cursor < value.length) {
|
|
104
|
+
setCursor(cursor + 1);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (ghost) {
|
|
108
|
+
const full = value + ghost;
|
|
109
|
+
setValue(full);
|
|
110
|
+
setCursor(full.length);
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// ← → mover cursor
|
|
115
|
+
if (key.leftArrow) {
|
|
116
|
+
if (cursor > 0)
|
|
117
|
+
setCursor(cursor - 1);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Backspace / Delete → borrar antes del cursor
|
|
121
|
+
if (key.backspace || key.delete) {
|
|
122
|
+
if (cursor === 0)
|
|
123
|
+
return;
|
|
124
|
+
const next = value.slice(0, cursor - 1) + value.slice(cursor);
|
|
125
|
+
setValue(next);
|
|
126
|
+
setCursor(cursor - 1);
|
|
127
|
+
if (historyIdx >= 0)
|
|
128
|
+
setHistoryIdx(-1);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Esc, Ctrl+*, Meta → no consumir, los maneja App.tsx
|
|
132
|
+
if (key.escape || key.ctrl || key.meta)
|
|
133
|
+
return;
|
|
134
|
+
// Char inputs (incluye pegado multibyte). Filtramos secuencias raras
|
|
135
|
+
// que Ink a veces entrega como `input` vacío.
|
|
136
|
+
if (input && input.length > 0) {
|
|
137
|
+
// Si veníamos de navegar historial y el usuario escribe algo, salimos
|
|
138
|
+
// del modo navegación y empezamos a editar normalmente.
|
|
139
|
+
if (historyIdx >= 0)
|
|
140
|
+
setHistoryIdx(-1);
|
|
141
|
+
const next = value.slice(0, cursor) + input + value.slice(cursor);
|
|
142
|
+
setValue(next);
|
|
143
|
+
setCursor(cursor + input.length);
|
|
144
|
+
}
|
|
145
|
+
}, { isActive: !disabled });
|
|
146
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: disabled ? 'gray' : 'green', paddingX: 1, children: [_jsxs(Text, { color: disabled ? 'gray' : 'green', bold: true, children: [disabled ? '·' : '›', ' '] }), disabled ? (_jsx(Text, { dimColor: true, children: "Waiting for response\u2026 (press Esc to cancel)" })) : value.length === 0 ? (_jsx(Text, { dimColor: true, children: "Type a message or /help" })) : (_jsxs(Text, { children: [_jsx(Text, { children: value.slice(0, cursor) }), _jsx(Text, { inverse: true, children: value.slice(cursor, cursor + 1) || ' ' }), _jsx(Text, { children: value.slice(cursor + 1) }), ghost ? _jsx(Text, { dimColor: true, children: ghost }) : null] }))] }));
|
|
15
147
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getBaseUrl } from '../api/studioApi.js';
|
|
2
|
+
import { platformCodeForChannel } from './types.js';
|
|
3
|
+
function headers(opts) {
|
|
4
|
+
const h = {
|
|
5
|
+
'Content-Type': 'application/json',
|
|
6
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
7
|
+
'x-workspace-id': opts.workspaceId,
|
|
8
|
+
};
|
|
9
|
+
if (opts.userId)
|
|
10
|
+
h['x-user-id'] = opts.userId;
|
|
11
|
+
return h;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* GET /api/workspace/{workspaceId} — el backend a veces devuelve un array
|
|
15
|
+
* (el front usa `[0]`). Devuelve el primer elemento o null.
|
|
16
|
+
*/
|
|
17
|
+
export async function getWorkspaceById(opts, signal) {
|
|
18
|
+
const url = `${getBaseUrl(opts.zone, opts.dev)}/api/workspace/${encodeURIComponent(opts.workspaceId)}`;
|
|
19
|
+
const res = await fetch(url, { method: 'GET', headers: headers(opts), signal });
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
throw new Error(`GET workspace ${res.status} ${res.statusText}`);
|
|
22
|
+
}
|
|
23
|
+
const json = (await res.json());
|
|
24
|
+
if (!json)
|
|
25
|
+
return null;
|
|
26
|
+
if (Array.isArray(json))
|
|
27
|
+
return json[0] ?? null;
|
|
28
|
+
return json;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* POST /api/workspace/{workspaceId}/integrations/{integrationId}/activate
|
|
32
|
+
* Body: { isActive: true, platformCode }
|
|
33
|
+
*
|
|
34
|
+
* `platformCode` se deriva del canal logico (whatsapp/instagram/facebook).
|
|
35
|
+
*/
|
|
36
|
+
export async function activateIntegration(opts, channel, integrationId, signal) {
|
|
37
|
+
const url = `${getBaseUrl(opts.zone, opts.dev)}/api/workspace/${encodeURIComponent(opts.workspaceId)}/integrations/${encodeURIComponent(integrationId)}/activate`;
|
|
38
|
+
const res = await fetch(url, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: headers(opts),
|
|
41
|
+
body: JSON.stringify({ isActive: true, platformCode: platformCodeForChannel(channel) }),
|
|
42
|
+
signal,
|
|
43
|
+
});
|
|
44
|
+
const text = await res.text();
|
|
45
|
+
let parsed = {};
|
|
46
|
+
if (text) {
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(text);
|
|
49
|
+
}
|
|
50
|
+
catch { /* not json */ }
|
|
51
|
+
}
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
return { success: false, message: parsed.message || `HTTP ${res.status} ${res.statusText}` };
|
|
54
|
+
}
|
|
55
|
+
return parsed.success === false ? parsed : { success: true, ...parsed };
|
|
56
|
+
}
|
|
57
|
+
/** Adapter: deriva las opciones de fetch desde un StudioStreamOptions. */
|
|
58
|
+
export function workspaceOptsFromStream(stream) {
|
|
59
|
+
return {
|
|
60
|
+
apiKey: stream.apiKey,
|
|
61
|
+
workspaceId: stream.workspaceId,
|
|
62
|
+
zone: stream.zone,
|
|
63
|
+
dev: stream.dev,
|
|
64
|
+
userId: stream.userId,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { activateIntegration, getWorkspaceById } from './api.js';
|
|
2
|
+
import { CONNECT_POLL_INTERVAL_MS, CONNECT_POLL_MAX_ATTEMPTS, describeIntegration, findNewIntegrationForChannel, } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Inicia el polling de conexion de canal. Devuelve un handle para cancelarlo.
|
|
5
|
+
* El loop:
|
|
6
|
+
* 1. GET workspace cada CONNECT_POLL_INTERVAL_MS hasta CONNECT_POLL_MAX_ATTEMPTS.
|
|
7
|
+
* 2. Busca una integracion del `channel` cuyo id NO este en `existingIntegrationIds`.
|
|
8
|
+
* 3. Cuando la encuentra, POST .../activate y reporta el resultado.
|
|
9
|
+
*/
|
|
10
|
+
export function startConnectPolling(opts, channel, linkData, cb) {
|
|
11
|
+
const knownIds = new Set(linkData.existingIntegrationIds ?? []);
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
let attempts = 0;
|
|
14
|
+
let busy = false;
|
|
15
|
+
let stopped = false;
|
|
16
|
+
let timer = null;
|
|
17
|
+
const stop = () => {
|
|
18
|
+
stopped = true;
|
|
19
|
+
if (timer) {
|
|
20
|
+
clearInterval(timer);
|
|
21
|
+
timer = null;
|
|
22
|
+
}
|
|
23
|
+
controller.abort();
|
|
24
|
+
};
|
|
25
|
+
const tick = async () => {
|
|
26
|
+
if (busy || stopped)
|
|
27
|
+
return;
|
|
28
|
+
busy = true;
|
|
29
|
+
attempts++;
|
|
30
|
+
cb.onAttempt(attempts);
|
|
31
|
+
try {
|
|
32
|
+
const ws = await getWorkspaceById(opts, controller.signal);
|
|
33
|
+
const newIntegration = findNewIntegrationForChannel(channel, ws?.integrations, knownIds);
|
|
34
|
+
if (newIntegration?.id) {
|
|
35
|
+
const label = describeIntegration(channel, newIntegration);
|
|
36
|
+
cb.onActivating(newIntegration.id, label);
|
|
37
|
+
if (timer) {
|
|
38
|
+
clearInterval(timer);
|
|
39
|
+
timer = null;
|
|
40
|
+
}
|
|
41
|
+
const res = await activateIntegration(opts, channel, newIntegration.id, controller.signal);
|
|
42
|
+
if (res.success !== false) {
|
|
43
|
+
cb.onConnected(label);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
cb.onError(res.message || 'Could not activate the integration.');
|
|
47
|
+
}
|
|
48
|
+
stopped = true;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err.name === 'AbortError')
|
|
54
|
+
return;
|
|
55
|
+
// Network transient: swallow and keep polling.
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
busy = false;
|
|
59
|
+
}
|
|
60
|
+
if (attempts >= CONNECT_POLL_MAX_ATTEMPTS) {
|
|
61
|
+
if (timer) {
|
|
62
|
+
clearInterval(timer);
|
|
63
|
+
timer = null;
|
|
64
|
+
}
|
|
65
|
+
stopped = true;
|
|
66
|
+
cb.onTimeout();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
timer = setInterval(() => { void tick(); }, CONNECT_POLL_INTERVAL_MS);
|
|
70
|
+
// Primer attempt inmediato para feedback visual.
|
|
71
|
+
void tick();
|
|
72
|
+
return {
|
|
73
|
+
cancel() { stop(); },
|
|
74
|
+
get stopped() { return stopped; },
|
|
75
|
+
};
|
|
76
|
+
}
|