plazbot-cli 0.2.26 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +34 -5
- package/README.md +21 -0
- package/dist/cli.js +32 -20
- package/dist/commands/agent/ai-config.js +98 -50
- package/dist/commands/agent/chat.js +80 -74
- package/dist/commands/agent/copy.js +23 -21
- package/dist/commands/agent/create.js +42 -72
- package/dist/commands/agent/delete.js +29 -30
- package/dist/commands/agent/enable-widget.js +30 -26
- package/dist/commands/agent/export.js +90 -77
- package/dist/commands/agent/files.js +68 -60
- package/dist/commands/agent/get.js +101 -87
- package/dist/commands/agent/index.js +53 -39
- package/dist/commands/agent/list.js +26 -24
- package/dist/commands/agent/monitor.js +91 -86
- package/dist/commands/agent/on-message.js +40 -37
- package/dist/commands/agent/set.js +62 -59
- package/dist/commands/agent/templates.js +109 -108
- package/dist/commands/agent/tools.js +64 -65
- package/dist/commands/agent/update.js +28 -27
- package/dist/commands/agent/validate.js +127 -0
- package/dist/commands/agent/wizard.js +152 -159
- package/dist/commands/auth/index.js +7 -10
- package/dist/commands/auth/login.js +50 -37
- package/dist/commands/auth/logout.js +16 -14
- package/dist/commands/auth/status.js +19 -16
- package/dist/commands/portal/add-agent.js +26 -24
- package/dist/commands/portal/add-link.js +21 -17
- package/dist/commands/portal/clear-links.js +17 -15
- package/dist/commands/portal/create.js +25 -21
- package/dist/commands/portal/delete.js +31 -30
- package/dist/commands/portal/get.js +33 -31
- package/dist/commands/portal/index.js +30 -22
- package/dist/commands/portal/list.js +34 -30
- package/dist/commands/portal/update.js +41 -33
- package/dist/commands/whatsapp/broadcast.js +40 -37
- package/dist/commands/whatsapp/channels.js +40 -34
- package/dist/commands/whatsapp/chat.js +33 -32
- package/dist/commands/whatsapp/connect.js +53 -52
- package/dist/commands/whatsapp/delete-webhook.js +19 -17
- package/dist/commands/whatsapp/index.js +35 -25
- package/dist/commands/whatsapp/register-webhook.js +21 -19
- package/dist/commands/whatsapp/send-template.js +39 -31
- package/dist/commands/whatsapp/send.js +27 -23
- package/dist/commands/whatsapp/widget.js +35 -31
- package/dist/commands/workers/deploy.js +49 -44
- package/dist/commands/workers/index.js +28 -18
- package/dist/commands/workers/list.js +43 -35
- package/dist/commands/workers/logs.js +38 -32
- package/dist/commands/workers/remove.js +38 -37
- package/dist/commands/workers/secret.js +63 -58
- package/dist/commands/workers/test.js +44 -36
- package/dist/schemas/agent.config.schema.json +569 -0
- package/dist/studio/api/sseClient.js +97 -0
- package/dist/studio/api/studioApi.js +25 -0
- package/dist/studio/api/types.js +16 -0
- package/dist/studio/components/AgentPanel.js +35 -0
- package/dist/studio/components/App.js +214 -0
- package/dist/studio/components/ChatLog.js +59 -0
- package/dist/studio/components/Footer.js +11 -0
- package/dist/studio/components/Header.js +8 -0
- package/dist/studio/components/Input.js +15 -0
- package/dist/studio/components/Message.js +56 -0
- package/dist/studio/components/Suggestions.js +11 -0
- package/dist/studio/components/ToolCall.js +33 -0
- package/dist/studio/components/WhatsappConnectCard.js +57 -0
- package/dist/studio/index.js +42 -0
- package/dist/studio/render/json.js +16 -0
- package/dist/studio/render/markdown.js +32 -0
- package/dist/studio/render/steps.js +58 -0
- package/dist/studio/runOneShot.js +96 -0
- package/dist/studio/runRepl.js +52 -0
- package/dist/studio/slash/handlers.js +199 -0
- package/dist/studio/slash/parser.js +46 -0
- package/dist/studio/slash/registry.js +16 -0
- package/dist/studio/state/store.js +181 -0
- package/dist/studio/whatsapp/api.js +63 -0
- package/dist/studio/whatsapp/polling.js +77 -0
- package/dist/studio/whatsapp/types.js +31 -0
- package/dist/types/agent.js +1 -2
- package/dist/types/auth.js +1 -2
- package/dist/types/common.js +1 -2
- package/dist/types/message.js +1 -2
- package/dist/types/portal.js +1 -2
- package/dist/types/workers.js +1 -2
- package/dist/utils/agent-errors.js +46 -0
- package/dist/utils/api.js +8 -9
- package/dist/utils/banner.js +33 -34
- package/dist/utils/credentials.js +12 -20
- package/dist/utils/help.js +44 -0
- package/dist/utils/logger.js +13 -19
- package/dist/utils/ui.js +35 -49
- package/package.json +21 -10
- package/src/cli.ts +24 -8
- package/src/commands/agent/ai-config.ts +89 -34
- package/src/commands/agent/chat.ts +49 -37
- package/src/commands/agent/copy.ts +19 -13
- package/src/commands/agent/create.ts +32 -22
- package/src/commands/agent/delete.ts +24 -18
- package/src/commands/agent/enable-widget.ts +31 -23
- package/src/commands/agent/export.ts +72 -51
- package/src/commands/agent/files.ts +51 -39
- package/src/commands/agent/get.ts +86 -66
- package/src/commands/agent/index.ts +36 -18
- package/src/commands/agent/list.ts +22 -16
- package/src/commands/agent/monitor.ts +67 -56
- package/src/commands/agent/on-message.ts +36 -27
- package/src/commands/agent/set.ts +47 -37
- package/src/commands/agent/templates.ts +90 -82
- package/src/commands/agent/tools.ts +53 -47
- package/src/commands/agent/update.ts +28 -20
- package/src/commands/agent/validate.ts +135 -0
- package/src/commands/agent/wizard.ts +114 -114
- package/src/commands/auth/index.ts +3 -3
- package/src/commands/auth/login.ts +44 -29
- package/src/commands/auth/logout.ts +16 -10
- package/src/commands/auth/status.ts +14 -8
- package/src/commands/portal/add-agent.ts +23 -17
- package/src/commands/portal/add-link.ts +17 -9
- package/src/commands/portal/clear-links.ts +13 -7
- package/src/commands/portal/create.ts +20 -12
- package/src/commands/portal/delete.ts +28 -20
- package/src/commands/portal/get.ts +25 -19
- package/src/commands/portal/index.ts +22 -10
- package/src/commands/portal/list.ts +27 -19
- package/src/commands/portal/update.ts +38 -26
- package/src/commands/whatsapp/broadcast.ts +28 -18
- package/src/commands/whatsapp/channels.ts +31 -20
- package/src/commands/whatsapp/chat.ts +20 -12
- package/src/commands/whatsapp/connect.ts +39 -31
- package/src/commands/whatsapp/delete-webhook.ts +15 -9
- package/src/commands/whatsapp/index.ts +24 -10
- package/src/commands/whatsapp/register-webhook.ts +16 -10
- package/src/commands/whatsapp/send-template.ts +33 -21
- package/src/commands/whatsapp/send.ts +23 -15
- package/src/commands/whatsapp/widget.ts +25 -17
- package/src/commands/workers/deploy.ts +34 -22
- package/src/commands/workers/index.ts +21 -7
- package/src/commands/workers/list.ts +31 -19
- package/src/commands/workers/logs.ts +30 -20
- package/src/commands/workers/remove.ts +30 -22
- package/src/commands/workers/secret.ts +46 -34
- package/src/commands/workers/test.ts +34 -22
- package/src/schemas/agent.config.schema.json +569 -0
- package/src/studio/api/sseClient.ts +91 -0
- package/src/studio/api/studioApi.ts +27 -0
- package/src/studio/api/types.ts +96 -0
- package/src/studio/components/App.tsx +266 -0
- package/src/studio/components/ChatLog.tsx +95 -0
- package/src/studio/components/Footer.tsx +38 -0
- package/src/studio/components/Header.tsx +39 -0
- package/src/studio/components/Input.tsx +32 -0
- package/src/studio/components/Message.tsx +87 -0
- package/src/studio/components/Suggestions.tsx +26 -0
- package/src/studio/components/ToolCall.tsx +58 -0
- package/src/studio/components/WhatsappConnectCard.tsx +139 -0
- package/src/studio/index.ts +58 -0
- package/src/studio/render/markdown.ts +32 -0
- package/src/studio/render/steps.ts +57 -0
- package/src/studio/runOneShot.ts +114 -0
- package/src/studio/runRepl.tsx +76 -0
- package/src/studio/slash/handlers.ts +226 -0
- package/src/studio/slash/parser.ts +41 -0
- package/src/studio/slash/registry.ts +54 -0
- package/src/studio/state/store.ts +273 -0
- package/src/studio/whatsapp/api.ts +96 -0
- package/src/studio/whatsapp/polling.ts +93 -0
- package/src/studio/whatsapp/types.ts +80 -0
- package/src/types/agent.ts +1 -1
- package/src/types/auth.ts +4 -3
- package/src/types/portal.ts +1 -1
- package/src/types/workers.ts +1 -1
- package/src/utils/agent-errors.ts +67 -0
- package/src/utils/api.ts +6 -0
- package/src/utils/banner.ts +14 -9
- package/src/utils/credentials.ts +6 -5
- package/src/utils/help.ts +51 -0
- package/tsconfig.json +9 -6
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { StudioStreamOptions } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Construye la URL base según zona / modo dev.
|
|
5
|
+
* LA -> https://api.plazbot.com
|
|
6
|
+
* EU -> https://apieu.plazbot.com
|
|
7
|
+
* dev -> http://localhost:5090
|
|
8
|
+
*/
|
|
9
|
+
export function getBaseUrl(zone: 'LA' | 'EU', dev?: boolean): string {
|
|
10
|
+
if (dev) return 'http://localhost:5090';
|
|
11
|
+
return zone === 'EU' ? 'https://apieu.plazbot.com' : 'https://api.plazbot.com';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildStudioUrl(zone: 'LA' | 'EU', dev?: boolean): string {
|
|
15
|
+
return `${getBaseUrl(zone, dev)}/api/agent/studio`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildStudioHeaders(opts: StudioStreamOptions): Record<string, string> {
|
|
19
|
+
const headers: Record<string, string> = {
|
|
20
|
+
'Content-Type': 'application/json',
|
|
21
|
+
Accept: 'text/event-stream',
|
|
22
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
23
|
+
'x-workspace-id': opts.workspaceId,
|
|
24
|
+
};
|
|
25
|
+
if (opts.userId) headers['x-user-id'] = opts.userId;
|
|
26
|
+
return headers;
|
|
27
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tipos de los chunks que emite el endpoint POST /api/agent/studio (SSE).
|
|
3
|
+
* Espejo del modelo del backend AgentStudioModels.cs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type StudioChunkType = 'text' | 'tool_call' | 'tool_result' | 'usage' | 'done' | 'error';
|
|
7
|
+
|
|
8
|
+
export interface StudioChunkText {
|
|
9
|
+
type: 'text';
|
|
10
|
+
content: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface StudioChunkToolCall {
|
|
14
|
+
type: 'tool_call';
|
|
15
|
+
tool_name: string;
|
|
16
|
+
tool_args?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Resultado genérico devuelto por una tool (espejo de ToolExecutionResult.cs). */
|
|
20
|
+
export interface ToolExecutionResult {
|
|
21
|
+
success: boolean;
|
|
22
|
+
tool?: string;
|
|
23
|
+
type?: string; // "agent_config" | "agent_list" | "diagnostic" | "agent_loaded" | ...
|
|
24
|
+
data?: unknown;
|
|
25
|
+
message?: string;
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StudioChunkToolResult {
|
|
30
|
+
type: 'tool_result';
|
|
31
|
+
tool_name: string;
|
|
32
|
+
tool_result?: ToolExecutionResult;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StudioChunkUsage {
|
|
36
|
+
type: 'usage';
|
|
37
|
+
input_tokens: number;
|
|
38
|
+
output_tokens: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface StudioChunkDone {
|
|
42
|
+
type: 'done';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StudioChunkError {
|
|
46
|
+
type: 'error';
|
|
47
|
+
error: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type StudioChunk =
|
|
51
|
+
| StudioChunkText
|
|
52
|
+
| StudioChunkToolCall
|
|
53
|
+
| StudioChunkToolResult
|
|
54
|
+
| StudioChunkUsage
|
|
55
|
+
| StudioChunkDone
|
|
56
|
+
| StudioChunkError;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Mensaje conversacional enviado al backend en el array `messages`.
|
|
60
|
+
*/
|
|
61
|
+
export interface StudioMessageIn {
|
|
62
|
+
role: 'user' | 'assistant';
|
|
63
|
+
content: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Payload del request POST /api/agent/studio.
|
|
68
|
+
* NB: el backend valida `message` (singular, string) y devuelve 400 si está vacío.
|
|
69
|
+
* `messages` lleva el historial; `message` lleva el último turno del usuario.
|
|
70
|
+
*/
|
|
71
|
+
export interface StudioRequest {
|
|
72
|
+
message: string;
|
|
73
|
+
messages: StudioMessageIn[];
|
|
74
|
+
agentId?: string | null;
|
|
75
|
+
agentConfig?: unknown;
|
|
76
|
+
workspaceId?: string;
|
|
77
|
+
userId?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Opciones para el cliente SSE (autenticación + ruteo).
|
|
82
|
+
*/
|
|
83
|
+
export interface StudioStreamOptions {
|
|
84
|
+
apiKey: string; // JWT
|
|
85
|
+
workspaceId: string;
|
|
86
|
+
userId?: string;
|
|
87
|
+
zone: 'LA' | 'EU';
|
|
88
|
+
dev?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class StudioHttpError extends Error {
|
|
92
|
+
constructor(public status: number, public statusText: string, public body?: string) {
|
|
93
|
+
super(`HTTP ${status} ${statusText}`);
|
|
94
|
+
this.name = 'StudioHttpError';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { Box, useApp, useInput } from 'ink';
|
|
3
|
+
import { Header } from './Header.js';
|
|
4
|
+
import { Footer } from './Footer.js';
|
|
5
|
+
import { ChatLog } from './ChatLog.js';
|
|
6
|
+
import { Input } from './Input.js';
|
|
7
|
+
import { Suggestions } from './Suggestions.js';
|
|
8
|
+
import { useStudioStore, selectBackendMessages } from '../state/store.js';
|
|
9
|
+
import { parseSlash } from '../slash/parser.js';
|
|
10
|
+
import '../slash/handlers.js'; // side effect: registra slashes
|
|
11
|
+
import { slashRegistry } from '../slash/registry.js';
|
|
12
|
+
import { streamStudio } from '../api/sseClient.js';
|
|
13
|
+
import { StudioHttpError } from '../api/types.js';
|
|
14
|
+
import type { StudioChunk, StudioStreamOptions } from '../api/types.js';
|
|
15
|
+
import { startWhatsappPolling, type PollingHandle } from '../whatsapp/polling.js';
|
|
16
|
+
import { workspaceOptsFromStream } from '../whatsapp/api.js';
|
|
17
|
+
import type { WhatsappLinkData } from '../whatsapp/types.js';
|
|
18
|
+
|
|
19
|
+
export interface AppProps {
|
|
20
|
+
version: string;
|
|
21
|
+
stream: StudioStreamOptions;
|
|
22
|
+
initialAgentId?: string | null;
|
|
23
|
+
dev: boolean;
|
|
24
|
+
supportMode?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function App({ version, stream, initialAgentId, dev, supportMode }: AppProps): React.ReactElement {
|
|
28
|
+
const { exit } = useApp();
|
|
29
|
+
const messages = useStudioStore((s) => s.messages);
|
|
30
|
+
const usage = useStudioStore((s) => s.usage);
|
|
31
|
+
const streaming = useStudioStore((s) => s.streaming);
|
|
32
|
+
const agentId = useStudioStore((s) => s.agentId);
|
|
33
|
+
|
|
34
|
+
// Active WhatsApp polling handle (cancelled on Esc / unmount / new stream).
|
|
35
|
+
const waPollingRef = useRef<PollingHandle | null>(null);
|
|
36
|
+
const streamingRef = useRef(streaming);
|
|
37
|
+
useEffect(() => { streamingRef.current = streaming; }, [streaming]);
|
|
38
|
+
// Ref so the polling onConnected callback can trigger sendToBackend from the
|
|
39
|
+
// most recent closure without resubscribing every render.
|
|
40
|
+
const sendBackendRef = useRef<(text: string) => Promise<void>>(async () => {});
|
|
41
|
+
|
|
42
|
+
const stopWaPolling = useCallback(() => {
|
|
43
|
+
if (waPollingRef.current) {
|
|
44
|
+
waPollingRef.current.cancel();
|
|
45
|
+
waPollingRef.current = null;
|
|
46
|
+
}
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
// Cargar agente inicial si se pasó por flag.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (initialAgentId) {
|
|
52
|
+
useStudioStore.getState().setAgentId(initialAgentId);
|
|
53
|
+
}
|
|
54
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
// Cleanup on unmount.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
return () => { stopWaPolling(); };
|
|
60
|
+
}, [stopWaPolling]);
|
|
61
|
+
|
|
62
|
+
// Atajos de teclado globales.
|
|
63
|
+
useInput((input, key) => {
|
|
64
|
+
if (key.escape) {
|
|
65
|
+
const wa = useStudioStore.getState().waConnect;
|
|
66
|
+
if (wa && (wa.status === 'waiting' || wa.status === 'activating')) {
|
|
67
|
+
stopWaPolling();
|
|
68
|
+
useStudioStore.getState().setWaStatus('cancelled');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
useStudioStore.getState().abortStream();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (key.ctrl && input === 'c') {
|
|
75
|
+
stopWaPolling();
|
|
76
|
+
useStudioStore.getState().abortStream();
|
|
77
|
+
exit();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const sendToBackend = useCallback(async (userText: string) => {
|
|
83
|
+
const store = useStudioStore.getState();
|
|
84
|
+
// Snapshot del historial ANTES de agregar el nuevo user message, igual que
|
|
85
|
+
// el front (AgentStudioModal.tsx:504). Si se calcula después, el último
|
|
86
|
+
// user input queda duplicado (en `message` y en `messages`).
|
|
87
|
+
const historyForApi = selectBackendMessages();
|
|
88
|
+
store.pushUserMessage(userText);
|
|
89
|
+
|
|
90
|
+
const assistantId = store.startAssistantMessage();
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
store.setAbortController(controller);
|
|
93
|
+
store.setStreaming(true);
|
|
94
|
+
|
|
95
|
+
const onChunk = (chunk: StudioChunk) => {
|
|
96
|
+
const s = useStudioStore.getState();
|
|
97
|
+
switch (chunk.type) {
|
|
98
|
+
case 'text':
|
|
99
|
+
s.appendAssistantText(assistantId, chunk.content ?? '');
|
|
100
|
+
break;
|
|
101
|
+
case 'tool_call':
|
|
102
|
+
s.startStep(chunk.tool_name, assistantId);
|
|
103
|
+
break;
|
|
104
|
+
case 'tool_result': {
|
|
105
|
+
const tr = chunk.tool_result;
|
|
106
|
+
const success = tr?.success !== false && !tr?.error;
|
|
107
|
+
s.resolveLastStep(chunk.tool_name, success ? 'success' : 'error', tr?.data, tr?.error);
|
|
108
|
+
// Stash the agent config in state (used as `agentConfig` context on
|
|
109
|
+
// the next request) and remember its id. We no longer render it.
|
|
110
|
+
const data = tr?.data as Record<string, unknown> | undefined;
|
|
111
|
+
if (data) {
|
|
112
|
+
const cfg = data.config ?? data.agentConfig ?? data.agent ?? null;
|
|
113
|
+
if (cfg && typeof cfg === 'object') {
|
|
114
|
+
s.setAgentConfig(cfg);
|
|
115
|
+
const idCandidate = (cfg as Record<string, unknown>).id;
|
|
116
|
+
if (typeof idCandidate === 'string') s.setAgentId(idCandidate);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// WhatsApp connect tool: spin up the card + polling.
|
|
120
|
+
if (tr?.type === 'whatsapp_link' && data) {
|
|
121
|
+
const linkData = data as WhatsappLinkData;
|
|
122
|
+
// No link to show (e.g. alreadyConnected without a new url) → skip card.
|
|
123
|
+
if (linkData.shortUrl) {
|
|
124
|
+
// Cancel any previous active polling before starting a new one.
|
|
125
|
+
stopWaPolling();
|
|
126
|
+
s.startWaConnect(linkData, assistantId);
|
|
127
|
+
waPollingRef.current = startWhatsappPolling(
|
|
128
|
+
workspaceOptsFromStream(stream),
|
|
129
|
+
linkData,
|
|
130
|
+
{
|
|
131
|
+
onAttempt(attempt) {
|
|
132
|
+
useStudioStore.getState().updateWaConnect({ attempt });
|
|
133
|
+
},
|
|
134
|
+
onActivating() {
|
|
135
|
+
useStudioStore.getState().setWaStatus('activating');
|
|
136
|
+
},
|
|
137
|
+
onConnected(number) {
|
|
138
|
+
useStudioStore.getState().setWaStatus('connected', { connectedNumber: number });
|
|
139
|
+
waPollingRef.current = null;
|
|
140
|
+
if (!streamingRef.current) {
|
|
141
|
+
const followUp =
|
|
142
|
+
`My WhatsApp is now connected. Number: ${number || '(no number)'}. ` +
|
|
143
|
+
`Continue with the agent setup.`;
|
|
144
|
+
void sendBackendRef.current(followUp);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
onTimeout() {
|
|
148
|
+
useStudioStore.getState().setWaStatus('timeout');
|
|
149
|
+
waPollingRef.current = null;
|
|
150
|
+
},
|
|
151
|
+
onError(message) {
|
|
152
|
+
useStudioStore.getState().setWaStatus('error', { errorMessage: message });
|
|
153
|
+
waPollingRef.current = null;
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case 'usage':
|
|
162
|
+
s.addUsage(chunk.input_tokens ?? 0, chunk.output_tokens ?? 0);
|
|
163
|
+
break;
|
|
164
|
+
case 'error':
|
|
165
|
+
s.failAllRunningSteps(chunk.error);
|
|
166
|
+
s.pushSyntheticAssistant(`✖ ${chunk.error}`, { error: true });
|
|
167
|
+
break;
|
|
168
|
+
case 'done':
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await streamStudio(
|
|
175
|
+
stream,
|
|
176
|
+
{
|
|
177
|
+
message: userText,
|
|
178
|
+
messages: historyForApi,
|
|
179
|
+
agentId: useStudioStore.getState().agentId,
|
|
180
|
+
agentConfig: useStudioStore.getState().agentConfig,
|
|
181
|
+
},
|
|
182
|
+
onChunk,
|
|
183
|
+
controller.signal,
|
|
184
|
+
);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
const msg = formatError(err, dev);
|
|
187
|
+
useStudioStore.getState().failAllRunningSteps('Network error');
|
|
188
|
+
useStudioStore.getState().pushSyntheticAssistant(msg, { error: true });
|
|
189
|
+
} finally {
|
|
190
|
+
useStudioStore.getState().finishAssistantMessage(assistantId);
|
|
191
|
+
useStudioStore.getState().setStreaming(false);
|
|
192
|
+
useStudioStore.getState().setAbortController(null);
|
|
193
|
+
// /compact flow: replace history with the assistant summary just produced.
|
|
194
|
+
if (useStudioStore.getState().pendingCompact) {
|
|
195
|
+
useStudioStore.getState().compactInto(assistantId);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}, [stream, dev, stopWaPolling]);
|
|
199
|
+
|
|
200
|
+
useEffect(() => { sendBackendRef.current = sendToBackend; }, [sendToBackend]);
|
|
201
|
+
|
|
202
|
+
const handleSubmit = useCallback(async (raw: string) => {
|
|
203
|
+
const parsed = parseSlash(raw);
|
|
204
|
+
if (parsed) {
|
|
205
|
+
const spec = slashRegistry.get(parsed.name);
|
|
206
|
+
if (!spec) {
|
|
207
|
+
useStudioStore.getState().pushSyntheticAssistant(
|
|
208
|
+
`Unknown command: \`/${parsed.name}\`. Run \`/help\` to see the list.`,
|
|
209
|
+
{ error: true },
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const res = await spec.handler(parsed, { exit });
|
|
214
|
+
if (res.kind === 'message') {
|
|
215
|
+
await sendToBackend(res.content);
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
await sendToBackend(raw);
|
|
220
|
+
}, [sendToBackend, exit]);
|
|
221
|
+
|
|
222
|
+
const empty = messages.length === 0;
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<Box flexDirection="column">
|
|
226
|
+
<Header
|
|
227
|
+
version={version}
|
|
228
|
+
workspace={stream.workspaceId}
|
|
229
|
+
zone={stream.zone}
|
|
230
|
+
agentId={agentId}
|
|
231
|
+
dev={dev}
|
|
232
|
+
supportMode={!!supportMode}
|
|
233
|
+
/>
|
|
234
|
+
|
|
235
|
+
<Box flexDirection="column" paddingX={1}>
|
|
236
|
+
{empty ? <Suggestions /> : <ChatLog />}
|
|
237
|
+
</Box>
|
|
238
|
+
|
|
239
|
+
<Input disabled={streaming} onSubmit={handleSubmit} />
|
|
240
|
+
<Footer
|
|
241
|
+
inputTokens={usage.inputTokens}
|
|
242
|
+
outputTokens={usage.outputTokens}
|
|
243
|
+
streaming={streaming}
|
|
244
|
+
/>
|
|
245
|
+
</Box>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function formatError(err: unknown, dev: boolean): string {
|
|
250
|
+
if (err instanceof StudioHttpError) {
|
|
251
|
+
if (err.status === 401) return '✖ Token expired or invalid. Run `plazbot init` with a valid JWT.';
|
|
252
|
+
if (err.status === 403) return '✖ No permission for this workspace.';
|
|
253
|
+
if (err.status === 429) return '✖ Rate limit reached. Wait a few seconds and try again.';
|
|
254
|
+
if (err.status >= 500) {
|
|
255
|
+
return dev
|
|
256
|
+
? `✖ Backend error ${err.status}.\n\`\`\`\n${err.body ?? err.statusText}\n\`\`\``
|
|
257
|
+
: `✖ The backend returned error ${err.status}. Retry in a few seconds.`;
|
|
258
|
+
}
|
|
259
|
+
return `✖ HTTP ${err.status} ${err.statusText}`;
|
|
260
|
+
}
|
|
261
|
+
if (err instanceof Error) {
|
|
262
|
+
if (err.name === 'AbortError') return '✖ Stream cancelled.';
|
|
263
|
+
return `✖ ${err.message}`;
|
|
264
|
+
}
|
|
265
|
+
return '✖ Unknown error';
|
|
266
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Static } from 'ink';
|
|
3
|
+
import { useStudioStore } from '../state/store.js';
|
|
4
|
+
import type { ChatMessage, ToolStep } from '../state/store.js';
|
|
5
|
+
import { Message } from './Message.js';
|
|
6
|
+
import { ToolCall } from './ToolCall.js';
|
|
7
|
+
import { WhatsappConnectCard } from './WhatsappConnectCard.js';
|
|
8
|
+
|
|
9
|
+
type HistoricItem =
|
|
10
|
+
| { kind: 'message'; key: string; message: ChatMessage }
|
|
11
|
+
| { kind: 'step'; key: string; step: ToolStep };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Renders the conversation log.
|
|
15
|
+
*
|
|
16
|
+
* Stable items (finished assistant/user messages + resolved tool steps that
|
|
17
|
+
* are not anchored to an active WhatsApp card) are rendered inside <Static>
|
|
18
|
+
* so Ink writes them to scrollback once and never re-renders them on resize.
|
|
19
|
+
* This is what fixes the "Header echoed multiple times" effect you see when
|
|
20
|
+
* dragging the terminal window while the dynamic frame is taller than the
|
|
21
|
+
* viewport.
|
|
22
|
+
*
|
|
23
|
+
* The dynamic block at the bottom contains the streaming message, any steps
|
|
24
|
+
* still in `running`, and the WhatsApp connect card while it is active.
|
|
25
|
+
*/
|
|
26
|
+
export function ChatLog(): React.ReactElement {
|
|
27
|
+
const messages = useStudioStore((s) => s.messages);
|
|
28
|
+
const steps = useStudioStore((s) => s.steps);
|
|
29
|
+
const waConnect = useStudioStore((s) => s.waConnect);
|
|
30
|
+
|
|
31
|
+
// Group steps by their anchor message for quick lookup.
|
|
32
|
+
const stepsByMsg = new Map<string, ToolStep[]>();
|
|
33
|
+
for (const step of steps) {
|
|
34
|
+
const arr = stepsByMsg.get(step.afterMessageId) ?? [];
|
|
35
|
+
arr.push(step);
|
|
36
|
+
stepsByMsg.set(step.afterMessageId, arr);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Determine which message ids must stay in the dynamic frame:
|
|
40
|
+
// - the last message if it's still streaming
|
|
41
|
+
// - any message whose tool steps are still running
|
|
42
|
+
// - the message anchored to an active WhatsApp connect card
|
|
43
|
+
const dynamicMsgIds = new Set<string>();
|
|
44
|
+
const last = messages[messages.length - 1];
|
|
45
|
+
if (last?.streaming) dynamicMsgIds.add(last.id);
|
|
46
|
+
for (const step of steps) {
|
|
47
|
+
if (step.status === 'running') dynamicMsgIds.add(step.afterMessageId);
|
|
48
|
+
}
|
|
49
|
+
if (waConnect) dynamicMsgIds.add(waConnect.anchorMessageId);
|
|
50
|
+
|
|
51
|
+
// Build the static (scrollback) list. Each message and step gets a unique
|
|
52
|
+
// key so React/Ink only writes it once.
|
|
53
|
+
const staticItems: HistoricItem[] = [];
|
|
54
|
+
for (const m of messages) {
|
|
55
|
+
if (dynamicMsgIds.has(m.id)) continue;
|
|
56
|
+
staticItems.push({ kind: 'message', key: `m-${m.id}`, message: m });
|
|
57
|
+
for (const s of stepsByMsg.get(m.id) ?? []) {
|
|
58
|
+
staticItems.push({ kind: 'step', key: `s-${s.id}`, step: s });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const dynamicMessages = messages.filter((m) => dynamicMsgIds.has(m.id));
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Box flexDirection="column">
|
|
66
|
+
<Static items={staticItems}>
|
|
67
|
+
{(item) =>
|
|
68
|
+
item.kind === 'message' ? (
|
|
69
|
+
<Message key={item.key} message={item.message} />
|
|
70
|
+
) : (
|
|
71
|
+
<ToolCall key={item.key} step={item.step} />
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
</Static>
|
|
75
|
+
|
|
76
|
+
<Box flexDirection="column">
|
|
77
|
+
{dynamicMessages.map((m) => (
|
|
78
|
+
<React.Fragment key={m.id}>
|
|
79
|
+
<Message message={m} />
|
|
80
|
+
{(stepsByMsg.get(m.id) ?? []).map((s) => (
|
|
81
|
+
<ToolCall key={s.id} step={s} />
|
|
82
|
+
))}
|
|
83
|
+
{waConnect && waConnect.anchorMessageId === m.id ? (
|
|
84
|
+
<WhatsappConnectCard state={waConnect} />
|
|
85
|
+
) : null}
|
|
86
|
+
</React.Fragment>
|
|
87
|
+
))}
|
|
88
|
+
{/* Fallback: waConnect anchor message was cleared. */}
|
|
89
|
+
{waConnect && !messages.some((m) => m.id === waConnect.anchorMessageId) ? (
|
|
90
|
+
<WhatsappConnectCard state={waConnect} />
|
|
91
|
+
) : null}
|
|
92
|
+
</Box>
|
|
93
|
+
</Box>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
|
|
5
|
+
interface FooterProps {
|
|
6
|
+
inputTokens: number;
|
|
7
|
+
outputTokens: number;
|
|
8
|
+
streaming: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fmt(n: number): string {
|
|
12
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
13
|
+
return String(n);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Footer({ inputTokens, outputTokens, streaming }: FooterProps): React.ReactElement {
|
|
17
|
+
return (
|
|
18
|
+
<Box paddingX={1} flexDirection="row" justifyContent="space-between">
|
|
19
|
+
<Box>
|
|
20
|
+
<Text dimColor>tokens: </Text>
|
|
21
|
+
<Text>{fmt(inputTokens)}</Text>
|
|
22
|
+
<Text dimColor> in / </Text>
|
|
23
|
+
<Text>{fmt(outputTokens)}</Text>
|
|
24
|
+
<Text dimColor> out</Text>
|
|
25
|
+
{streaming ? (
|
|
26
|
+
<>
|
|
27
|
+
<Text> </Text>
|
|
28
|
+
<Text color="yellow"><Spinner type="dots" /></Text>
|
|
29
|
+
<Text color="yellow"> streaming…</Text>
|
|
30
|
+
</>
|
|
31
|
+
) : null}
|
|
32
|
+
</Box>
|
|
33
|
+
<Box>
|
|
34
|
+
<Text dimColor>/help · Esc cancel · Ctrl+C quit</Text>
|
|
35
|
+
</Box>
|
|
36
|
+
</Box>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
interface HeaderProps {
|
|
5
|
+
version: string;
|
|
6
|
+
workspace: string;
|
|
7
|
+
zone: 'LA' | 'EU';
|
|
8
|
+
agentId: string | null;
|
|
9
|
+
dev: boolean;
|
|
10
|
+
supportMode?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Header({ version, workspace, zone, agentId, dev, supportMode }: HeaderProps): React.ReactElement {
|
|
14
|
+
// Sin borde: cualquier `borderStyle` se acumula visualmente cuando el usuario
|
|
15
|
+
// redimensiona la terminal (Ink no limpia el scrollback). Estilo Claude Code:
|
|
16
|
+
// una sola línea con título a la izquierda y metadata a la derecha.
|
|
17
|
+
return (
|
|
18
|
+
<Box flexDirection="row" justifyContent="space-between" paddingX={1}>
|
|
19
|
+
<Box>
|
|
20
|
+
<Text bold color={supportMode ? 'yellow' : 'green'}>● Plazbot Studio</Text>
|
|
21
|
+
<Text dimColor> v{version}</Text>
|
|
22
|
+
{dev ? <Text color="yellow"> dev</Text> : null}
|
|
23
|
+
{supportMode ? <Text color="yellow" bold> · SUPPORT</Text> : null}
|
|
24
|
+
</Box>
|
|
25
|
+
<Box>
|
|
26
|
+
<Text dimColor>workspace </Text>
|
|
27
|
+
<Text color={supportMode ? 'yellow' : undefined}>{workspace}</Text>
|
|
28
|
+
<Text dimColor> zone </Text>
|
|
29
|
+
<Text>{zone}</Text>
|
|
30
|
+
{agentId ? (
|
|
31
|
+
<>
|
|
32
|
+
<Text dimColor> agent </Text>
|
|
33
|
+
<Text color="cyan">{agentId.slice(0, 12)}</Text>
|
|
34
|
+
</>
|
|
35
|
+
) : null}
|
|
36
|
+
</Box>
|
|
37
|
+
</Box>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
|
|
5
|
+
interface InputProps {
|
|
6
|
+
disabled: boolean;
|
|
7
|
+
onSubmit: (value: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Input({ disabled, onSubmit }: InputProps): React.ReactElement {
|
|
11
|
+
const [value, setValue] = useState('');
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Box borderStyle="round" borderColor={disabled ? 'gray' : 'green'} paddingX={1}>
|
|
15
|
+
<Text color={disabled ? 'gray' : 'green'} bold>
|
|
16
|
+
{disabled ? '·' : '›'}{' '}
|
|
17
|
+
</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
|
+
/>
|
|
30
|
+
</Box>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
import type { ChatMessage } from '../state/store.js';
|
|
5
|
+
import { renderMarkdown } from '../render/markdown.js';
|
|
6
|
+
|
|
7
|
+
interface MessageProps {
|
|
8
|
+
message: ChatMessage;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Phrases shown next to the spinner while waiting for the first text token.
|
|
13
|
+
* Rotated every ~1.8s for a Claude Code–style feel.
|
|
14
|
+
*/
|
|
15
|
+
const THINKING_PHRASES = [
|
|
16
|
+
'Thinking',
|
|
17
|
+
'Pondering',
|
|
18
|
+
'Cooking',
|
|
19
|
+
'Reasoning',
|
|
20
|
+
'Crafting',
|
|
21
|
+
'Working',
|
|
22
|
+
'Brewing',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function useRotatingPhrase(active: boolean): string {
|
|
26
|
+
const [idx, setIdx] = useState(0);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!active) return;
|
|
29
|
+
const t = setInterval(() => setIdx((i) => (i + 1) % THINKING_PHRASES.length), 1800);
|
|
30
|
+
return () => clearInterval(t);
|
|
31
|
+
}, [active]);
|
|
32
|
+
return THINKING_PHRASES[idx];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function useBlinkingCursor(active: boolean): boolean {
|
|
36
|
+
const [on, setOn] = useState(true);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!active) return;
|
|
39
|
+
const t = setInterval(() => setOn((v) => !v), 450);
|
|
40
|
+
return () => clearInterval(t);
|
|
41
|
+
}, [active]);
|
|
42
|
+
return on;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function Message({ message }: MessageProps): React.ReactElement {
|
|
46
|
+
const isUser = message.role === 'user';
|
|
47
|
+
const dotColor = isUser ? 'cyan' : message.error ? 'red' : 'green';
|
|
48
|
+
const labelColor = message.error ? 'red' : undefined;
|
|
49
|
+
const label = isUser ? 'You' : 'Plazbot';
|
|
50
|
+
const streamingNoContent = !!message.streaming && !message.content && !isUser;
|
|
51
|
+
const streamingWithContent = !!message.streaming && !!message.content && !isUser;
|
|
52
|
+
|
|
53
|
+
const phrase = useRotatingPhrase(streamingNoContent);
|
|
54
|
+
const cursorOn = useBlinkingCursor(streamingWithContent);
|
|
55
|
+
|
|
56
|
+
const body = useMemo(() => {
|
|
57
|
+
if (!message.content) return '';
|
|
58
|
+
if (isUser) return message.content;
|
|
59
|
+
return renderMarkdown(message.content);
|
|
60
|
+
}, [message.content, isUser]);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
64
|
+
<Box>
|
|
65
|
+
<Text color={dotColor} bold>● </Text>
|
|
66
|
+
<Text bold color={labelColor}>{label}</Text>
|
|
67
|
+
{streamingNoContent ? (
|
|
68
|
+
<>
|
|
69
|
+
<Text dimColor> </Text>
|
|
70
|
+
<Text color="yellow"><Spinner type="dots" /></Text>
|
|
71
|
+
<Text dimColor> {phrase}…</Text>
|
|
72
|
+
</>
|
|
73
|
+
) : null}
|
|
74
|
+
</Box>
|
|
75
|
+
{body || streamingWithContent ? (
|
|
76
|
+
<Box marginLeft={2}>
|
|
77
|
+
<Text>
|
|
78
|
+
{body}
|
|
79
|
+
{streamingWithContent ? (
|
|
80
|
+
<Text color="green" dimColor>{cursorOn ? '▍' : ' '}</Text>
|
|
81
|
+
) : null}
|
|
82
|
+
</Text>
|
|
83
|
+
</Box>
|
|
84
|
+
) : null}
|
|
85
|
+
</Box>
|
|
86
|
+
);
|
|
87
|
+
}
|