@4djs/assistant 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +322 -0
- package/package.json +41 -0
- package/src/core/chat-activity.ts +107 -0
- package/src/core/chat-commands.ts +173 -0
- package/src/core/chat-history.ts +113 -0
- package/src/core/chat-reply-suggestions-parse.ts +119 -0
- package/src/core/code-highlight.ts +20 -0
- package/src/core/create-assistant-store.ts +639 -0
- package/src/core/fetch-suggested-prompts.ts +53 -0
- package/src/core/index.ts +125 -0
- package/src/core/interactive-tools/choices.ts +155 -0
- package/src/core/interactive-tools/confirmation.ts +63 -0
- package/src/core/interactive-tools/constants.ts +22 -0
- package/src/core/interactive-tools/execute.ts +70 -0
- package/src/core/interactive-tools/index.ts +41 -0
- package/src/core/interactive-tools/suggestions.ts +87 -0
- package/src/core/interactive-tools/waiters.ts +55 -0
- package/src/core/llm-chat.ts +686 -0
- package/src/core/llm-config.ts +101 -0
- package/src/core/llm-models.ts +96 -0
- package/src/core/llm-provider.ts +99 -0
- package/src/core/llm-settings-storage.ts +331 -0
- package/src/core/llm-sse.ts +166 -0
- package/src/core/llm-types.ts +52 -0
- package/src/core/markdown-utils.ts +11 -0
- package/src/core/prepare-markdown.ts +38 -0
- package/src/core/types.ts +86 -0
- package/src/css.d.ts +1 -0
- package/src/react/Assistant.tsx +358 -0
- package/src/react/components/HighlightedJsonCode.tsx +24 -0
- package/src/react/components/MarkdownContent.tsx +98 -0
- package/src/react/components/MarkdownEditor.tsx +60 -0
- package/src/react/components/MermaidDiagram.tsx +139 -0
- package/src/react/components/ModelSelector.tsx +243 -0
- package/src/react/components/chat/AssistantErrorCallout.tsx +79 -0
- package/src/react/components/chat/ChatActivity.tsx +274 -0
- package/src/react/components/chat/ChatComposer.tsx +189 -0
- package/src/react/components/chat/ChatEmptyState.tsx +145 -0
- package/src/react/components/chat/ChatInteractivePrompt/choices-prompt.tsx +262 -0
- package/src/react/components/chat/ChatInteractivePrompt/confirmation-prompt.tsx +97 -0
- package/src/react/components/chat/ChatInteractivePrompt/index.tsx +60 -0
- package/src/react/components/chat/ChatInteractivePrompt/shell.tsx +60 -0
- package/src/react/components/chat/ChatInteractivePrompt/utils.ts +14 -0
- package/src/react/components/chat/ChatMessage.tsx +150 -0
- package/src/react/components/chat/ChatMessageScroll.tsx +116 -0
- package/src/react/components/chat/ChatReplySuggestions.tsx +231 -0
- package/src/react/components/chat/ComposerCommandMenu.tsx +69 -0
- package/src/react/components/chat/LlmSettingsStrip.tsx +348 -0
- package/src/react/components/chat/LlmSetupPrompt.tsx +58 -0
- package/src/react/components/chat/LlmUnavailableBanner.tsx +11 -0
- package/src/react/components/chat/SuggestedPromptsList.tsx +121 -0
- package/src/react/components/chat/SuggestedPromptsStrip.tsx +72 -0
- package/src/react/components/chat/SystemPromptField.tsx +107 -0
- package/src/react/components/highlighted-code.tsx +107 -0
- package/src/react/context.tsx +72 -0
- package/src/react/hooks/use-composer-commands.ts +129 -0
- package/src/react/hooks/use-suggested-prompts.ts +128 -0
- package/src/react/index.ts +39 -0
- package/src/react/lib/parse-assistant-error.ts +96 -0
- package/src/react/lib/prompt-icons.ts +40 -0
- package/src/react/types.ts +83 -0
- package/src/react/utils/cn.ts +5 -0
- package/src/styles/assistant.css +3009 -0
- package/test/buildLlmHistory.test.ts +95 -0
- package/test/llm-config.test.ts +72 -0
- package/test/llmSettingsStorage.test.ts +121 -0
- package/test/parse-assistant-error.test.ts +24 -0
- package/tsconfig.json +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# @4d/assistant
|
|
2
|
+
|
|
3
|
+
Embeddable React chat assistant with LLM tool-calling, streaming, markdown rendering, and optional fallback mode when no LLM is configured.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
This package lives in the monorepo as a workspace dependency:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@4d/assistant": "workspace:*"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Peer dependencies: `react` and `react-dom` (^19).
|
|
18
|
+
|
|
19
|
+
Import styles once in your app entry (required for the UI):
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import "@4d/assistant/styles.css";
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The stylesheet uses CSS variables from your host app (`--brand`, `--surface-panel`, `--text-heading`, etc.). Define those tokens in your global CSS so the assistant matches your design system.
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
The simplest integration is `AssistantRoot`, which wires the provider, store bootstrap, and default panel UI:
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { AssistantRoot, type AssistantConfig } from "@4d/assistant";
|
|
33
|
+
|
|
34
|
+
const config: AssistantConfig = {
|
|
35
|
+
llm: {
|
|
36
|
+
enabled: Boolean(import.meta.env.LLM_KEY),
|
|
37
|
+
baseUrl: import.meta.env.LLM_BASE_URL ?? "https://api.openai.com/v1",
|
|
38
|
+
apiKey: import.meta.env.LLM_KEY ?? null,
|
|
39
|
+
model: import.meta.env.LLM_MODEL ?? "gpt-4o-mini",
|
|
40
|
+
systemPrompt: "You are a helpful assistant…",
|
|
41
|
+
},
|
|
42
|
+
storageKeys: {
|
|
43
|
+
history: "my-app-chat-history",
|
|
44
|
+
llmSettings: "my-app-llm-settings",
|
|
45
|
+
},
|
|
46
|
+
welcomeMessage: ({ llmEnabled, model }) => ({
|
|
47
|
+
id: "welcome",
|
|
48
|
+
role: "assistant",
|
|
49
|
+
content: llmEnabled
|
|
50
|
+
? `Hello! Using **${model ?? "LLM"}**.`
|
|
51
|
+
: "Hello! LLM is disabled — try `/clear` or use fallback commands.",
|
|
52
|
+
timestamp: Date.now(),
|
|
53
|
+
}),
|
|
54
|
+
listTools: async () => myTools,
|
|
55
|
+
invokeTool: async (name, args) => myToolRunner(name, args),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function MyAssistant() {
|
|
59
|
+
return <AssistantRoot config={config} />;
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
See [`apps/web/src/assistant/config.ts`](../../apps/web/src/assistant/config.ts) for a full production example wired to the 4D tool registry.
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
`AssistantConfig` extends the store dependencies with UI options.
|
|
68
|
+
|
|
69
|
+
### Required store hooks
|
|
70
|
+
|
|
71
|
+
| Option | Description |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `welcomeMessage(ctx)` | Returns the initial assistant message. Called again when LLM status or model changes. |
|
|
74
|
+
| `listTools()` | Returns OpenAI-style tool definitions (`name`, `description`, `inputSchema`). |
|
|
75
|
+
| `invokeTool(name, args)` | Executes a tool and returns `{ content, isError?, structuredContent? }`. |
|
|
76
|
+
|
|
77
|
+
### Optional store hooks
|
|
78
|
+
|
|
79
|
+
| Option | Description |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `llm` | OpenAI-compatible provider settings (see below). |
|
|
82
|
+
| `storageKeys` | `localStorage` keys for chat history and LLM settings (including selected model). |
|
|
83
|
+
| `fallbackHandler` | Called when LLM is disabled. Return an `AssistantMessage` or `null`. |
|
|
84
|
+
| `onToolInvoked` | Side-effect hook after each tool completes (e.g. refresh app state). |
|
|
85
|
+
| `fetchSuggestedPrompts` | Async hook to generate contextual starter prompts via LLM. |
|
|
86
|
+
| `autoLoadLlmStatus` | Fetch LLM config on mount (default: `true`). |
|
|
87
|
+
|
|
88
|
+
### UI options
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
{
|
|
92
|
+
header: {
|
|
93
|
+
title: "Assistant",
|
|
94
|
+
subtitle: "Query and explore your datastore",
|
|
95
|
+
icon: Sparkles, // lucide-react icon
|
|
96
|
+
showClearButton: true, // footer toolbar (default: true)
|
|
97
|
+
showSuggestionsButton: true, // footer toolbar when fetchSuggestedPrompts is set
|
|
98
|
+
},
|
|
99
|
+
emptyState: {
|
|
100
|
+
title: "Get started",
|
|
101
|
+
description: "Ask a question or pick a suggestion below.",
|
|
102
|
+
dynamicSuggestedPrompts: true, // manual LLM fetch on welcome screen
|
|
103
|
+
suggestedPrompts: [ // static fallback when LLM is off
|
|
104
|
+
{ id: "catalog", label: "Show catalog", prompt: "catalog", icon: Database },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
ui: {
|
|
108
|
+
composerPlaceholder: "Ask the assistant…",
|
|
109
|
+
showModelSelector: true,
|
|
110
|
+
maxWidth: "56rem",
|
|
111
|
+
className: "panel", // extra class on the root panel
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Per-instance overrides are supported via `Assistant` / `AssistantRoot` props: `header`, `emptyState`, `ui`.
|
|
117
|
+
|
|
118
|
+
## LLM provider
|
|
119
|
+
|
|
120
|
+
The assistant calls an **OpenAI-compatible** chat completions API directly from the browser (or your host runtime). No proxy `/api/chat` routes are required.
|
|
121
|
+
|
|
122
|
+
Pass `llm` on `AssistantConfig`:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import type { AssistantLlmSettings } from "@4d/assistant/core";
|
|
126
|
+
|
|
127
|
+
const llm: AssistantLlmSettings = {
|
|
128
|
+
enabled: true,
|
|
129
|
+
baseUrl: "https://api.openai.com/v1",
|
|
130
|
+
apiKey: import.meta.env.LLM_KEY ?? null,
|
|
131
|
+
model: "gpt-4o-mini",
|
|
132
|
+
systemPrompt: "You are a helpful assistant with access to tools.",
|
|
133
|
+
models: ["gpt-4o-mini", "gpt-4o"], // optional static list
|
|
134
|
+
};
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| Field | Description |
|
|
138
|
+
| --- | --- |
|
|
139
|
+
| `enabled` | When `false`, or when `apiKey` is missing, fallback mode is used. |
|
|
140
|
+
| `baseUrl` | Provider base URL (e.g. `https://api.openai.com/v1`). |
|
|
141
|
+
| `apiKey` | Bearer token for the provider. |
|
|
142
|
+
| `model` | Default model when the user has not picked one. |
|
|
143
|
+
| `models` | Optional static model list. When omitted, models are fetched from `{baseUrl}/models`. |
|
|
144
|
+
| `systemPrompt` | Prepended system message for each completion. |
|
|
145
|
+
|
|
146
|
+
You can also pass a resolver function for dynamic config:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
llm: async () => ({
|
|
150
|
+
enabled: true,
|
|
151
|
+
baseUrl: "https://api.openai.com/v1",
|
|
152
|
+
apiKey: await getApiKeyFromSecureStorage(),
|
|
153
|
+
model: "gpt-4o-mini",
|
|
154
|
+
}),
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Configure globally outside React:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { configureAssistantLlm } from "@4d/assistant/core";
|
|
161
|
+
|
|
162
|
+
configureAssistantLlm({ enabled: true, baseUrl: "…", apiKey: "…", model: "…" });
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Environment variables (4D web app)
|
|
166
|
+
|
|
167
|
+
The playground reads these from `.env.local` (exposed to the client via Vite `envPrefix`):
|
|
168
|
+
|
|
169
|
+
| Variable | Description |
|
|
170
|
+
| --- | --- |
|
|
171
|
+
| `LLM_KEY` | API key (required to enable LLM) |
|
|
172
|
+
| `LLM_BASE_URL` | Provider URL (default: OpenAI) |
|
|
173
|
+
| `LLM_MODEL` | Default model |
|
|
174
|
+
| `LLM_MODELS` | Comma-separated extra models for the selector |
|
|
175
|
+
|
|
176
|
+
**Note:** Because calls are made directly to the provider, the API key is available to the client bundle. Use this pattern for local/dev playgrounds or when the key is scoped and acceptable in the browser.
|
|
177
|
+
|
|
178
|
+
## Wiring tools
|
|
179
|
+
|
|
180
|
+
Tools must match the shape expected by the LLM agent:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import type { AssistantToolDefinition, AssistantToolResult } from "@4d/assistant";
|
|
184
|
+
|
|
185
|
+
const tools: AssistantToolDefinition[] = [
|
|
186
|
+
{
|
|
187
|
+
name: "query_entity",
|
|
188
|
+
description: "Query records from a dataclass",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
type: "object",
|
|
191
|
+
properties: {
|
|
192
|
+
entity: { type: "string" },
|
|
193
|
+
filter: { type: "string" },
|
|
194
|
+
},
|
|
195
|
+
required: ["entity"],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
async function invokeTool(
|
|
201
|
+
name: string,
|
|
202
|
+
args: Record<string, unknown>,
|
|
203
|
+
): Promise<AssistantToolResult> {
|
|
204
|
+
// run tool, return text content for the model
|
|
205
|
+
return {
|
|
206
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The store passes `listTools` output to the LLM on each turn and calls `invokeTool` for each tool call in the response.
|
|
212
|
+
|
|
213
|
+
## Suggested prompts
|
|
214
|
+
|
|
215
|
+
Two modes:
|
|
216
|
+
|
|
217
|
+
1. **Static** — set `emptyState.suggestedPrompts` with `label`, `hint`, `prompt`, and optional Lucide `icon`.
|
|
218
|
+
2. **Dynamic** — implement `fetchSuggestedPrompts`. The UI does **not** auto-fetch; users click **Generate suggestions** on the welcome screen or the sparkles button in the composer footer.
|
|
219
|
+
|
|
220
|
+
Dynamic fetch receives `{ llmEnabled, model, tools }` and should return:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
Array<{
|
|
224
|
+
id: string;
|
|
225
|
+
label: string;
|
|
226
|
+
description?: string;
|
|
227
|
+
prompt: string;
|
|
228
|
+
icon?: string; // e.g. "database", "search", "sparkles"
|
|
229
|
+
}>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Use `parseSuggestedPromptsResponse` from `@4d/assistant/core` to validate LLM JSON output.
|
|
233
|
+
|
|
234
|
+
## Fallback mode (no LLM)
|
|
235
|
+
|
|
236
|
+
When `configUrl` reports `enabled: false`, the assistant skips the LLM and calls `fallbackHandler` instead:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
fallbackHandler: async ({ message, tools }) => {
|
|
240
|
+
if (message === "catalog") {
|
|
241
|
+
return {
|
|
242
|
+
id: crypto.randomUUID(),
|
|
243
|
+
role: "assistant",
|
|
244
|
+
content: "Here is your catalog…",
|
|
245
|
+
timestamp: Date.now(),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
},
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Return `null` to show a generic “LLM disabled” message.
|
|
253
|
+
|
|
254
|
+
## Custom composition
|
|
255
|
+
|
|
256
|
+
Use lower-level exports to build your own layout:
|
|
257
|
+
|
|
258
|
+
```tsx
|
|
259
|
+
import {
|
|
260
|
+
AssistantProvider,
|
|
261
|
+
AssistantBootstrap,
|
|
262
|
+
Assistant,
|
|
263
|
+
useAssistant,
|
|
264
|
+
useAssistantActions,
|
|
265
|
+
} from "@4d/assistant";
|
|
266
|
+
|
|
267
|
+
function CustomShell() {
|
|
268
|
+
const messages = useAssistant((s) => s.messages);
|
|
269
|
+
const { sendChat } = useAssistantActions();
|
|
270
|
+
// render your own chrome around <Assistant /> or individual chat components
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function App() {
|
|
274
|
+
return (
|
|
275
|
+
<AssistantProvider config={config}>
|
|
276
|
+
<AssistantBootstrap>
|
|
277
|
+
<CustomShell />
|
|
278
|
+
</AssistantProvider>
|
|
279
|
+
</AssistantProvider>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Exported building blocks include `ChatComposer`, `ChatMessageView`, `ChatEmptyState`, `ChatActivity`, `ModelSelector`, `MarkdownContent`, and `MermaidDiagram`.
|
|
285
|
+
|
|
286
|
+
## Composer commands
|
|
287
|
+
|
|
288
|
+
The input supports slash commands (e.g. `/clear`). Extend commands in `@4d/assistant/core` via `chat-commands.ts` or handle custom logic before `sendChat` in your host app.
|
|
289
|
+
|
|
290
|
+
## Core package
|
|
291
|
+
|
|
292
|
+
Import headless logic without React:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
import {
|
|
296
|
+
createAssistantStore,
|
|
297
|
+
runLlmAgent,
|
|
298
|
+
buildLlmHistory,
|
|
299
|
+
fetchLlmStatus,
|
|
300
|
+
runAssistantChatCommand,
|
|
301
|
+
} from "@4d/assistant/core";
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Useful for server routes, tests, or a fully custom UI.
|
|
305
|
+
|
|
306
|
+
## Package exports
|
|
307
|
+
|
|
308
|
+
| Import | Contents |
|
|
309
|
+
| --- | --- |
|
|
310
|
+
| `@4d/assistant` | React components, hooks, types |
|
|
311
|
+
| `@4d/assistant/core` | Store, LLM client, commands, interactive tools |
|
|
312
|
+
| `@4d/assistant/styles.css` | Component styles |
|
|
313
|
+
|
|
314
|
+
## Features
|
|
315
|
+
|
|
316
|
+
- Streaming LLM responses with tool-call activity timeline
|
|
317
|
+
- Interactive tool UI (confirmations, choices, reply suggestions)
|
|
318
|
+
- Markdown + GFM + math (KaTeX) + Mermaid diagrams
|
|
319
|
+
- Model selector with searchable dropdown
|
|
320
|
+
- Persistent chat history and model preference (`localStorage`)
|
|
321
|
+
- Welcome screen with static or LLM-generated starter prompts
|
|
322
|
+
- Composer footer toolbar: model selector, generate suggestions, clear chat
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@4djs/assistant",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/react/index.ts",
|
|
7
|
+
"./core": "./src/core/index.ts",
|
|
8
|
+
"./styles.css": "./src/styles/assistant.css"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"build": "bun run build:core && bun run build:react && bun run build:styles",
|
|
13
|
+
"build:core": "bun build ./src/core/index.ts --outdir ./dist/core --target node --format esm",
|
|
14
|
+
"build:react": "bun build ./src/react/index.ts --outdir ./dist --target browser --external react --external react-dom --format esm",
|
|
15
|
+
"build:styles": "mkdir -p dist && cp src/styles/assistant.css dist/styles.css",
|
|
16
|
+
"prepack": "bun run build"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"highlight.js": "11.11.1",
|
|
20
|
+
"katex": "0.17.0",
|
|
21
|
+
"lucide-react": "^0.511.0",
|
|
22
|
+
"mermaid": "11.15.0",
|
|
23
|
+
"react-markdown": "10.1.0",
|
|
24
|
+
"rehype-katex": "7.0.1",
|
|
25
|
+
"remark-gfm": "4.0.1",
|
|
26
|
+
"remark-math": "6.0.0",
|
|
27
|
+
"zustand": "^5.0.5"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": "^19.0.0",
|
|
31
|
+
"react-dom": "^19.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@4d/typescript-config": "workspace:*",
|
|
35
|
+
"@types/react": "^19.1.2",
|
|
36
|
+
"@types/react-dom": "^19.1.2",
|
|
37
|
+
"react": "^19.1.0",
|
|
38
|
+
"react-dom": "^19.1.0",
|
|
39
|
+
"typescript": "catalog:"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export type ChatActivityStatus = "active" | "done" | "error";
|
|
2
|
+
|
|
3
|
+
export interface ChatActivityStep {
|
|
4
|
+
id: string;
|
|
5
|
+
kind: "tool";
|
|
6
|
+
name: string;
|
|
7
|
+
args: Record<string, unknown>;
|
|
8
|
+
callId?: string;
|
|
9
|
+
status: ChatActivityStatus;
|
|
10
|
+
result?: unknown;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function summarizeActivityResult(result: unknown): unknown {
|
|
15
|
+
if (result === undefined || result === null) return result;
|
|
16
|
+
if (typeof result !== "object") return result;
|
|
17
|
+
|
|
18
|
+
const value = result as Record<string, unknown>;
|
|
19
|
+
if ("structuredContent" in value) {
|
|
20
|
+
return value.structuredContent ?? value;
|
|
21
|
+
}
|
|
22
|
+
if ("content" in value && Array.isArray(value.content)) {
|
|
23
|
+
return value.content;
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function chatActivityStepLabel(step: ChatActivityStep): string {
|
|
29
|
+
if (step.status === "active") {
|
|
30
|
+
if (step.name === "request_confirmation")
|
|
31
|
+
return "Waiting for confirmation…";
|
|
32
|
+
if (step.name === "request_choices") return "Waiting for your choice…";
|
|
33
|
+
if (step.name === "suggest_replies") return "Suggesting replies…";
|
|
34
|
+
return `Running ${step.name}…`;
|
|
35
|
+
}
|
|
36
|
+
if (step.status === "error") return `${step.name} failed`;
|
|
37
|
+
if (step.name === "request_confirmation") return "Confirmation answered";
|
|
38
|
+
if (step.name === "request_choices") return "Choice submitted";
|
|
39
|
+
if (step.name === "suggest_replies") return "Reply suggestions shown";
|
|
40
|
+
return `Ran ${step.name}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatJsonIfLarge(raw: string): string {
|
|
44
|
+
const trimmed = raw.trim();
|
|
45
|
+
const JSON_PRETTY_MIN_LENGTH = 80;
|
|
46
|
+
|
|
47
|
+
if (trimmed.length < JSON_PRETTY_MIN_LENGTH) {
|
|
48
|
+
return trimmed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
|
53
|
+
} catch {
|
|
54
|
+
return trimmed;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatActivityJson(raw?: string): string | null {
|
|
59
|
+
if (!raw?.trim()) return null;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
return JSON.stringify(JSON.parse(raw), null, 2);
|
|
63
|
+
} catch {
|
|
64
|
+
return raw;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatActivityJsonValue(value: unknown): string | null {
|
|
69
|
+
if (value === undefined || value === null) return null;
|
|
70
|
+
if (typeof value === "string") return formatActivityJson(value);
|
|
71
|
+
try {
|
|
72
|
+
return JSON.stringify(value, null, 2);
|
|
73
|
+
} catch {
|
|
74
|
+
return String(value);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isEmptyActivityJson(value: unknown): boolean {
|
|
79
|
+
if (value === undefined || value === null) return true;
|
|
80
|
+
|
|
81
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
82
|
+
return Object.keys(value as Record<string, unknown>).length === 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (typeof value === "string") {
|
|
86
|
+
if (!value.trim()) return true;
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(value) as unknown;
|
|
89
|
+
return (
|
|
90
|
+
(typeof parsed === "object" &&
|
|
91
|
+
parsed !== null &&
|
|
92
|
+
Object.keys(parsed).length === 0) ||
|
|
93
|
+
parsed === null
|
|
94
|
+
);
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function stepHasDetails(step: ChatActivityStep): boolean {
|
|
104
|
+
if (step.error?.trim()) return true;
|
|
105
|
+
if (step.result !== undefined && step.result !== null) return true;
|
|
106
|
+
return !isEmptyActivityJson(step.args);
|
|
107
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
export type ChatCommandSurface = "assistant";
|
|
2
|
+
|
|
3
|
+
export type ChatCommandRunResult = {
|
|
4
|
+
handled: boolean;
|
|
5
|
+
clearInput?: boolean;
|
|
6
|
+
error?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type AssistantCommandDeps = {
|
|
10
|
+
clearMessages: () => void;
|
|
11
|
+
setError: (error: string | null) => void;
|
|
12
|
+
streaming: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const CHAT_COMMANDS = [
|
|
16
|
+
{
|
|
17
|
+
name: "clear",
|
|
18
|
+
description: "Clear the current conversation",
|
|
19
|
+
surfaces: ["assistant"] as const,
|
|
20
|
+
run: async (args: string, deps: AssistantCommandDeps) => {
|
|
21
|
+
if (args) {
|
|
22
|
+
throw new Error("The /clear command does not accept arguments.");
|
|
23
|
+
}
|
|
24
|
+
if (deps.streaming) {
|
|
25
|
+
throw new Error("Wait for the assistant to finish before clearing.");
|
|
26
|
+
}
|
|
27
|
+
deps.clearMessages();
|
|
28
|
+
deps.setError(null);
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
] satisfies Array<{
|
|
32
|
+
name: string;
|
|
33
|
+
description: string;
|
|
34
|
+
surfaces: readonly ChatCommandSurface[];
|
|
35
|
+
run: (args: string, deps: AssistantCommandDeps) => void | Promise<void>;
|
|
36
|
+
}>;
|
|
37
|
+
|
|
38
|
+
export type ChatCommandSuggestion = {
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
usage: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function getChatCommandSuggestions(
|
|
45
|
+
surface: ChatCommandSurface,
|
|
46
|
+
): ChatCommandSuggestion[] {
|
|
47
|
+
return CHAT_COMMANDS.filter((command) =>
|
|
48
|
+
command.surfaces.includes(surface),
|
|
49
|
+
).map(({ name, description }) => ({
|
|
50
|
+
name,
|
|
51
|
+
description,
|
|
52
|
+
usage: `/${name}`,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isChatCommandInput(value: string): boolean {
|
|
57
|
+
return value.trimStart().startsWith("/");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function filterChatCommands(
|
|
61
|
+
value: string,
|
|
62
|
+
surface: ChatCommandSurface,
|
|
63
|
+
): ChatCommandSuggestion[] {
|
|
64
|
+
if (!isChatCommandInput(value)) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const query = value.trimStart().slice(1).toLowerCase();
|
|
69
|
+
const commands = getChatCommandSuggestions(surface);
|
|
70
|
+
|
|
71
|
+
if (!query) {
|
|
72
|
+
return commands;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return commands.filter(
|
|
76
|
+
(command) =>
|
|
77
|
+
command.name.startsWith(query) ||
|
|
78
|
+
command.usage.toLowerCase().startsWith(`/${query}`),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function shouldShowChatCommandMenu(
|
|
83
|
+
value: string,
|
|
84
|
+
surface: ChatCommandSurface,
|
|
85
|
+
): boolean {
|
|
86
|
+
if (!isChatCommandInput(value) || value.includes("\n")) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const trimmed = value.trimStart();
|
|
91
|
+
if (trimmed.includes(" ")) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return filterChatCommands(value, surface).length > 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function completionForChatCommand(
|
|
99
|
+
command: ChatCommandSuggestion,
|
|
100
|
+
): string {
|
|
101
|
+
return `${command.usage} `;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function listChatCommands(surface?: ChatCommandSurface): Array<{
|
|
105
|
+
name: string;
|
|
106
|
+
description: string;
|
|
107
|
+
}> {
|
|
108
|
+
return CHAT_COMMANDS.filter(
|
|
109
|
+
(command) => !surface || command.surfaces.includes(surface),
|
|
110
|
+
).map(({ name, description }) => ({ name, description }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseChatCommand(
|
|
114
|
+
input: string,
|
|
115
|
+
): { name: string; args: string } | null {
|
|
116
|
+
const trimmed = input.trim();
|
|
117
|
+
const match = /^\/([a-z][a-z0-9_-]*)(?:\s+([\s\S]*))?$/i.exec(trimmed);
|
|
118
|
+
if (!match) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const name = match[1];
|
|
123
|
+
if (!name) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
name: name.toLowerCase(),
|
|
129
|
+
args: (match[2] ?? "").trim(),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function executeCommand(
|
|
134
|
+
parsed: { name: string; args: string },
|
|
135
|
+
deps: AssistantCommandDeps,
|
|
136
|
+
): Promise<ChatCommandRunResult> {
|
|
137
|
+
const command = CHAT_COMMANDS.find((entry) => entry.name === parsed.name);
|
|
138
|
+
if (!command?.surfaces.includes("assistant")) {
|
|
139
|
+
return {
|
|
140
|
+
handled: true,
|
|
141
|
+
clearInput: true,
|
|
142
|
+
error: `Unknown command: /${parsed.name}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await command.run(parsed.args, deps);
|
|
148
|
+
return { handled: true, clearInput: true };
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return {
|
|
151
|
+
handled: true,
|
|
152
|
+
clearInput: true,
|
|
153
|
+
error: error instanceof Error ? error.message : "Command failed",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function runAssistantChatCommand(
|
|
159
|
+
input: string,
|
|
160
|
+
deps: AssistantCommandDeps,
|
|
161
|
+
): Promise<ChatCommandRunResult> {
|
|
162
|
+
const parsed = parseChatCommand(input);
|
|
163
|
+
if (!parsed) {
|
|
164
|
+
return { handled: false };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return executeCommand(parsed, deps);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** @deprecated Use runAssistantChatCommand instead */
|
|
171
|
+
export function isClearCommand(text: string): boolean {
|
|
172
|
+
return parseChatCommand(text)?.name === "clear";
|
|
173
|
+
}
|