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.
@@ -1,32 +1,176 @@
1
- import React, { useState } from 'react';
2
- import { Box, Text } from 'ink';
3
- import TextInput from 'ink-text-input';
1
+ import React, { useMemo, useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { useStudioStore } from '../state/store.js';
4
4
 
5
5
  interface InputProps {
6
6
  disabled: boolean;
7
7
  onSubmit: (value: string) => void;
8
8
  }
9
9
 
10
+ /**
11
+ * Caja de texto del REPL. Implementación custom (sin ink-text-input) para poder
12
+ * soportar:
13
+ *
14
+ * - Historial estilo bash: ↑/↓ navega por mensajes previos del usuario.
15
+ * - Ghost text estilo zsh autosuggestions: muestra en gris la coincidencia
16
+ * más reciente del historial cuyo prefijo es lo que el usuario ya tipeó.
17
+ * Se acepta con Tab o → (solo cuando el cursor está al final del texto).
18
+ *
19
+ * Esc y Ctrl+C no se manejan aquí — burbujean al useInput global de App para
20
+ * que cancele stream / haga exit, sin cambiar el comportamiento existente.
21
+ */
10
22
  export function Input({ disabled, onSubmit }: InputProps): React.ReactElement {
11
23
  const [value, setValue] = useState('');
24
+ const [cursor, setCursor] = useState(0);
25
+ // -1 = no estamos navegando historial (ghost activo, texto editable libre).
26
+ // 0 = mensaje más reciente; aumenta hacia mensajes más viejos.
27
+ const [historyIdx, setHistoryIdx] = useState(-1);
28
+
29
+ // Historial derivado del store: solo mensajes del usuario, deduplicados
30
+ // consecutivamente, más recientes primero.
31
+ const history = useStudioStore((s) => {
32
+ const out: string[] = [];
33
+ for (let i = s.messages.length - 1; i >= 0; i--) {
34
+ const m = s.messages[i];
35
+ if (m.role !== 'user') continue;
36
+ if (out[out.length - 1] === m.content) continue;
37
+ out.push(m.content);
38
+ }
39
+ return out;
40
+ });
41
+
42
+ // Ghost: primer match del historial cuyo prefijo es `value`. Solo se muestra
43
+ // mientras NO estemos navegando con flechas (para no confundir al usuario).
44
+ const ghost = useMemo(() => {
45
+ if (!value || historyIdx >= 0) return '';
46
+ for (const h of history) {
47
+ if (h.length > value.length && h.startsWith(value)) {
48
+ return h.slice(value.length);
49
+ }
50
+ }
51
+ return '';
52
+ }, [value, history, historyIdx]);
53
+
54
+ const resetAfterSubmit = () => {
55
+ setValue('');
56
+ setCursor(0);
57
+ setHistoryIdx(-1);
58
+ };
59
+
60
+ useInput(
61
+ (input, key) => {
62
+ if (disabled) return;
63
+
64
+ // Enter → submit
65
+ if (key.return) {
66
+ if (!value.trim()) return;
67
+ const toSend = value;
68
+ resetAfterSubmit();
69
+ onSubmit(toSend);
70
+ return;
71
+ }
72
+
73
+ // ↑ → mensaje más viejo
74
+ if (key.upArrow) {
75
+ if (history.length === 0) return;
76
+ const next = Math.min(historyIdx + 1, history.length - 1);
77
+ const text = history[next];
78
+ setHistoryIdx(next);
79
+ setValue(text);
80
+ setCursor(text.length);
81
+ return;
82
+ }
83
+
84
+ // ↓ → mensaje más reciente / volver al input vacío
85
+ if (key.downArrow) {
86
+ if (historyIdx <= 0) {
87
+ setHistoryIdx(-1);
88
+ setValue('');
89
+ setCursor(0);
90
+ return;
91
+ }
92
+ const next = historyIdx - 1;
93
+ const text = history[next];
94
+ setHistoryIdx(next);
95
+ setValue(text);
96
+ setCursor(text.length);
97
+ return;
98
+ }
99
+
100
+ // Tab → aceptar ghost completo
101
+ if (key.tab) {
102
+ if (ghost) {
103
+ const full = value + ghost;
104
+ setValue(full);
105
+ setCursor(full.length);
106
+ }
107
+ return;
108
+ }
109
+
110
+ // → → mover cursor; si está al final y hay ghost, acepta el ghost
111
+ if (key.rightArrow) {
112
+ if (cursor < value.length) {
113
+ setCursor(cursor + 1);
114
+ return;
115
+ }
116
+ if (ghost) {
117
+ const full = value + ghost;
118
+ setValue(full);
119
+ setCursor(full.length);
120
+ }
121
+ return;
122
+ }
123
+
124
+ // ← → mover cursor
125
+ if (key.leftArrow) {
126
+ if (cursor > 0) setCursor(cursor - 1);
127
+ return;
128
+ }
129
+
130
+ // Backspace / Delete → borrar antes del cursor
131
+ if (key.backspace || key.delete) {
132
+ if (cursor === 0) return;
133
+ const next = value.slice(0, cursor - 1) + value.slice(cursor);
134
+ setValue(next);
135
+ setCursor(cursor - 1);
136
+ if (historyIdx >= 0) setHistoryIdx(-1);
137
+ return;
138
+ }
139
+
140
+ // Esc, Ctrl+*, Meta → no consumir, los maneja App.tsx
141
+ if (key.escape || key.ctrl || key.meta) return;
142
+
143
+ // Char inputs (incluye pegado multibyte). Filtramos secuencias raras
144
+ // que Ink a veces entrega como `input` vacío.
145
+ if (input && input.length > 0) {
146
+ // Si veníamos de navegar historial y el usuario escribe algo, salimos
147
+ // del modo navegación y empezamos a editar normalmente.
148
+ if (historyIdx >= 0) setHistoryIdx(-1);
149
+ const next = value.slice(0, cursor) + input + value.slice(cursor);
150
+ setValue(next);
151
+ setCursor(cursor + input.length);
152
+ }
153
+ },
154
+ { isActive: !disabled },
155
+ );
12
156
 
13
157
  return (
14
158
  <Box borderStyle="round" borderColor={disabled ? 'gray' : 'green'} paddingX={1}>
15
159
  <Text color={disabled ? 'gray' : 'green'} bold>
16
160
  {disabled ? '·' : '›'}{' '}
17
161
  </Text>
18
- <TextInput
19
- value={value}
20
- onChange={disabled ? () => {} : setValue}
21
- onSubmit={(v: string) => {
22
- if (disabled) return;
23
- if (!v.trim()) return;
24
- setValue('');
25
- onSubmit(v);
26
- }}
27
- placeholder={disabled ? 'Waiting for response… (press Esc to cancel)' : 'Type a message or /help'}
28
- showCursor={!disabled}
29
- />
162
+ {disabled ? (
163
+ <Text dimColor>Waiting for response… (press Esc to cancel)</Text>
164
+ ) : value.length === 0 ? (
165
+ <Text dimColor>Type a message or /help</Text>
166
+ ) : (
167
+ <Text>
168
+ <Text>{value.slice(0, cursor)}</Text>
169
+ <Text inverse>{value.slice(cursor, cursor + 1) || ' '}</Text>
170
+ <Text>{value.slice(cursor + 1)}</Text>
171
+ {ghost ? <Text dimColor>{ghost}</Text> : null}
172
+ </Text>
173
+ )}
30
174
  </Box>
31
175
  );
32
176
  }
@@ -1,10 +1,10 @@
1
1
  import { getBaseUrl } from '../api/studioApi.js';
2
2
  import type { StudioStreamOptions } from '../api/types.js';
3
- import type { WorkspaceIntegrationItem } from './types.js';
3
+ import { platformCodeForChannel, type ConnectChannel, type WorkspaceIntegrationItem } from './types.js';
4
4
 
5
5
  /**
6
- * REST helpers used during the WhatsApp connection polling loop.
7
- * Reuses the same zone/dev base URL + Bearer headers as the SSE client.
6
+ * Helpers REST para el polling/activacion durante la conexion de un canal.
7
+ * Reusa el mismo base URL + Bearer del SSE client.
8
8
  */
9
9
 
10
10
  interface WorkspaceFetchOpts {
@@ -31,8 +31,8 @@ function headers(opts: WorkspaceFetchOpts): Record<string, string> {
31
31
  }
32
32
 
33
33
  /**
34
- * GET /api/workspace/{workspaceId} — the API returns an array of workspaces
35
- * (the front uses `[0]`). Returns the first element or null.
34
+ * GET /api/workspace/{workspaceId} — el backend a veces devuelve un array
35
+ * (el front usa `[0]`). Devuelve el primer elemento o null.
36
36
  */
37
37
  export async function getWorkspaceById(
38
38
  opts: WorkspaceFetchOpts,
@@ -56,10 +56,13 @@ export interface ActivateResult {
56
56
 
57
57
  /**
58
58
  * POST /api/workspace/{workspaceId}/integrations/{integrationId}/activate
59
- * Body: { isActive: true, platformCode: "whatsapp" }
59
+ * Body: { isActive: true, platformCode }
60
+ *
61
+ * `platformCode` se deriva del canal logico (whatsapp/instagram/facebook).
60
62
  */
61
63
  export async function activateIntegration(
62
64
  opts: WorkspaceFetchOpts,
65
+ channel: ConnectChannel,
63
66
  integrationId: string,
64
67
  signal?: AbortSignal,
65
68
  ): Promise<ActivateResult> {
@@ -67,7 +70,7 @@ export async function activateIntegration(
67
70
  const res = await fetch(url, {
68
71
  method: 'POST',
69
72
  headers: headers(opts),
70
- body: JSON.stringify({ isActive: true, platformCode: 'whatsapp' }),
73
+ body: JSON.stringify({ isActive: true, platformCode: platformCodeForChannel(channel) }),
71
74
  signal,
72
75
  });
73
76
  const text = await res.text();
@@ -81,10 +84,9 @@ export async function activateIntegration(
81
84
  return parsed.success === false ? parsed : { success: true, ...parsed };
82
85
  }
83
86
 
84
- /** Re-export for callers that need just the auth shape. */
85
87
  export type WorkspaceAuthOpts = WorkspaceFetchOpts;
86
88
 
87
- /** Adapter: derives the workspace fetch options from a StudioStreamOptions. */
89
+ /** Adapter: deriva las opciones de fetch desde un StudioStreamOptions. */
88
90
  export function workspaceOptsFromStream(stream: StudioStreamOptions): WorkspaceAuthOpts {
89
91
  return {
90
92
  apiKey: stream.apiKey,
@@ -1,34 +1,38 @@
1
1
  import { activateIntegration, getWorkspaceById, type WorkspaceAuthOpts } from './api.js';
2
2
  import {
3
- findNewWhatsappIntegration,
4
- WA_POLL_INTERVAL_MS,
5
- WA_POLL_MAX_ATTEMPTS,
6
- type WhatsappLinkData,
3
+ CONNECT_POLL_INTERVAL_MS,
4
+ CONNECT_POLL_MAX_ATTEMPTS,
5
+ describeIntegration,
6
+ findNewIntegrationForChannel,
7
+ type ChannelLinkData,
8
+ type ConnectChannel,
7
9
  } from './types.js';
8
10
 
9
11
  export interface PollingCallbacks {
10
12
  onAttempt(attempt: number): void;
11
- onActivating(integrationId: string, number: string): void;
12
- onConnected(number: string): void;
13
+ onActivating(integrationId: string, label: string): void;
14
+ onConnected(label: string): void;
13
15
  onTimeout(): void;
14
16
  onError(message: string): void;
15
17
  }
16
18
 
17
19
  export interface PollingHandle {
18
20
  cancel(): void;
19
- /** True once the loop has stopped (success, timeout, error, or cancel). */
21
+ /** True una vez que el loop se detuvo (success, timeout, error o cancel). */
20
22
  readonly stopped: boolean;
21
23
  }
22
24
 
23
25
  /**
24
- * Starts the polling loop. Returns a handle to cancel it. The loop:
25
- * 1. GETs the workspace every WA_POLL_INTERVAL_MS up to WA_POLL_MAX_ATTEMPTS.
26
- * 2. Looks for a whatsapp integration whose id isn't in `existingIntegrationIds`.
27
- * 3. When found, calls POST .../activate and reports the result.
26
+ * Inicia el polling de conexion de canal. Devuelve un handle para cancelarlo.
27
+ * El loop:
28
+ * 1. GET workspace cada CONNECT_POLL_INTERVAL_MS hasta CONNECT_POLL_MAX_ATTEMPTS.
29
+ * 2. Busca una integracion del `channel` cuyo id NO este en `existingIntegrationIds`.
30
+ * 3. Cuando la encuentra, POST .../activate y reporta el resultado.
28
31
  */
29
- export function startWhatsappPolling(
32
+ export function startConnectPolling(
30
33
  opts: WorkspaceAuthOpts,
31
- linkData: WhatsappLinkData,
34
+ channel: ConnectChannel,
35
+ linkData: ChannelLinkData,
32
36
  cb: PollingCallbacks,
33
37
  ): PollingHandle {
34
38
  const knownIds = new Set(linkData.existingIntegrationIds ?? []);
@@ -51,17 +55,14 @@ export function startWhatsappPolling(
51
55
  cb.onAttempt(attempts);
52
56
  try {
53
57
  const ws = await getWorkspaceById(opts, controller.signal);
54
- const newIntegration = findNewWhatsappIntegration(ws?.integrations, knownIds);
58
+ const newIntegration = findNewIntegrationForChannel(channel, ws?.integrations, knownIds);
55
59
  if (newIntegration?.id) {
56
- const number =
57
- newIntegration.cellphoneNumberFormat ||
58
- newIntegration.cellphoneNumber ||
59
- '';
60
- cb.onActivating(newIntegration.id, number);
60
+ const label = describeIntegration(channel, newIntegration);
61
+ cb.onActivating(newIntegration.id, label);
61
62
  if (timer) { clearInterval(timer); timer = null; }
62
- const res = await activateIntegration(opts, newIntegration.id, controller.signal);
63
+ const res = await activateIntegration(opts, channel, newIntegration.id, controller.signal);
63
64
  if (res.success !== false) {
64
- cb.onConnected(number);
65
+ cb.onConnected(label);
65
66
  } else {
66
67
  cb.onError(res.message || 'Could not activate the integration.');
67
68
  }
@@ -70,20 +71,20 @@ export function startWhatsappPolling(
70
71
  }
71
72
  } catch (err) {
72
73
  if ((err as Error).name === 'AbortError') return;
73
- // Transient network errors: swallow and keep polling.
74
+ // Network transient: swallow and keep polling.
74
75
  } finally {
75
76
  busy = false;
76
77
  }
77
78
 
78
- if (attempts >= WA_POLL_MAX_ATTEMPTS) {
79
+ if (attempts >= CONNECT_POLL_MAX_ATTEMPTS) {
79
80
  if (timer) { clearInterval(timer); timer = null; }
80
81
  stopped = true;
81
82
  cb.onTimeout();
82
83
  }
83
84
  };
84
85
 
85
- timer = setInterval(() => { void tick(); }, WA_POLL_INTERVAL_MS);
86
- // Trigger the first attempt immediately so the user sees activity right away.
86
+ timer = setInterval(() => { void tick(); }, CONNECT_POLL_INTERVAL_MS);
87
+ // Primer attempt inmediato para feedback visual.
87
88
  void tick();
88
89
 
89
90
  return {
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Tipos y helpers del flujo "Conectar canal desde Agent Studio".
3
+ *
4
+ * El backend emite `tool_result` con `type` = `whatsapp_link` |
5
+ * `instagram_link` | `messenger_link` segun la tool invocada. Todos comparten
6
+ * el mismo shape de datos (link corto + expiracion + baseline de integraciones)
7
+ * y el mismo patron de polling (GET workspace cada 5s hasta detectar el nuevo
8
+ * id) y activacion (POST .../activate).
9
+ *
10
+ * Este modulo es la version generalizada de lo que antes vivia en
11
+ * `src/studio/whatsapp/` cuando solo existia WhatsApp.
12
+ */
13
+
14
+ export type ConnectChannel = 'whatsapp' | 'instagram' | 'messenger';
15
+
16
+ /** Mapea el `type` del tool_result al canal logico del cliente. */
17
+ export function channelFromToolResultType(type: string | undefined): ConnectChannel | null {
18
+ if (type === 'whatsapp_link') return 'whatsapp';
19
+ if (type === 'instagram_link') return 'instagram';
20
+ if (type === 'messenger_link') return 'messenger';
21
+ return null;
22
+ }
23
+
24
+ /** Tipos de integracion (workspace.integrations[].type) a vigilar por canal. */
25
+ export function integrationTypesForChannel(channel: ConnectChannel): string[] {
26
+ switch (channel) {
27
+ case 'whatsapp': return ['whatsapp', 'whatsapp_business'];
28
+ case 'instagram': return ['instagram'];
29
+ case 'messenger': return ['facebook']; // Messenger se guarda como type=facebook
30
+ }
31
+ }
32
+
33
+ /**
34
+ * `platformCode` que espera POST .../activate. Para WhatsApp el endpoint usa
35
+ * este valor para una validacion de duplicados; para IG/Messenger no ejecuta
36
+ * ninguna logica especifica pero debe enviarse algo coherente.
37
+ */
38
+ export function platformCodeForChannel(channel: ConnectChannel): string {
39
+ switch (channel) {
40
+ case 'whatsapp': return 'whatsapp';
41
+ case 'instagram': return 'instagram';
42
+ case 'messenger': return 'facebook';
43
+ }
44
+ }
45
+
46
+ export interface ChannelLinkData {
47
+ shortUrl?: string | null;
48
+ /** whatsapp | whatsapp_business | instagram | facebook */
49
+ linkType?: string;
50
+ expiresAt?: string;
51
+ isNewLink?: boolean;
52
+ alreadyConnected?: boolean;
53
+ /** Snapshot WhatsApp (legacy, mantenido por compat con la tool de WA). */
54
+ connectedNumbers?: Array<{ id?: string; number?: string; alias?: string }>;
55
+ /** Snapshot IG/Messenger (pageId + nombre legible + alias). */
56
+ connectedItems?: Array<{ id?: string; pageId?: string; name?: string; alias?: string }>;
57
+ existingIntegrationIds?: string[];
58
+ }
59
+
60
+ export type ConnectStatus =
61
+ | 'waiting' // card visible, polling activo
62
+ | 'activating' // integracion detectada, activando via API
63
+ | 'connected' // activada con exito
64
+ | 'timeout' // intentos agotados
65
+ | 'cancelled' // usuario cancelo
66
+ | 'error'; // activacion fallo
67
+
68
+ export interface ConnectState {
69
+ /** Id estable para keys/diffs. */
70
+ id: string;
71
+ channel: ConnectChannel;
72
+ linkData: ChannelLinkData;
73
+ status: ConnectStatus;
74
+ /** Mensaje del chat al que ancla la tarjeta. */
75
+ anchorMessageId: string;
76
+ /** Etiqueta humana del item conectado (numero, pagina, etc.) — populado en `connected`. */
77
+ connectedLabel?: string;
78
+ errorMessage?: string;
79
+ attempt: number;
80
+ }
81
+
82
+ /** Shape minimo de un item de `workspace.integrations` que devuelve el GET. */
83
+ export interface WorkspaceIntegrationItem {
84
+ id?: string;
85
+ type?: string;
86
+ /** whatsapp */
87
+ cellphoneNumber?: string;
88
+ cellphoneNumberFormat?: string;
89
+ /** facebook (messenger) */
90
+ facebookPageId?: string;
91
+ /** instagram */
92
+ instagramPageId?: string;
93
+ /** comun: nombre legible (display name de Meta) */
94
+ srcName?: string;
95
+ /** comun: alias humano puesto manualmente */
96
+ aliasName?: string;
97
+ creationDate?: string;
98
+ isActive?: number | boolean;
99
+ }
100
+
101
+ export const CONNECT_POLL_INTERVAL_MS = 5000;
102
+ export const CONNECT_POLL_MAX_ATTEMPTS = 60; // 5 minutos
103
+
104
+ /**
105
+ * Devuelve la integracion mas reciente del canal cuyo id NO este en el baseline.
106
+ * Si hay varias nuevas, gana la de `creationDate` mayor.
107
+ */
108
+ export function findNewIntegrationForChannel(
109
+ channel: ConnectChannel,
110
+ integrations: WorkspaceIntegrationItem[] | undefined,
111
+ knownIds: Set<string>,
112
+ ): WorkspaceIntegrationItem | null {
113
+ if (!integrations?.length) return null;
114
+
115
+ const types = new Set(integrationTypesForChannel(channel));
116
+ const candidates = integrations.filter(
117
+ (i) => !!i.type && types.has(i.type) && !!i.id && !knownIds.has(i.id as string),
118
+ );
119
+ if (!candidates.length) return null;
120
+
121
+ return candidates.reduce((latest, current) => {
122
+ const a = latest.creationDate ? new Date(latest.creationDate).getTime() : 0;
123
+ const b = current.creationDate ? new Date(current.creationDate).getTime() : 0;
124
+ return b > a ? current : latest;
125
+ });
126
+ }
127
+
128
+ /** Etiqueta humana del item recien conectado (numero, nombre de pagina, etc.). */
129
+ export function describeIntegration(
130
+ channel: ConnectChannel,
131
+ i: WorkspaceIntegrationItem,
132
+ ): string {
133
+ switch (channel) {
134
+ case 'whatsapp':
135
+ return i.cellphoneNumberFormat || i.cellphoneNumber || i.srcName || '';
136
+ case 'instagram':
137
+ return i.srcName || i.aliasName || i.instagramPageId || '';
138
+ case 'messenger':
139
+ return i.srcName || i.aliasName || i.facebookPageId || '';
140
+ }
141
+ }
@@ -15,12 +15,11 @@ interface RunReplOptions {
15
15
  workspaceOverride?: string;
16
16
  }
17
17
 
18
- // Alternate screen buffer (ANSI). Same trick used by vim, htop, claude code, lazygit.
19
- // The TUI runs in a clean canvas and the original scrollback is restored on exit,
20
- // which eliminates the "ghost frames" Ink leaves behind when the terminal is resized.
21
- const ALT_SCREEN_ENTER = '\x1b[?1049h\x1b[H';
22
- const ALT_SCREEN_EXIT = '\x1b[?1049l';
23
- const SHOW_CURSOR = '\x1b[?25h';
18
+ // Estilo Claude Code: NO usamos alt screen buffer. Razon: en el alt screen el
19
+ // scrollback nativo de la terminal queda inaccesible y el usuario no puede
20
+ // revisar la conversacion anterior. Aceptamos el tradeoff de que en algunos
21
+ // resizes de la terminal pueda haber artifacts visuales temporales (los frames
22
+ // estaticos ya quedaron en scrollback gracias a <Static>).
24
23
 
25
24
  export async function runRepl(opts: RunReplOptions): Promise<void> {
26
25
  let creds;
@@ -49,32 +48,6 @@ export async function runRepl(opts: RunReplOptions): Promise<void> {
49
48
  };
50
49
 
51
50
  const version = readVersion();
52
- const useAltScreen = !!process.stdout.isTTY;
53
-
54
- // Cleanup idempotente; lo registramos en varias señales para que la terminal
55
- // nunca quede en alt screen ni con el cursor oculto si el proceso muere mal.
56
- let cleanedUp = false;
57
- const restoreTerminal = () => {
58
- if (cleanedUp) return;
59
- cleanedUp = true;
60
- if (useAltScreen) {
61
- process.stdout.write(ALT_SCREEN_EXIT);
62
- process.stdout.write(SHOW_CURSOR);
63
- }
64
- };
65
-
66
- if (useAltScreen) {
67
- process.stdout.write(ALT_SCREEN_ENTER);
68
- }
69
- process.on('exit', restoreTerminal);
70
- process.on('SIGHUP', restoreTerminal);
71
- process.on('SIGTERM', restoreTerminal);
72
- process.on('uncaughtException', (err) => {
73
- restoreTerminal();
74
- // Re-emit so node prints the trace after restoring the terminal.
75
- console.error(err);
76
- process.exit(1);
77
- });
78
51
 
79
52
  const { waitUntilExit } = render(
80
53
  <App
@@ -94,11 +67,7 @@ export async function runRepl(opts: RunReplOptions): Promise<void> {
94
67
  // no-op aquí; App.tsx llama exit() al detectar Ctrl+C.
95
68
  });
96
69
 
97
- try {
98
- await waitUntilExit();
99
- } finally {
100
- restoreTerminal();
101
- }
70
+ await waitUntilExit();
102
71
  }
103
72
 
104
73
  function readVersion(): string {
@@ -1,6 +1,11 @@
1
1
  import { create } from 'zustand';
2
2
  import { randomUUID } from 'node:crypto';
3
- import type { WaConnectState, WaConnectStatus, WhatsappLinkData } from '../whatsapp/types.js';
3
+ import type {
4
+ ChannelLinkData,
5
+ ConnectChannel,
6
+ ConnectState,
7
+ ConnectStatus,
8
+ } from '../connect/types.js';
4
9
 
5
10
  export type Role = 'user' | 'assistant' | 'system';
6
11
 
@@ -58,14 +63,14 @@ export interface StudioState {
58
63
  setAbortController(ctrl: AbortController | null): void;
59
64
  abortStream(): void;
60
65
 
61
- /** WhatsApp connection card state (only one active at a time). */
62
- waConnect: WaConnectState | null;
63
- startWaConnect(linkData: WhatsappLinkData, anchorMessageId: string): string;
64
- updateWaConnect(patch: Partial<WaConnectState>): void;
65
- setWaStatus(status: WaConnectStatus, extra?: Partial<WaConnectState>): void;
66
- clearWaConnect(): void;
66
+ /** Channel connection card state (whatsapp/instagram/messenger; one active at a time). */
67
+ connectCard: ConnectState | null;
68
+ startConnectCard(channel: ConnectChannel, linkData: ChannelLinkData, anchorMessageId: string): string;
69
+ updateConnectCard(patch: Partial<ConnectState>): void;
70
+ setConnectStatus(status: ConnectStatus, extra?: Partial<ConnectState>): void;
71
+ clearConnectCard(): void;
67
72
 
68
- /** Borra mensajes/steps/waConnect pero MANTIENE agente cargado, usage y config. */
73
+ /** Borra mensajes/steps/connectCard pero MANTIENE agente cargado, usage y config. */
69
74
  clearMessages(): void;
70
75
  /** Marca que el próximo stream completo debe reemplazar el historial. */
71
76
  setPendingCompact(v: boolean): void;
@@ -88,7 +93,7 @@ export const useStudioStore = create<StudioState>((set, get) => ({
88
93
  usage: { inputTokens: 0, outputTokens: 0 },
89
94
  streaming: false,
90
95
  abortController: null,
91
- waConnect: null,
96
+ connectCard: null,
92
97
  pendingCompact: false,
93
98
 
94
99
  pushUserMessage(content) {
@@ -178,11 +183,12 @@ export const useStudioStore = create<StudioState>((set, get) => ({
178
183
  if (ctrl && !ctrl.signal.aborted) ctrl.abort();
179
184
  },
180
185
 
181
- startWaConnect(linkData, anchorMessageId) {
186
+ startConnectCard(channel, linkData, anchorMessageId) {
182
187
  const id = uid();
183
188
  set({
184
- waConnect: {
189
+ connectCard: {
185
190
  id,
191
+ channel,
186
192
  linkData,
187
193
  status: 'waiting',
188
194
  anchorMessageId,
@@ -192,16 +198,16 @@ export const useStudioStore = create<StudioState>((set, get) => ({
192
198
  return id;
193
199
  },
194
200
 
195
- updateWaConnect(patch) {
196
- set((s) => (s.waConnect ? { waConnect: { ...s.waConnect, ...patch } } : {}));
201
+ updateConnectCard(patch) {
202
+ set((s) => (s.connectCard ? { connectCard: { ...s.connectCard, ...patch } } : {}));
197
203
  },
198
204
 
199
- setWaStatus(status, extra) {
200
- set((s) => (s.waConnect ? { waConnect: { ...s.waConnect, status, ...(extra ?? {}) } } : {}));
205
+ setConnectStatus(status, extra) {
206
+ set((s) => (s.connectCard ? { connectCard: { ...s.connectCard, status, ...(extra ?? {}) } } : {}));
201
207
  },
202
208
 
203
- clearWaConnect() {
204
- set({ waConnect: null });
209
+ clearConnectCard() {
210
+ set({ connectCard: null });
205
211
  },
206
212
 
207
213
  clearMessages() {
@@ -212,7 +218,7 @@ export const useStudioStore = create<StudioState>((set, get) => ({
212
218
  steps: [],
213
219
  streaming: false,
214
220
  abortController: null,
215
- waConnect: null,
221
+ connectCard: null,
216
222
  pendingCompact: false,
217
223
  // agentConfig, agentId, usage se mantienen para seguir trabajando.
218
224
  });
@@ -238,7 +244,7 @@ export const useStudioStore = create<StudioState>((set, get) => ({
238
244
  },
239
245
  ],
240
246
  steps: [],
241
- waConnect: null,
247
+ connectCard: null,
242
248
  pendingCompact: false,
243
249
  };
244
250
  });
@@ -255,7 +261,7 @@ export const useStudioStore = create<StudioState>((set, get) => ({
255
261
  usage: { inputTokens: 0, outputTokens: 0 },
256
262
  streaming: false,
257
263
  abortController: null,
258
- waConnect: null,
264
+ connectCard: null,
259
265
  pendingCompact: false,
260
266
  });
261
267
  },