Skip to content

Commit 1dd60fa

Browse files
authored
Charm mentions in prompt composer (#1858)
* Add `@` mentions to `ct-prompt-input` * Pills and files * Implement `ct-chip` and track attachments in `chatbot.tsx` * Checkpoint, pulling handlers off charm * Working (yet janky) dynamic tools * Dice rolling experiment * Ensure charm is running before invoking handler * Remove excess code * Update comment * Remove injected result cell from schema * Add/remove attachments before submission * Format pass * Fix type errors * Remove .md plan * Remove manual schemas now that we trim result cell * Remove commented line * Format pass * Remove TODO * Address PR review * Allow removal of attachments * Format pass * Optional result cell
1 parent 33f5dfd commit 1dd60fa

File tree

19 files changed

+1837
-374
lines changed

19 files changed

+1837
-374
lines changed

packages/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ export interface BuiltInLLMDialogState {
314314
error: unknown;
315315
cancelGeneration: Stream<void>;
316316
addMessage: Stream<BuiltInLLMMessage>;
317+
flattenedTools: Record<string, any>;
317318
}
318319

319320
export interface BuiltInGenerateObjectParams {

packages/patterns/chatbot-note-composed.tsx

Lines changed: 2 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -262,116 +262,48 @@ export default recipe<ChatbotNoteInput, ChatbotNoteResult>(
262262
},
263263
editActiveNote: {
264264
description: "Modify the shared note.",
265-
inputSchema: {
266-
type: "object",
267-
properties: {
268-
body: {
269-
type: "string",
270-
description: "The content of the note.",
271-
},
272-
},
273-
required: ["body"],
274-
} as JSONSchema,
275265
handler: editNote({ content }),
276266
},
277267
readActiveNote: {
278268
description: "Read the currently focused note.",
279-
inputSchema: {
280-
type: "object",
281-
properties: {},
282-
required: [],
283-
} as JSONSchema,
284269
handler: readNote({ content }),
285270
},
286271
listNotes: {
287272
description:
288273
"List all mentionable note titles (read the body with readNoteByIndex).",
289-
inputSchema: {
290-
type: "object",
291-
properties: {},
292-
required: [],
293-
} as JSONSchema,
294274
handler: listMentionable({
295275
allCharms: allCharms as unknown as OpaqueRef<MentionableCharm[]>,
296276
}),
297277
},
298278
readNoteByIndex: {
299279
description:
300280
"Read the body of a note by its index in the listNotes() list.",
301-
inputSchema: {
302-
type: "object",
303-
properties: {
304-
index: {
305-
type: "number",
306-
description: "The index of the note in the notes list.",
307-
},
308-
},
309-
required: ["index"],
310-
} as JSONSchema,
311281
handler: readNoteByIndex({
312282
allCharms: allCharms as unknown as OpaqueRef<MentionableCharm[]>,
313283
}),
314284
},
315285
editNoteByIndex: {
316286
description:
317287
"Edit the body of a note by its index in the listNotes() list.",
318-
inputSchema: {
319-
type: "object",
320-
properties: {
321-
index: {
322-
type: "number",
323-
description: "The index of the note in the notes list.",
324-
},
325-
body: {
326-
type: "string",
327-
description: "The new content of the note.",
328-
},
329-
},
330-
required: ["index", "body"],
331-
} as JSONSchema,
332288
handler: editNoteByIndex({
333289
allCharms: allCharms as unknown as OpaqueRef<MentionableCharm[]>,
334290
}),
335291
},
336292
navigateToNote: {
337293
description: "Navigate to a note by its index in the listNotes() list.",
338-
inputSchema: {
339-
type: "object",
340-
properties: {
341-
index: {
342-
type: "number",
343-
description: "The index of the note in the notes list.",
344-
},
345-
},
346-
required: ["index"],
347-
} as JSONSchema,
348294
handler: navigateToNote({
349295
allCharms: allCharms as unknown as OpaqueRef<MentionableCharm[]>,
350296
}),
351297
},
352298
newNote: {
353-
description: "Read the shared note.",
354-
inputSchema: {
355-
type: "object",
356-
properties: {
357-
title: {
358-
type: "string",
359-
description: "The title of the note.",
360-
},
361-
content: {
362-
type: "string",
363-
description: "The content of the note.",
364-
},
365-
},
366-
required: ["title"],
367-
} as JSONSchema,
299+
description: "Create a new note instance",
368300
handler: newNote({
369301
allCharms: allCharms as unknown as OpaqueRef<MentionableCharm[]>,
370302
}),
371303
},
372304
};
373305

374-
const chat = Chat({ messages, tools });
306+
const chat = Chat({ messages, tools, mentionable: allCharms });
375307
const note = Note({ title, content, allCharms });
376308

377309
return {

packages/patterns/chatbot-outliner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export default recipe<LLMTestInput, LLMTestResult>(
140140
},
141141
};
142142

143-
const chat = Chat({ messages, tools });
143+
const chat = Chat({ messages, tools, mentionable: allCharms });
144144
const { addMessage, cancelGeneration, pending } = chat;
145145

146146
return {

packages/patterns/chatbot.tsx

Lines changed: 137 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,87 @@ import {
2020
Stream,
2121
UI,
2222
} from "commontools";
23+
import { MentionableCharm } from "./chatbot-list-view.tsx";
24+
25+
const addAttachment = handler<
26+
{
27+
detail: {
28+
attachment: PromptAttachment;
29+
};
30+
},
31+
{
32+
allAttachments: Cell<Array<PromptAttachment>>;
33+
}
34+
>((event, { allAttachments }) => {
35+
const { attachment } = event.detail;
36+
const current = allAttachments.get() || [];
37+
allAttachments.set([...current, attachment]);
38+
});
39+
40+
const removeAttachment = handler<
41+
{
42+
detail: {
43+
id: string;
44+
};
45+
},
46+
{
47+
allAttachments: Cell<Array<PromptAttachment>>;
48+
}
49+
>((event, { allAttachments }) => {
50+
const { id } = event.detail;
51+
const current = allAttachments.get() || [];
52+
allAttachments.set(current.filter((a) => a.id !== id));
53+
});
2354

2455
const sendMessage = handler<
25-
{ detail: { message: string } },
56+
{
57+
detail: {
58+
text: string;
59+
attachments: Array<PromptAttachment>;
60+
mentions: Array<any>;
61+
message: string; // Backward compatibility
62+
};
63+
},
2664
{
2765
addMessage: Stream<BuiltInLLMMessage>;
66+
allAttachments: Cell<Array<PromptAttachment>>;
67+
}
68+
>((event, { addMessage, allAttachments }) => {
69+
const { text } = event.detail;
70+
71+
// Build content array from text and attachments
72+
const contentParts = [{ type: "text" as const, text }];
73+
74+
// Get current attachments from the global list
75+
const attachments = allAttachments.get() || [];
76+
77+
// Compute mentions from mention attachments so they are available to consumers
78+
const mentions = attachments
79+
.filter((a) => a.type === "mention" && a.charm)
80+
.map((a) => a.charm);
81+
82+
// Process attachments
83+
for (const attachment of attachments) {
84+
if (attachment.type === "file" && attachment.data) {
85+
// For now, add a text reference
86+
contentParts.push({
87+
type: "text" as const,
88+
text: `[Attached file: ${attachment.name}]`,
89+
});
90+
} else if (attachment.type === "clipboard" && attachment.data) {
91+
// Append clipboard content as additional context
92+
contentParts.push({
93+
type: "text" as const,
94+
text: `\n\n--- Pasted content ---\n${attachment.data}`,
95+
});
96+
}
97+
// Note: mentions are already in the text as clean names
98+
// The charm references are available in attachment.charm if needed
2899
}
29-
>((event, { addMessage }) => {
100+
30101
addMessage.send({
31102
role: "user",
32-
content: [{ type: "text", text: event.detail.message }],
103+
content: contentParts,
33104
});
34105
});
35106

@@ -50,6 +121,15 @@ type ChatInput = {
50121
messages: Default<Array<BuiltInLLMMessage>, []>;
51122
tools: any;
52123
theme?: any;
124+
mentionable: Cell<MentionableCharm[]>;
125+
};
126+
127+
type PromptAttachment = {
128+
id: string;
129+
name: string;
130+
type: "file" | "clipboard" | "mention";
131+
data?: any; // File | Blob | string
132+
charm?: any;
53133
};
54134

55135
type ChatOutput = {
@@ -58,6 +138,8 @@ type ChatOutput = {
58138
addMessage: Stream<BuiltInLLMMessage>;
59139
cancelGeneration: Stream<void>;
60140
title?: string;
141+
attachments: Array<PromptAttachment>;
142+
tools: any;
61143
};
62144

63145
export const TitleGenerator = recipe<
@@ -100,16 +182,45 @@ export const TitleGenerator = recipe<
100182

101183
export default recipe<ChatInput, ChatOutput>(
102184
"Chat",
103-
({ messages, tools, theme }) => {
185+
({ messages, tools, theme, mentionable }) => {
104186
const model = cell<string>("anthropic:claude-sonnet-4-5");
187+
const allAttachments = cell<Array<PromptAttachment>>([]);
105188

106-
const { addMessage, cancelGeneration, pending } = llmDialog({
107-
system: "You are a helpful assistant with some tools.",
108-
messages,
109-
tools,
110-
model,
189+
// Derive tools from attachments
190+
const dynamicTools = derive(allAttachments, (attachments) => {
191+
const tools: Record<string, any> = {};
192+
193+
for (const attachment of attachments || []) {
194+
if (attachment.type === "mention" && attachment.charm) {
195+
const charmName = attachment.charm[NAME] || "Charm";
196+
tools[charmName] = {
197+
charm: attachment.charm,
198+
description: `Handlers from ${charmName}`,
199+
};
200+
}
201+
}
202+
203+
return tools;
111204
});
112205

206+
// Merge static and dynamic tools
207+
const mergedTools = derive(
208+
[tools, dynamicTools],
209+
([staticTools, dynamic]: [any, any]) => ({
210+
...staticTools,
211+
...dynamic,
212+
}),
213+
);
214+
215+
const { addMessage, cancelGeneration, pending, flattenedTools } = llmDialog(
216+
{
217+
system: "You are a helpful assistant with some tools.",
218+
messages,
219+
tools: mergedTools,
220+
model,
221+
},
222+
);
223+
113224
const { result } = fetchData({
114225
url: "/api/ai/llm/models",
115226
mode: "json",
@@ -130,26 +241,36 @@ export default recipe<ChatInput, ChatOutput>(
130241
[NAME]: title,
131242
[UI]: (
132243
<ct-screen>
133-
<ct-hstack justify="between" slot="header">
244+
<ct-vstack slot="header">
134245
<ct-heading level={4}>{title}</ct-heading>
135-
<ct-tools-chip tools={tools} />
136-
</ct-hstack>
246+
<ct-hstack gap="normal">
247+
<ct-attachments-bar
248+
attachments={allAttachments}
249+
removable
250+
onct-remove={removeAttachment({ allAttachments })}
251+
/>
252+
<ct-tools-chip tools={flattenedTools} />
253+
</ct-hstack>
254+
</ct-vstack>
137255

138256
<ct-vscroll flex showScrollbar fadeEdges snapToBottom>
139257
<ct-chat
140258
theme={theme}
141259
$messages={messages}
142260
pending={pending}
143-
tools={tools}
261+
tools={flattenedTools}
144262
/>
145263
</ct-vscroll>
146264

147265
<div slot="footer">
148266
<ct-prompt-input
149267
placeholder="Ask the LLM a question..."
150268
pending={pending}
151-
onct-send={sendMessage({ addMessage })}
269+
$mentionable={mentionable}
270+
onct-send={sendMessage({ addMessage, allAttachments })}
152271
onct-stop={cancelGeneration}
272+
onct-attachment-add={addAttachment({ allAttachments })}
273+
onct-attachment-remove={removeAttachment({ allAttachments })}
153274
/>
154275
<ct-select
155276
items={items}
@@ -163,6 +284,8 @@ export default recipe<ChatInput, ChatOutput>(
163284
addMessage,
164285
cancelGeneration,
165286
title,
287+
attachments: allAttachments,
288+
tools: flattenedTools,
166289
};
167290
},
168291
);

packages/patterns/counter-handlers.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
/// <cts-enable />
22
import { Cell, derive, handler } from "commontools";
33

4-
export const increment = handler<unknown, { value: Cell<number> }>(
5-
(_, state) => {
4+
export const increment = handler<
5+
{ result?: Cell<string> },
6+
{ value: Cell<number> }
7+
>(
8+
(args, state) => {
69
state.value.set(state.value.get() + 1);
10+
args.result?.set(`Incremented to ${state.value.get()}`);
711
},
812
);
913

10-
export const decrement = handler((_, state: { value: Cell<number> }) => {
11-
state.value.set(state.value.get() - 1);
12-
});
14+
export const decrement = handler<
15+
{ result?: Cell<string> },
16+
{ value: Cell<number> }
17+
>(
18+
(args, state) => {
19+
state.value.set(state.value.get() - 1);
20+
args.result?.set(`Decremented to ${state.value.get()}`);
21+
},
22+
);
1323

1424
export function nth(value: number) {
1525
if (value === 1) {

packages/patterns/counter.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,7 @@ export default recipe<RecipeState>("Counter", (state) => {
2323
</div>
2424
),
2525
value: state.value,
26+
increment: increment(state),
27+
decrement: decrement(state),
2628
};
2729
});

0 commit comments

Comments
 (0)