@ifc-lite/viewer 1.19.0 → 1.19.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/.turbo/turbo-build.log +15 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +8 -0
- package/dist/assets/basketViewActivator-CA2CTcVo.js +71 -0
- package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
- package/dist/assets/{exporters-BraHBeoi.js → exporters-xbXqEDlO.js} +53 -46
- package/dist/assets/ids-2WdONLlu.js +2033 -0
- package/dist/assets/index-BXeEKqJG.css +1 -0
- package/dist/assets/{index-BOi3BuUI.js → index-D8Epw-e7.js} +48072 -30928
- package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-DKmx1z95.js} +2 -2
- package/dist/assets/{sandbox-Baez7n-t.js → sandbox-tccwm5Bo.js} +547 -529
- package/dist/assets/{server-client-BB6cMAXE.js → server-client-LoWPK1N2.js} +1 -1
- package/dist/assets/three-CDRZThFA.js +4057 -0
- package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-BsJGgPMs.js} +1 -1
- package/dist/index.html +8 -7
- package/dist/samples/building-architecture.ifc +453 -0
- package/dist/samples/hello-wall.ifc +1054 -0
- package/dist/samples/infra-bridge.ifc +962 -0
- package/package.json +7 -2
- package/public/samples/building-architecture.ifc +453 -0
- package/public/samples/hello-wall.ifc +1054 -0
- package/public/samples/infra-bridge.ifc +962 -0
- package/src/App.tsx +37 -3
- package/src/components/mcp/HeroScene.tsx +876 -0
- package/src/components/mcp/McpLanding.tsx +1318 -0
- package/src/components/mcp/McpPlayground.tsx +524 -0
- package/src/components/mcp/PlaygroundChat.tsx +1097 -0
- package/src/components/mcp/PlaygroundViewer.tsx +815 -0
- package/src/components/mcp/README.md +171 -0
- package/src/components/mcp/data.ts +659 -0
- package/src/components/mcp/playground-dispatcher.ts +1649 -0
- package/src/components/mcp/playground-files.ts +107 -0
- package/src/components/mcp/playground-uploads.ts +122 -0
- package/src/components/mcp/types.ts +65 -0
- package/src/components/mcp/use-mcp-page.ts +109 -0
- package/src/components/viewer/MainToolbar.tsx +19 -0
- package/src/components/viewer/ViewportContainer.tsx +35 -4
- package/src/generated/mcp-catalog.json +82 -0
- package/vite.config.ts +6 -0
- package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
- package/dist/assets/ids-DQ5jY0E8.js +0 -1
- package/dist/assets/index-0XpVr_S5.css +0 -1
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PlaygroundChat — chat panel that streams from Anthropic with the MCP
|
|
7
|
+
* tool catalogue exposed via Claude's native `tools` parameter.
|
|
8
|
+
*
|
|
9
|
+
* Loop:
|
|
10
|
+
* 1. User submits a prompt.
|
|
11
|
+
* 2. We POST history + tools[] to Anthropic via @anthropic-ai/sdk
|
|
12
|
+
* (dangerouslyAllowBrowser: true; key from BYOK localStorage).
|
|
13
|
+
* 3. While the response contains `tool_use` blocks AND we're under the
|
|
14
|
+
* 25-call hard cap: run each tool through `dispatch()`, push the
|
|
15
|
+
* paired `tool_result` blocks back as a new user message, ask
|
|
16
|
+
* Anthropic to continue.
|
|
17
|
+
* 4. When the response has no more tool_use blocks (or we hit the cap),
|
|
18
|
+
* flush the assistant message + render.
|
|
19
|
+
*
|
|
20
|
+
* Tool calls are rendered inline as collapsible cards so the agent's
|
|
21
|
+
* reasoning trail is the page's main signal.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
type ReactNode,
|
|
26
|
+
useCallback,
|
|
27
|
+
useEffect,
|
|
28
|
+
useMemo,
|
|
29
|
+
useRef,
|
|
30
|
+
useState,
|
|
31
|
+
} from 'react';
|
|
32
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
33
|
+
import ReactMarkdown from 'react-markdown';
|
|
34
|
+
import remarkGfm from 'remark-gfm';
|
|
35
|
+
import { ArrowUp, Check, ChevronDown, ChevronRight, Download, Key, Loader2, RefreshCcw, Wrench } from 'lucide-react';
|
|
36
|
+
import { cn } from '@/lib/utils';
|
|
37
|
+
import { getApiKeys, subscribeApiKeys, updateApiKeys, type ApiKeyConfig } from '@/services/api-keys';
|
|
38
|
+
import {
|
|
39
|
+
anthropicToolDefinitions,
|
|
40
|
+
dispatch,
|
|
41
|
+
type AnthropicToolDef,
|
|
42
|
+
type DispatchContext,
|
|
43
|
+
type LoadedPlaygroundModel,
|
|
44
|
+
type ToolDispatchResult,
|
|
45
|
+
} from './playground-dispatcher';
|
|
46
|
+
import { playgroundFiles, formatBytes as formatFileBytes } from './playground-files';
|
|
47
|
+
import { playgroundUploads, usePlaygroundUploads, type UploadedFile } from './playground-uploads';
|
|
48
|
+
import { Paperclip, X } from 'lucide-react';
|
|
49
|
+
|
|
50
|
+
// Default Claude model. Stays within the safe BYOK price band; users with a
|
|
51
|
+
// big key can swap via localStorage / a future picker.
|
|
52
|
+
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
|
53
|
+
const MAX_TOOL_CALLS = 25;
|
|
54
|
+
const MAX_TOKENS = 4096;
|
|
55
|
+
const SYSTEM_PROMPT = `You are a BIM/IFC analyst driving @ifc-lite/mcp tools against a pre-loaded model. Be terse — the user is technical and time-pressed.
|
|
56
|
+
|
|
57
|
+
Voice rules (strict):
|
|
58
|
+
- NO greetings, preambles, sign-offs, or apologies. Skip "Sure!", "Done!", "Here's…", "Let me know if…".
|
|
59
|
+
- NO emojis. Ever.
|
|
60
|
+
- NO unprompted follow-up menus ("Would you like to…?", "I can also…"). The user will ask if they want more.
|
|
61
|
+
- Aim for 1-3 short lines per reply unless the user asked for analysis.
|
|
62
|
+
- When a tool call already prints the result inline, your text reply can be empty or one short sentence at most.
|
|
63
|
+
- Markdown is fine for tables / data, but skip it for short confirmations.
|
|
64
|
+
|
|
65
|
+
Tool etiquette:
|
|
66
|
+
- Prefer tool calls over guessing — query_entities, get_entity, properties_unique, materials_list, geometry_volume/area, model_audit, bsdd_* are all available.
|
|
67
|
+
- MUTATION IS ALLOWED. The full read+write surface is wired here, not just read-only. When the user asks to set, change, add, delete, create, batch-edit, etc., go ahead — call entity_set_property, entity_set_attribute, entity_create, entity_delete, entity_delete_property, mutation_batch directly. Don't refuse, don't ask "are you sure?", don't suggest they "run this in the stdio MCP instead". Browser-local model, undo via mutation_undo, save via model_save.
|
|
68
|
+
- BCF authoring is also live: bcf_topic_create, bcf_topic_update, bcf_topic_close, bcf_viewpoint_create. The .bcfzip auto-stages after every BCF call — the user just clicks the pill.
|
|
69
|
+
- The 3D viewer is INLINE on this page. When the user asks any 3D action (open, isolate, colorize, section, fly to, etc.), call viewer_open / viewer_* DIRECTLY. Do NOT call viewer_ask first. viewer_ask is only for when YOU are proactively suggesting the viewer.
|
|
70
|
+
- Quote real GlobalIds + values when you cite something. Don't paraphrase.
|
|
71
|
+
|
|
72
|
+
File attachments (IDS specs etc.):
|
|
73
|
+
- The user can drag-drop .ids files (or any text file) onto the chat. When they do, a system note appears in their message: "[Attached file: foo.ids …]". Use ids_validate / ids_explain with ids_path: "foo.ids" — the playground resolves the upload behind the scenes. Do NOT ask the user to paste raw XML.
|
|
74
|
+
- If the user mentions "this IDS" / "validate against the spec" but no attachment is in this turn, ask them to drop the .ids file onto the chat (don't ask for raw XML).
|
|
75
|
+
|
|
76
|
+
Downloads (very important):
|
|
77
|
+
- When a tool produces a file (bcf_export, model_save, export_ifc/csv/json, ids_validate), the playground UI automatically renders an inline "Get .bcf" / "Save IFC" / etc. button under that tool call. The user clicks it explicitly — files NEVER download automatically.
|
|
78
|
+
- Don't tell the user "the file is in the Downloads panel" or "click the download button" — the button is right there, redundant. Just confirm what was produced in 1 line.
|
|
79
|
+
- BCF auto-stages: every bcf_topic_* / bcf_viewpoint_create call already produces a fresh .bcfzip pill. DO NOT call bcf_export afterwards just to "create" the download — it's already there. Only call bcf_export if the user explicitly asks to re-export with custom settings.
|
|
80
|
+
- After mutations, if the user is wrapping up, suggest model_save ONCE so they can grab the edited IFC. Don't keep re-suggesting.`;
|
|
81
|
+
|
|
82
|
+
// ── message model ──────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
interface ChatToolCall {
|
|
85
|
+
id: string;
|
|
86
|
+
name: string;
|
|
87
|
+
args: Record<string, unknown>;
|
|
88
|
+
result?: ToolDispatchResult;
|
|
89
|
+
startedAt: number;
|
|
90
|
+
finishedAt?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface ChatMessage {
|
|
94
|
+
id: string;
|
|
95
|
+
role: 'user' | 'assistant';
|
|
96
|
+
text: string;
|
|
97
|
+
toolCalls?: ChatToolCall[];
|
|
98
|
+
/** True while we're still streaming + looping tool calls. */
|
|
99
|
+
pending?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── component ──────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export function PlaygroundChat({
|
|
105
|
+
model,
|
|
106
|
+
dispatchContext,
|
|
107
|
+
}: {
|
|
108
|
+
model: LoadedPlaygroundModel | null;
|
|
109
|
+
/** Lets the chat thread the live viewer controller (etc.) into every
|
|
110
|
+
* tool call so viewer_* tools can drive the inline canvas. */
|
|
111
|
+
dispatchContext?: () => DispatchContext;
|
|
112
|
+
}): ReactNode {
|
|
113
|
+
const [keys, setKeys] = useState<ApiKeyConfig>(() => getApiKeys());
|
|
114
|
+
useEffect(() => subscribeApiKeys(() => setKeys(getApiKeys())), []);
|
|
115
|
+
|
|
116
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
117
|
+
const [input, setInput] = useState('');
|
|
118
|
+
const [isStreaming, setStreaming] = useState(false);
|
|
119
|
+
const [error, setError] = useState<string | null>(null);
|
|
120
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
121
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
122
|
+
const [dragOver, setDragOver] = useState(false);
|
|
123
|
+
// Files attached since the last send. They land in the playgroundUploads
|
|
124
|
+
// store (so the dispatcher can resolve them by name) AND get listed in
|
|
125
|
+
// a "to send" array we drain on each submit.
|
|
126
|
+
const uploads = usePlaygroundUploads();
|
|
127
|
+
const [pendingAttachments, setPendingAttachments] = useState<UploadedFile[]>([]);
|
|
128
|
+
|
|
129
|
+
// Auto-scroll on new content
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const el = scrollRef.current;
|
|
132
|
+
if (!el) return;
|
|
133
|
+
el.scrollTop = el.scrollHeight;
|
|
134
|
+
}, [messages]);
|
|
135
|
+
|
|
136
|
+
const tools = useMemo(() => anthropicToolDefinitions(), []);
|
|
137
|
+
|
|
138
|
+
const send = useCallback(
|
|
139
|
+
async (prompt: string, attached: UploadedFile[]) => {
|
|
140
|
+
if (!model) {
|
|
141
|
+
setError('Load a sample model first.');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!keys.anthropicKey) {
|
|
145
|
+
setError('Set an Anthropic key (top right).');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
setError(null);
|
|
149
|
+
setStreaming(true);
|
|
150
|
+
|
|
151
|
+
// Prepend a tiny system-note prefix when files were attached so the
|
|
152
|
+
// agent knows the upload exists and how to reference it. Per-kind
|
|
153
|
+
// hints so the agent picks the right tool without guessing.
|
|
154
|
+
const attachNote = attached.length > 0
|
|
155
|
+
? attached.map((u) => describeAttachment(u)).join('\n') + '\n\n'
|
|
156
|
+
: '';
|
|
157
|
+
const fullPrompt = attachNote + prompt;
|
|
158
|
+
|
|
159
|
+
const userMessage: ChatMessage = { id: rid(), role: 'user', text: fullPrompt };
|
|
160
|
+
const assistantMessage: ChatMessage = {
|
|
161
|
+
id: rid(),
|
|
162
|
+
role: 'assistant',
|
|
163
|
+
text: '',
|
|
164
|
+
toolCalls: [],
|
|
165
|
+
pending: true,
|
|
166
|
+
};
|
|
167
|
+
setMessages((m) => [...m, userMessage, assistantMessage]);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await runConversation({
|
|
171
|
+
apiKey: keys.anthropicKey,
|
|
172
|
+
tools,
|
|
173
|
+
history: [...messages, userMessage],
|
|
174
|
+
model,
|
|
175
|
+
assistantId: assistantMessage.id,
|
|
176
|
+
getDispatchContext: dispatchContext ?? (() => ({})),
|
|
177
|
+
onUpdate: (patch) => {
|
|
178
|
+
setMessages((m) =>
|
|
179
|
+
m.map((msg) => (msg.id === assistantMessage.id ? { ...msg, ...patch } : msg)),
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
} catch (err) {
|
|
184
|
+
setMessages((m) =>
|
|
185
|
+
m.map((msg) =>
|
|
186
|
+
msg.id === assistantMessage.id
|
|
187
|
+
? { ...msg, pending: false, text: msg.text || '— request failed —' }
|
|
188
|
+
: msg,
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
192
|
+
} finally {
|
|
193
|
+
setStreaming(false);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
[keys.anthropicKey, model, tools, messages, dispatchContext],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const onSubmit = (e: React.FormEvent) => {
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
const trimmed = input.trim();
|
|
202
|
+
if ((!trimmed && pendingAttachments.length === 0) || isStreaming) return;
|
|
203
|
+
const attachedThisTurn = pendingAttachments;
|
|
204
|
+
setInput('');
|
|
205
|
+
setPendingAttachments([]);
|
|
206
|
+
void send(trimmed || '(see attached file)', attachedThisTurn);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const attachFiles = useCallback(async (files: FileList | File[]) => {
|
|
210
|
+
const list: UploadedFile[] = [];
|
|
211
|
+
for (const f of Array.from(files)) {
|
|
212
|
+
// 25 MB cap so a small IFC fits, but blocks rogue gigabyte drops.
|
|
213
|
+
if (f.size > 25 * 1024 * 1024) {
|
|
214
|
+
setError(`${f.name} is over 25 MB — too large for chat attachments. Use the sample picker instead.`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const entry = await playgroundUploads.add(f);
|
|
219
|
+
list.push(entry);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
setError(`Failed to read ${f.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (list.length > 0) setPendingAttachments((prev) => [...prev, ...list]);
|
|
225
|
+
}, []);
|
|
226
|
+
|
|
227
|
+
/** Per-kind system note so the agent knows what to do with the file
|
|
228
|
+
* without having to guess from the extension. */
|
|
229
|
+
function describeAttachment(u: UploadedFile): string {
|
|
230
|
+
const ext = u.name.toLowerCase().split('.').pop() ?? '';
|
|
231
|
+
const head = `[Attached: ${u.name} · ${formatFileBytes(u.size)}]`;
|
|
232
|
+
switch (ext) {
|
|
233
|
+
case 'ids':
|
|
234
|
+
return `${head} — IDS spec. Call ids_validate / ids_explain with ids_path: "${u.name}".`;
|
|
235
|
+
case 'csv':
|
|
236
|
+
case 'tsv': {
|
|
237
|
+
// Inline a small preview so the agent can summarise without a
|
|
238
|
+
// dedicated read_file tool. Cap at ~16 lines / 2 KB.
|
|
239
|
+
const preview = u.text.split('\n').slice(0, 16).join('\n').slice(0, 2048);
|
|
240
|
+
return `${head} — CSV data. First lines:\n\`\`\`\n${preview}\n\`\`\``;
|
|
241
|
+
}
|
|
242
|
+
case 'json': {
|
|
243
|
+
const preview = u.text.slice(0, 2048);
|
|
244
|
+
return `${head} — JSON. Preview:\n\`\`\`json\n${preview}${u.text.length > 2048 ? '\n…' : ''}\n\`\`\``;
|
|
245
|
+
}
|
|
246
|
+
case 'ifc':
|
|
247
|
+
return `${head} — IFC file. The playground's loaded model is the primary one; treat this as background reference. Don't try to ingest it as a second model in v1.`;
|
|
248
|
+
case 'bcf':
|
|
249
|
+
case 'bcfzip':
|
|
250
|
+
return `${head} — BCF bundle. The playground can only WRITE BCF in v1; tell the user this is read-only context.`;
|
|
251
|
+
case 'xml':
|
|
252
|
+
return `${head} — XML. If it looks like IDS, call ids_validate with ids_path: "${u.name}".`;
|
|
253
|
+
default: {
|
|
254
|
+
const preview = u.text.slice(0, 1024);
|
|
255
|
+
return `${head}\n\`\`\`\n${preview}${u.text.length > 1024 ? '\n…' : ''}\n\`\`\``;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── render ──────────────────────────────────────────────────────────────
|
|
261
|
+
return (
|
|
262
|
+
<div className="flex h-full min-h-0 flex-col bg-[#0f0f12] text-[#ede4d3]">
|
|
263
|
+
<KeyHeader keys={keys} onSave={(next) => updateApiKeys(next)} />
|
|
264
|
+
|
|
265
|
+
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-5">
|
|
266
|
+
{messages.length === 0 ? (
|
|
267
|
+
<Welcome model={model} onPickPrompt={(p) => setInput(p)} />
|
|
268
|
+
) : (
|
|
269
|
+
<ul className="flex flex-col gap-5">
|
|
270
|
+
{messages.map((m) => (
|
|
271
|
+
<li key={m.id}>
|
|
272
|
+
<MessageView msg={m} />
|
|
273
|
+
</li>
|
|
274
|
+
))}
|
|
275
|
+
</ul>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
{error && (
|
|
280
|
+
<div className="mx-5 mb-2 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-[12px] text-red-200">
|
|
281
|
+
{error}
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
<form
|
|
286
|
+
onSubmit={onSubmit}
|
|
287
|
+
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
|
288
|
+
onDragLeave={() => setDragOver(false)}
|
|
289
|
+
onDrop={(e) => {
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
setDragOver(false);
|
|
292
|
+
if (e.dataTransfer.files.length > 0) void attachFiles(e.dataTransfer.files);
|
|
293
|
+
}}
|
|
294
|
+
className={cn('relative border-t p-3 transition-colors', dragOver ? 'border-[#d6ff3f]/60 bg-[#d6ff3f]/5' : 'border-white/10')}
|
|
295
|
+
>
|
|
296
|
+
{/* Pending attachment chips */}
|
|
297
|
+
{pendingAttachments.length > 0 && (
|
|
298
|
+
<div className="mb-2 flex flex-wrap gap-1.5">
|
|
299
|
+
{pendingAttachments.map((f) => (
|
|
300
|
+
<span
|
|
301
|
+
key={f.name}
|
|
302
|
+
className="inline-flex items-center gap-1.5 rounded-full border border-[#d6ff3f]/30 bg-[#d6ff3f]/10 px-2 py-1 text-[11px]"
|
|
303
|
+
style={{ ...{ fontFamily: '"JetBrains Mono", monospace' }, color: '#d6ff3f' }}
|
|
304
|
+
>
|
|
305
|
+
<Paperclip size={11} />
|
|
306
|
+
<span className="max-w-[180px] truncate" title={f.name}>{f.name}</span>
|
|
307
|
+
<span className="text-white/40">{formatFileBytes(f.size)}</span>
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
onClick={() => {
|
|
311
|
+
playgroundUploads.remove(f.name);
|
|
312
|
+
setPendingAttachments((prev) => prev.filter((x) => x.name !== f.name));
|
|
313
|
+
}}
|
|
314
|
+
className="ml-0.5 inline-flex h-3.5 w-3.5 items-center justify-center rounded-full text-white/50 hover:bg-white/10 hover:text-white"
|
|
315
|
+
aria-label={`Remove ${f.name}`}
|
|
316
|
+
>
|
|
317
|
+
<X size={10} />
|
|
318
|
+
</button>
|
|
319
|
+
</span>
|
|
320
|
+
))}
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
|
|
324
|
+
<div className="flex items-end gap-2 rounded-md border border-white/15 bg-white/[0.03] px-2.5 py-2">
|
|
325
|
+
{/* Attach */}
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
onClick={() => fileInputRef.current?.click()}
|
|
329
|
+
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded text-white/55 hover:bg-white/5 hover:text-white"
|
|
330
|
+
title="Attach a file (.ids, .xml, …)"
|
|
331
|
+
>
|
|
332
|
+
<Paperclip size={14} />
|
|
333
|
+
</button>
|
|
334
|
+
<input
|
|
335
|
+
ref={fileInputRef}
|
|
336
|
+
type="file"
|
|
337
|
+
multiple
|
|
338
|
+
accept=".ids,.xml,.json,.csv,.txt,.ifc,.bcf,.bcfzip,.md"
|
|
339
|
+
className="hidden"
|
|
340
|
+
onChange={(e) => {
|
|
341
|
+
if (e.target.files) {
|
|
342
|
+
void attachFiles(e.target.files);
|
|
343
|
+
e.target.value = ''; // allow re-attaching the same file
|
|
344
|
+
}
|
|
345
|
+
}}
|
|
346
|
+
/>
|
|
347
|
+
<textarea
|
|
348
|
+
value={input}
|
|
349
|
+
onChange={(e) => setInput(e.target.value)}
|
|
350
|
+
onKeyDown={(e) => {
|
|
351
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
onSubmit(e);
|
|
354
|
+
}
|
|
355
|
+
}}
|
|
356
|
+
placeholder={
|
|
357
|
+
!model
|
|
358
|
+
? 'Load a sample model first.'
|
|
359
|
+
: !keys.anthropicKey
|
|
360
|
+
? 'Set an Anthropic key first.'
|
|
361
|
+
: pendingAttachments.length > 0
|
|
362
|
+
? 'Add a note (or just send to validate the attached file)…'
|
|
363
|
+
: 'Ask the agent — drop a .ids onto the chat to validate it.'
|
|
364
|
+
}
|
|
365
|
+
disabled={!model || isStreaming}
|
|
366
|
+
rows={1}
|
|
367
|
+
className="min-h-[28px] max-h-32 flex-1 resize-none bg-transparent text-[14px] outline-none placeholder:text-white/30"
|
|
368
|
+
style={{ fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' }}
|
|
369
|
+
/>
|
|
370
|
+
<button
|
|
371
|
+
type="submit"
|
|
372
|
+
disabled={(!input.trim() && pendingAttachments.length === 0) || isStreaming || !model || !keys.anthropicKey}
|
|
373
|
+
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-[#d6ff3f] text-[#0a0a0c] transition-opacity disabled:opacity-30"
|
|
374
|
+
aria-label="Send"
|
|
375
|
+
>
|
|
376
|
+
{isStreaming ? <Loader2 size={14} className="animate-spin" /> : <ArrowUp size={14} strokeWidth={2.5} />}
|
|
377
|
+
</button>
|
|
378
|
+
</div>
|
|
379
|
+
<p className="mt-1.5 text-[10px] text-white/40">
|
|
380
|
+
BYOK · {tools.length} tools · {uploads.length > 0 ? `${uploads.length} attached file${uploads.length === 1 ? '' : 's'} · ` : ''}enter to send · ⇧+enter for newline · drop files to attach
|
|
381
|
+
</p>
|
|
382
|
+
{dragOver && (
|
|
383
|
+
<div
|
|
384
|
+
className="pointer-events-none absolute inset-2 flex items-center justify-center rounded-md border-2 border-dashed text-[12px]"
|
|
385
|
+
style={{ borderColor: '#d6ff3f', color: '#d6ff3f', background: 'rgba(214,255,63,0.05)', fontFamily: '"JetBrains Mono", monospace' }}
|
|
386
|
+
>
|
|
387
|
+
release to attach
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
</form>
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── header / key entry ─────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
function KeyHeader({
|
|
398
|
+
keys,
|
|
399
|
+
onSave,
|
|
400
|
+
}: {
|
|
401
|
+
keys: ApiKeyConfig;
|
|
402
|
+
onSave: (next: Partial<ApiKeyConfig>) => void;
|
|
403
|
+
}): ReactNode {
|
|
404
|
+
const [editing, setEditing] = useState(false);
|
|
405
|
+
const [draft, setDraft] = useState(keys.anthropicKey);
|
|
406
|
+
useEffect(() => setDraft(keys.anthropicKey), [keys.anthropicKey]);
|
|
407
|
+
const masked = keys.anthropicKey
|
|
408
|
+
? `${keys.anthropicKey.slice(0, 7)}…${keys.anthropicKey.slice(-4)}`
|
|
409
|
+
: '';
|
|
410
|
+
return (
|
|
411
|
+
<div className="flex items-center justify-between gap-2 border-b border-white/10 px-4 py-2.5">
|
|
412
|
+
<div className="flex items-center gap-2">
|
|
413
|
+
<span
|
|
414
|
+
className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-[#d6ff3f]"
|
|
415
|
+
aria-hidden
|
|
416
|
+
/>
|
|
417
|
+
<span className="text-[10px] uppercase tracking-[0.22em] text-white/60" style={{ fontFamily: '"JetBrains Mono", monospace' }}>
|
|
418
|
+
ifc-lite/mcp · agent
|
|
419
|
+
</span>
|
|
420
|
+
</div>
|
|
421
|
+
{editing ? (
|
|
422
|
+
<form
|
|
423
|
+
onSubmit={(e) => {
|
|
424
|
+
e.preventDefault();
|
|
425
|
+
onSave({ anthropicKey: draft.trim() });
|
|
426
|
+
setEditing(false);
|
|
427
|
+
}}
|
|
428
|
+
className="flex items-center gap-1.5"
|
|
429
|
+
>
|
|
430
|
+
<input
|
|
431
|
+
type="password"
|
|
432
|
+
value={draft}
|
|
433
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
434
|
+
placeholder="sk-ant-…"
|
|
435
|
+
className="w-56 rounded border border-white/20 bg-white/5 px-2 py-1 text-[11px] outline-none placeholder:text-white/30"
|
|
436
|
+
style={{ fontFamily: '"JetBrains Mono", monospace' }}
|
|
437
|
+
autoFocus
|
|
438
|
+
/>
|
|
439
|
+
<button type="submit" className="rounded bg-[#d6ff3f] px-2 py-1 text-[10px] font-semibold text-[#0a0a0c]">
|
|
440
|
+
save
|
|
441
|
+
</button>
|
|
442
|
+
<button type="button" onClick={() => setEditing(false)} className="text-[10px] text-white/50">
|
|
443
|
+
cancel
|
|
444
|
+
</button>
|
|
445
|
+
</form>
|
|
446
|
+
) : (
|
|
447
|
+
<button
|
|
448
|
+
onClick={() => setEditing(true)}
|
|
449
|
+
className={cn(
|
|
450
|
+
'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-[10.5px]',
|
|
451
|
+
keys.anthropicKey
|
|
452
|
+
? 'border-[#d6ff3f]/40 text-[#d6ff3f]'
|
|
453
|
+
: 'border-orange-400/40 text-orange-300',
|
|
454
|
+
)}
|
|
455
|
+
style={{ fontFamily: '"JetBrains Mono", monospace' }}
|
|
456
|
+
>
|
|
457
|
+
<Key size={11} />
|
|
458
|
+
{keys.anthropicKey ? `key set · ${masked}` : 'set Anthropic key'}
|
|
459
|
+
</button>
|
|
460
|
+
)}
|
|
461
|
+
</div>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── welcome ───────────────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
const STARTER_PROMPTS = [
|
|
468
|
+
'Run model_audit and tell me the score. Then list any issues.',
|
|
469
|
+
'How many IfcWall vs IfcWindow vs IfcDoor are in this model?',
|
|
470
|
+
'Find every IfcWall where Pset_WallCommon.IsExternal = true. Tell me their GlobalIds.',
|
|
471
|
+
'Look up Pset_WallCommon in bSDD and list its canonical properties.',
|
|
472
|
+
'Group the entities by storey. Which storey has the most elements?',
|
|
473
|
+
];
|
|
474
|
+
|
|
475
|
+
function Welcome({
|
|
476
|
+
model,
|
|
477
|
+
onPickPrompt,
|
|
478
|
+
}: {
|
|
479
|
+
model: LoadedPlaygroundModel | null;
|
|
480
|
+
onPickPrompt: (p: string) => void;
|
|
481
|
+
}): ReactNode {
|
|
482
|
+
return (
|
|
483
|
+
<div className="flex h-full flex-col items-start justify-center gap-4">
|
|
484
|
+
<div>
|
|
485
|
+
<h2
|
|
486
|
+
className="text-[28px] leading-none tracking-tight"
|
|
487
|
+
style={{ fontFamily: '"Instrument Serif", serif', fontStyle: 'italic' }}
|
|
488
|
+
>
|
|
489
|
+
{model ? `Ask the agent about ${model.name}.` : 'Load a model. Ask the agent.'}
|
|
490
|
+
</h2>
|
|
491
|
+
<p className="mt-2 max-w-md text-[13.5px] leading-snug text-white/60">
|
|
492
|
+
Claude drives the same {anthropicToolDefinitions().length} tools the stdio MCP exposes — query, mutate, validate, BCF, export. Tool calls render inline.
|
|
493
|
+
</p>
|
|
494
|
+
</div>
|
|
495
|
+
{model && (
|
|
496
|
+
<div className="mt-2 flex flex-col gap-1.5">
|
|
497
|
+
<span className="text-[10px] uppercase tracking-[0.22em] text-white/40" style={{ fontFamily: '"JetBrains Mono", monospace' }}>
|
|
498
|
+
try
|
|
499
|
+
</span>
|
|
500
|
+
<div className="flex flex-col gap-1.5">
|
|
501
|
+
{STARTER_PROMPTS.map((p) => (
|
|
502
|
+
<button
|
|
503
|
+
key={p}
|
|
504
|
+
onClick={() => onPickPrompt(p)}
|
|
505
|
+
className="text-left text-[12.5px] leading-snug text-white/80 underline-offset-4 hover:text-[#d6ff3f] hover:underline"
|
|
506
|
+
>
|
|
507
|
+
↳ {p}
|
|
508
|
+
</button>
|
|
509
|
+
))}
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
)}
|
|
513
|
+
</div>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ── message render ────────────────────────────────────────────────────────
|
|
518
|
+
|
|
519
|
+
function MessageView({ msg }: { msg: ChatMessage }): ReactNode {
|
|
520
|
+
if (msg.role === 'user') {
|
|
521
|
+
return (
|
|
522
|
+
<div className="flex flex-col items-end">
|
|
523
|
+
<div className="max-w-[85%] rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 text-[13.5px] leading-snug">
|
|
524
|
+
{msg.text}
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
// The auto-staged BCF bundle reuses ONE fileId across many tool calls
|
|
530
|
+
// (topic_create → topic_update → viewpoint_create → bcf_export all bump
|
|
531
|
+
// the same blob). Render the inline `Get .bcfzip` pill only on the LAST
|
|
532
|
+
// call that produced each fileId, otherwise we get a wall of duplicate
|
|
533
|
+
// download buttons that all point at the same artifact.
|
|
534
|
+
const lastDownloadIdx = new Map<string, number>();
|
|
535
|
+
msg.toolCalls?.forEach((tc, i) => {
|
|
536
|
+
const fid = tc.result?.download?.fileId;
|
|
537
|
+
if (fid) lastDownloadIdx.set(fid, i);
|
|
538
|
+
});
|
|
539
|
+
return (
|
|
540
|
+
<div className="flex flex-col items-start gap-2">
|
|
541
|
+
{msg.toolCalls && msg.toolCalls.length > 0 && (
|
|
542
|
+
<ul className="flex w-full flex-col gap-1.5">
|
|
543
|
+
{msg.toolCalls.map((tc, i) => {
|
|
544
|
+
const fid = tc.result?.download?.fileId;
|
|
545
|
+
const showDownload = !fid || lastDownloadIdx.get(fid) === i;
|
|
546
|
+
return (
|
|
547
|
+
<li key={tc.id}>
|
|
548
|
+
<ToolCallView call={tc} showDownload={showDownload} />
|
|
549
|
+
</li>
|
|
550
|
+
);
|
|
551
|
+
})}
|
|
552
|
+
</ul>
|
|
553
|
+
)}
|
|
554
|
+
{msg.text && (
|
|
555
|
+
<div className="prose-playground max-w-[95%] text-[13.5px] leading-relaxed text-white/95">
|
|
556
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
|
|
557
|
+
{msg.text}
|
|
558
|
+
</ReactMarkdown>
|
|
559
|
+
{msg.pending && <span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-[#d6ff3f]" />}
|
|
560
|
+
</div>
|
|
561
|
+
)}
|
|
562
|
+
{msg.pending && !msg.text && msg.toolCalls && msg.toolCalls.length > 0 && (
|
|
563
|
+
<div className="text-[11px] text-white/40">… composing answer …</div>
|
|
564
|
+
)}
|
|
565
|
+
{msg.pending && !msg.text && (!msg.toolCalls || msg.toolCalls.length === 0) && (
|
|
566
|
+
<div className="text-[11px] text-white/40">… thinking …</div>
|
|
567
|
+
)}
|
|
568
|
+
</div>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function ToolCallView({ call, showDownload = true }: { call: ChatToolCall; showDownload?: boolean }): ReactNode {
|
|
573
|
+
const [open, setOpen] = useState(false);
|
|
574
|
+
const isErr = call.result?.isError;
|
|
575
|
+
const ms = call.finishedAt ? call.finishedAt - call.startedAt : null;
|
|
576
|
+
const download = showDownload ? call.result?.download : undefined;
|
|
577
|
+
return (
|
|
578
|
+
<div
|
|
579
|
+
className={cn(
|
|
580
|
+
'rounded-md border text-[12.5px]',
|
|
581
|
+
isErr ? 'border-red-500/40 bg-red-500/[0.04]' : 'border-white/10 bg-white/[0.025]',
|
|
582
|
+
)}
|
|
583
|
+
>
|
|
584
|
+
<button
|
|
585
|
+
onClick={() => setOpen((o) => !o)}
|
|
586
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-left"
|
|
587
|
+
>
|
|
588
|
+
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
589
|
+
<Wrench size={12} className={isErr ? 'text-red-400' : 'text-[#d6ff3f]'} />
|
|
590
|
+
<code className="font-mono text-[12px]" style={{ fontFamily: '"JetBrains Mono", monospace' }}>
|
|
591
|
+
{call.name}
|
|
592
|
+
</code>
|
|
593
|
+
<span className="ml-auto inline-flex items-center gap-2 text-[10.5px] text-white/40">
|
|
594
|
+
{call.result == null ? (
|
|
595
|
+
<Loader2 size={10} className="animate-spin" />
|
|
596
|
+
) : (
|
|
597
|
+
<>
|
|
598
|
+
{ms != null && <span style={{ fontFamily: '"JetBrains Mono", monospace' }}>{ms} ms</span>}
|
|
599
|
+
<span
|
|
600
|
+
className={cn(
|
|
601
|
+
'rounded px-1 py-px uppercase tracking-[0.15em]',
|
|
602
|
+
isErr ? 'bg-red-500/15 text-red-300' : 'bg-[#d6ff3f]/15 text-[#d6ff3f]',
|
|
603
|
+
)}
|
|
604
|
+
style={{ fontFamily: '"JetBrains Mono", monospace' }}
|
|
605
|
+
>
|
|
606
|
+
{isErr ? call.result.errorCode ?? 'error' : 'ok'}
|
|
607
|
+
</span>
|
|
608
|
+
</>
|
|
609
|
+
)}
|
|
610
|
+
</span>
|
|
611
|
+
</button>
|
|
612
|
+
{/* Inline download offer — present whenever the tool produced an
|
|
613
|
+
artifact. Lives between the header and the collapsible details so
|
|
614
|
+
it's never hidden behind a "click to expand" gesture. The
|
|
615
|
+
download is opt-in: it ONLY fires when this button is clicked. */}
|
|
616
|
+
{download && <InlineDownload download={download} />}
|
|
617
|
+
{open && (
|
|
618
|
+
<div className="border-t border-white/10 px-3 py-2.5">
|
|
619
|
+
{Object.keys(call.args).length > 0 && (
|
|
620
|
+
<>
|
|
621
|
+
<div className="mb-1 text-[10px] uppercase tracking-[0.22em] text-white/40" style={{ fontFamily: '"JetBrains Mono", monospace' }}>
|
|
622
|
+
args
|
|
623
|
+
</div>
|
|
624
|
+
<pre
|
|
625
|
+
className="mb-3 overflow-x-auto rounded bg-black/40 p-2 text-[11px]"
|
|
626
|
+
style={{ fontFamily: '"JetBrains Mono", monospace' }}
|
|
627
|
+
>
|
|
628
|
+
{JSON.stringify(call.args, null, 2)}
|
|
629
|
+
</pre>
|
|
630
|
+
</>
|
|
631
|
+
)}
|
|
632
|
+
{call.result && (
|
|
633
|
+
<>
|
|
634
|
+
<div className="mb-1 text-[10px] uppercase tracking-[0.22em] text-white/40" style={{ fontFamily: '"JetBrains Mono", monospace' }}>
|
|
635
|
+
result
|
|
636
|
+
</div>
|
|
637
|
+
<pre
|
|
638
|
+
className={cn(
|
|
639
|
+
'overflow-x-auto whitespace-pre-wrap rounded p-2 text-[11px] leading-snug',
|
|
640
|
+
isErr ? 'bg-red-500/10 text-red-200' : 'bg-black/40 text-white/85',
|
|
641
|
+
)}
|
|
642
|
+
style={{ fontFamily: '"JetBrains Mono", monospace' }}
|
|
643
|
+
>
|
|
644
|
+
{call.result.text}
|
|
645
|
+
</pre>
|
|
646
|
+
{call.result.hint && (
|
|
647
|
+
<p className="mt-1.5 text-[10.5px] italic text-white/50">{call.result.hint}</p>
|
|
648
|
+
)}
|
|
649
|
+
</>
|
|
650
|
+
)}
|
|
651
|
+
</div>
|
|
652
|
+
)}
|
|
653
|
+
</div>
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ── conversation runner ───────────────────────────────────────────────────
|
|
658
|
+
|
|
659
|
+
interface AnthropicTextBlock {
|
|
660
|
+
type: 'text';
|
|
661
|
+
text: string;
|
|
662
|
+
}
|
|
663
|
+
interface AnthropicToolUseBlock {
|
|
664
|
+
type: 'tool_use';
|
|
665
|
+
id: string;
|
|
666
|
+
name: string;
|
|
667
|
+
input: Record<string, unknown>;
|
|
668
|
+
}
|
|
669
|
+
type AnthropicAssistantBlock = AnthropicTextBlock | AnthropicToolUseBlock;
|
|
670
|
+
|
|
671
|
+
interface AnthropicToolResultBlock {
|
|
672
|
+
type: 'tool_result';
|
|
673
|
+
tool_use_id: string;
|
|
674
|
+
content: string;
|
|
675
|
+
is_error?: boolean;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
interface RunOpts {
|
|
679
|
+
apiKey: string;
|
|
680
|
+
tools: AnthropicToolDef[];
|
|
681
|
+
history: ChatMessage[];
|
|
682
|
+
model: LoadedPlaygroundModel;
|
|
683
|
+
assistantId: string;
|
|
684
|
+
getDispatchContext: () => DispatchContext;
|
|
685
|
+
onUpdate: (patch: Partial<ChatMessage>) => void;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
type ApiMessage =
|
|
689
|
+
| { role: 'user'; content: string | Array<AnthropicToolResultBlock | { type: 'text'; text: string }> }
|
|
690
|
+
| { role: 'assistant'; content: AnthropicAssistantBlock[] };
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Rebuild the Anthropic message list from React state.
|
|
694
|
+
*
|
|
695
|
+
* Anthropic's hard contract: every `tool_use` block in an assistant message
|
|
696
|
+
* MUST be followed by a user message whose first blocks are matching
|
|
697
|
+
* `tool_result`s. The naive shape (assistant turn → standalone user
|
|
698
|
+
* tool_result message → standalone user text message) breaks that contract
|
|
699
|
+
* the moment the user asks a follow-up after a tool round, because the
|
|
700
|
+
* NEW user text lands AFTER the tool_result, separating it from the
|
|
701
|
+
* tool_use by an extra turn (and yielding a double-user pair Anthropic
|
|
702
|
+
* also rejects).
|
|
703
|
+
*
|
|
704
|
+
* Fix: merge an assistant turn's pending tool_results into the very next
|
|
705
|
+
* user turn as combined blocks (`[tool_result_*…, { type:'text', text }]`).
|
|
706
|
+
* If no next user turn exists yet (mid-conversation, agent still wrapping
|
|
707
|
+
* up), fall through to a standalone tool_result user message — that is
|
|
708
|
+
* the in-loop intermediate shape and Anthropic accepts it.
|
|
709
|
+
*/
|
|
710
|
+
function buildApiMessages(history: ChatMessage[]): ApiMessage[] {
|
|
711
|
+
const out: ApiMessage[] = [];
|
|
712
|
+
let i = 0;
|
|
713
|
+
while (i < history.length) {
|
|
714
|
+
const m = history[i];
|
|
715
|
+
if (m.role === 'user') {
|
|
716
|
+
out.push({ role: 'user', content: m.text });
|
|
717
|
+
i += 1;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// role === 'assistant'
|
|
722
|
+
//
|
|
723
|
+
// CRITICAL block ordering: Anthropic's tool-use protocol expects
|
|
724
|
+
// `text` (and `thinking`) blocks BEFORE `tool_use` blocks within an
|
|
725
|
+
// assistant turn. If a `text` block follows a `tool_use` block in the
|
|
726
|
+
// same turn, the API treats the tool_use as cancelled by the model's
|
|
727
|
+
// subsequent reasoning and rejects the next user message's
|
|
728
|
+
// tool_result with "tool_use ids were found without tool_result
|
|
729
|
+
// blocks immediately after". We were building [tool_use, text] which
|
|
730
|
+
// tripped exactly that check; the docs example sequences as
|
|
731
|
+
// [text, tool_use_1, tool_use_2, …]. Mirror that here.
|
|
732
|
+
const blocks: AnthropicAssistantBlock[] = [];
|
|
733
|
+
if (m.text) blocks.push({ type: 'text', text: m.text });
|
|
734
|
+
if (m.toolCalls) {
|
|
735
|
+
for (const tc of m.toolCalls) {
|
|
736
|
+
blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.args });
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
if (blocks.length > 0) out.push({ role: 'assistant', content: blocks });
|
|
740
|
+
|
|
741
|
+
// Anthropic requires every tool_use to be paired with a tool_result in
|
|
742
|
+
// the next user message. Synthesise a stub for any that finished
|
|
743
|
+
// without a recorded result (HMR cleared state, dispatch interrupted,
|
|
744
|
+
// etc.) so we never emit a malformed sequence.
|
|
745
|
+
const results: AnthropicToolResultBlock[] = (m.toolCalls ?? []).map((tc) => ({
|
|
746
|
+
type: 'tool_result',
|
|
747
|
+
tool_use_id: tc.id,
|
|
748
|
+
content: tc.result?.text ?? '(tool call did not complete in this session — ignore and continue)',
|
|
749
|
+
is_error: tc.result?.isError ?? true,
|
|
750
|
+
}));
|
|
751
|
+
|
|
752
|
+
if (results.length === 0) {
|
|
753
|
+
i += 1;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Try to fold the next user turn's text into the same user message
|
|
758
|
+
// so we never create a double-user pair (which Anthropic also rejects).
|
|
759
|
+
const next = history[i + 1];
|
|
760
|
+
if (next && next.role === 'user') {
|
|
761
|
+
out.push({
|
|
762
|
+
role: 'user',
|
|
763
|
+
content: [...results, { type: 'text', text: next.text }],
|
|
764
|
+
});
|
|
765
|
+
i += 2;
|
|
766
|
+
} else {
|
|
767
|
+
out.push({ role: 'user', content: results });
|
|
768
|
+
i += 1;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return out;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Walk the apiMessages list and confirm every `tool_use` block has a
|
|
776
|
+
* matching `tool_result` block in the immediately-following user message.
|
|
777
|
+
* Throws (caught by runConversation) if not. This exists so we fail in
|
|
778
|
+
* code we can fix instead of mysteriously erroring at Anthropic.
|
|
779
|
+
*/
|
|
780
|
+
function assertToolUseShape(messages: ApiMessage[]): void {
|
|
781
|
+
for (let i = 0; i < messages.length; i++) {
|
|
782
|
+
const m = messages[i];
|
|
783
|
+
if (m.role !== 'assistant') continue;
|
|
784
|
+
const toolUseIds: string[] = [];
|
|
785
|
+
for (const block of m.content) {
|
|
786
|
+
if (typeof block === 'object' && block.type === 'tool_use') toolUseIds.push((block as { id: string }).id);
|
|
787
|
+
}
|
|
788
|
+
if (toolUseIds.length === 0) continue;
|
|
789
|
+
const next = messages[i + 1];
|
|
790
|
+
if (!next || next.role !== 'user' || typeof next.content === 'string') {
|
|
791
|
+
throw new Error(`assistant turn ${i} has tool_use but next turn isn't a user-with-blocks message`);
|
|
792
|
+
}
|
|
793
|
+
const resultIds = new Set<string>();
|
|
794
|
+
for (const block of next.content) {
|
|
795
|
+
if (typeof block === 'object' && block.type === 'tool_result') {
|
|
796
|
+
resultIds.add((block as { tool_use_id: string }).tool_use_id);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const missing = toolUseIds.filter((id) => !resultIds.has(id));
|
|
800
|
+
if (missing.length > 0) {
|
|
801
|
+
throw new Error(`tool_use without matching tool_result at turn ${i}: ${missing.join(', ')}`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function runConversation(opts: RunOpts): Promise<void> {
|
|
807
|
+
const client = new Anthropic({ apiKey: opts.apiKey, dangerouslyAllowBrowser: true });
|
|
808
|
+
const apiMessages = buildApiMessages(opts.history);
|
|
809
|
+
|
|
810
|
+
// Compact, opt-in diagnostic logging. The previous version dumped the
|
|
811
|
+
// full apiMessages payload (including raw user prompts, attachment text,
|
|
812
|
+
// tool args, and tool results) on every request — fine for development,
|
|
813
|
+
// but a privacy footgun for a BYOK feature where users own the API key
|
|
814
|
+
// and don't expect their conversation to land in browser devtools.
|
|
815
|
+
// Anything heavier than the one-line summary is now gated behind a
|
|
816
|
+
// localStorage flag the user has to set explicitly.
|
|
817
|
+
const wantsVerbose = (() => {
|
|
818
|
+
try {
|
|
819
|
+
return typeof window !== 'undefined' && window.localStorage?.getItem('ifclite-playground-debug') === '1';
|
|
820
|
+
} catch {
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
})();
|
|
824
|
+
// eslint-disable-next-line no-console
|
|
825
|
+
console.debug(`[playground-chat] → ${apiMessages.length} messages`);
|
|
826
|
+
if (wantsVerbose) {
|
|
827
|
+
// eslint-disable-next-line no-console
|
|
828
|
+
console.groupCollapsed('[playground-chat] full payload (debug)');
|
|
829
|
+
// eslint-disable-next-line no-console
|
|
830
|
+
console.log(JSON.parse(JSON.stringify(apiMessages)));
|
|
831
|
+
// eslint-disable-next-line no-console
|
|
832
|
+
console.groupEnd();
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
try {
|
|
836
|
+
assertToolUseShape(apiMessages);
|
|
837
|
+
} catch (err) {
|
|
838
|
+
// eslint-disable-next-line no-console
|
|
839
|
+
console.error('[playground-chat] FAILED tool-use shape assertion before sending:', err);
|
|
840
|
+
throw err;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const accumulated: { text: string; toolCalls: ChatToolCall[] } = { text: '', toolCalls: [] };
|
|
844
|
+
let toolCallCount = 0;
|
|
845
|
+
|
|
846
|
+
// Loop: each iteration is a single Anthropic round-trip. Stops when the
|
|
847
|
+
// assistant finishes without requesting more tools, or when we hit the cap.
|
|
848
|
+
while (true) {
|
|
849
|
+
const res = await client.messages.create({
|
|
850
|
+
model: DEFAULT_MODEL,
|
|
851
|
+
max_tokens: MAX_TOKENS,
|
|
852
|
+
system: SYSTEM_PROMPT,
|
|
853
|
+
tools: opts.tools as unknown as Parameters<typeof client.messages.create>[0]['tools'],
|
|
854
|
+
messages: apiMessages as Parameters<typeof client.messages.create>[0]['messages'],
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
const blocks = res.content as AnthropicAssistantBlock[];
|
|
858
|
+
const newToolCalls: ChatToolCall[] = [];
|
|
859
|
+
let newText = '';
|
|
860
|
+
for (const block of blocks) {
|
|
861
|
+
if (block.type === 'text') newText += block.text;
|
|
862
|
+
else if (block.type === 'tool_use') {
|
|
863
|
+
newToolCalls.push({
|
|
864
|
+
id: block.id,
|
|
865
|
+
name: block.name,
|
|
866
|
+
args: block.input ?? {},
|
|
867
|
+
startedAt: Date.now(),
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (newText) accumulated.text += (accumulated.text ? '\n' : '') + newText;
|
|
873
|
+
if (newToolCalls.length > 0) accumulated.toolCalls.push(...newToolCalls);
|
|
874
|
+
opts.onUpdate({ text: accumulated.text, toolCalls: accumulated.toolCalls });
|
|
875
|
+
|
|
876
|
+
// Push the assistant turn into the rolling history regardless of whether
|
|
877
|
+
// there are more tools — Anthropic requires the full assistant block list.
|
|
878
|
+
apiMessages.push({ role: 'assistant', content: blocks });
|
|
879
|
+
|
|
880
|
+
if (newToolCalls.length === 0 || res.stop_reason !== 'tool_use') {
|
|
881
|
+
// Done.
|
|
882
|
+
opts.onUpdate({ text: accumulated.text, toolCalls: accumulated.toolCalls, pending: false });
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (toolCallCount + newToolCalls.length > MAX_TOOL_CALLS) {
|
|
887
|
+
// Cap reached — synthesise an error tool_result so Claude can wrap up.
|
|
888
|
+
const cap: AnthropicToolResultBlock[] = newToolCalls.map((tc) => ({
|
|
889
|
+
type: 'tool_result',
|
|
890
|
+
tool_use_id: tc.id,
|
|
891
|
+
content: `Stopped: this playground caps at ${MAX_TOOL_CALLS} tool calls per request. Summarise what you have so far.`,
|
|
892
|
+
is_error: true,
|
|
893
|
+
}));
|
|
894
|
+
apiMessages.push({ role: 'user', content: cap });
|
|
895
|
+
for (const tc of newToolCalls) {
|
|
896
|
+
tc.result = {
|
|
897
|
+
text: `Stopped at cap (${MAX_TOOL_CALLS}).`,
|
|
898
|
+
structured: null,
|
|
899
|
+
isError: true,
|
|
900
|
+
errorCode: 'CAP_REACHED',
|
|
901
|
+
};
|
|
902
|
+
tc.finishedAt = Date.now();
|
|
903
|
+
}
|
|
904
|
+
opts.onUpdate({ toolCalls: accumulated.toolCalls });
|
|
905
|
+
// Continue once more so Claude can produce a final text answer.
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Run each tool, build tool_result blocks. Re-pull the dispatch context
|
|
910
|
+
// every iteration so the viewer controller stays fresh if the user
|
|
911
|
+
// mounts/unmounts the panel mid-conversation.
|
|
912
|
+
const results: AnthropicToolResultBlock[] = [];
|
|
913
|
+
for (const tc of newToolCalls) {
|
|
914
|
+
const ctx = opts.getDispatchContext();
|
|
915
|
+
const dispatched = await dispatch(opts.model, tc.name, tc.args, ctx);
|
|
916
|
+
tc.result = dispatched;
|
|
917
|
+
tc.finishedAt = Date.now();
|
|
918
|
+
results.push({
|
|
919
|
+
type: 'tool_result',
|
|
920
|
+
tool_use_id: tc.id,
|
|
921
|
+
content: dispatched.text,
|
|
922
|
+
is_error: dispatched.isError,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
toolCallCount += newToolCalls.length;
|
|
926
|
+
opts.onUpdate({ toolCalls: accumulated.toolCalls });
|
|
927
|
+
apiMessages.push({ role: 'user', content: results });
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function rid(): string {
|
|
932
|
+
return Math.random().toString(36).slice(2, 11);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* InlineDownload — chartreuse "Get .bcf" / "Save IFC" pill that surfaces
|
|
937
|
+
* directly under the tool-call header whenever a tool produced a file. The
|
|
938
|
+
* file is already in the playgroundFiles store (so the sidebar Downloads
|
|
939
|
+
* panel mirrors it); this is the discoverable, in-context offer right next
|
|
940
|
+
* to the action that produced it.
|
|
941
|
+
*
|
|
942
|
+
* UX rules baked in:
|
|
943
|
+
* • The artifact NEVER auto-downloads. The user has to click.
|
|
944
|
+
* • After a click, the button shows "Saved" for ~2s so the user has
|
|
945
|
+
* visible feedback without losing the affordance to re-download.
|
|
946
|
+
* • Re-clicking re-runs the download — useful if the user closed the
|
|
947
|
+
* prompt by accident.
|
|
948
|
+
*/
|
|
949
|
+
function InlineDownload({
|
|
950
|
+
download,
|
|
951
|
+
}: {
|
|
952
|
+
download: NonNullable<ToolDispatchResult['download']>;
|
|
953
|
+
}): ReactNode {
|
|
954
|
+
const [savedAt, setSavedAt] = useState<number | null>(null);
|
|
955
|
+
const justSaved = savedAt != null && Date.now() - savedAt < 2000;
|
|
956
|
+
useEffect(() => {
|
|
957
|
+
if (savedAt == null) return;
|
|
958
|
+
const t = setTimeout(() => setSavedAt(null), 2000);
|
|
959
|
+
return () => clearTimeout(t);
|
|
960
|
+
}, [savedAt]);
|
|
961
|
+
|
|
962
|
+
return (
|
|
963
|
+
<div className="border-t border-white/5 px-3 py-2">
|
|
964
|
+
<button
|
|
965
|
+
onClick={() => {
|
|
966
|
+
playgroundFiles.download(download.fileId);
|
|
967
|
+
setSavedAt(Date.now());
|
|
968
|
+
}}
|
|
969
|
+
className={cn(
|
|
970
|
+
'group flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left transition-colors',
|
|
971
|
+
justSaved ? 'bg-white/10' : 'bg-[#d6ff3f] hover:bg-[#e6ff66]',
|
|
972
|
+
)}
|
|
973
|
+
style={justSaved ? { color: '#d6ff3f' } : { color: '#0a0a0c' }}
|
|
974
|
+
>
|
|
975
|
+
<span className="flex min-w-0 items-center gap-2.5">
|
|
976
|
+
<span
|
|
977
|
+
className={cn(
|
|
978
|
+
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded',
|
|
979
|
+
justSaved ? 'bg-white/10' : 'bg-black/10',
|
|
980
|
+
)}
|
|
981
|
+
>
|
|
982
|
+
{justSaved ? <Check size={14} strokeWidth={2.6} /> : <Download size={14} strokeWidth={2.4} />}
|
|
983
|
+
</span>
|
|
984
|
+
<span className="flex min-w-0 flex-col leading-tight">
|
|
985
|
+
<span
|
|
986
|
+
className="truncate text-[13px] font-semibold"
|
|
987
|
+
style={{ fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' }}
|
|
988
|
+
>
|
|
989
|
+
{justSaved ? 'Saved — click again to re-download' : download.label}
|
|
990
|
+
</span>
|
|
991
|
+
<span
|
|
992
|
+
className={cn('truncate text-[10.5px]', justSaved ? 'text-white/50' : 'text-black/55')}
|
|
993
|
+
style={{ fontFamily: '"JetBrains Mono", monospace' }}
|
|
994
|
+
>
|
|
995
|
+
{download.filename} · {formatFileBytes(download.size)}
|
|
996
|
+
</span>
|
|
997
|
+
</span>
|
|
998
|
+
</span>
|
|
999
|
+
</button>
|
|
1000
|
+
</div>
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// ── markdown rendering ────────────────────────────────────────────────────
|
|
1005
|
+
//
|
|
1006
|
+
// Tailwind utility set scoped to the chat. Headings stay quiet (the chat
|
|
1007
|
+
// already structures the conversation). Tables get hairline borders.
|
|
1008
|
+
// Inline code + code blocks pick up JetBrains Mono so they read like the
|
|
1009
|
+
// tool-call cards.
|
|
1010
|
+
|
|
1011
|
+
const MARKDOWN_COMPONENTS = {
|
|
1012
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1013
|
+
h1: (props: any) => <h3 className="mt-3 mb-1 text-[16px] font-semibold" {...props} />,
|
|
1014
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1015
|
+
h2: (props: any) => <h4 className="mt-3 mb-1 text-[14.5px] font-semibold" {...props} />,
|
|
1016
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1017
|
+
h3: (props: any) => <h5 className="mt-2 mb-1 text-[13.5px] font-semibold" {...props} />,
|
|
1018
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1019
|
+
p: (props: any) => <p className="my-1.5 first:mt-0 last:mb-0" {...props} />,
|
|
1020
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1021
|
+
ul: (props: any) => <ul className="my-1.5 ml-4 list-disc space-y-0.5" {...props} />,
|
|
1022
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1023
|
+
ol: (props: any) => <ol className="my-1.5 ml-4 list-decimal space-y-0.5" {...props} />,
|
|
1024
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1025
|
+
li: (props: any) => <li className="leading-snug" {...props} />,
|
|
1026
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1027
|
+
strong: (props: any) => <strong className="font-semibold text-white" {...props} />,
|
|
1028
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1029
|
+
em: (props: any) => <em className="italic text-white/95" {...props} />,
|
|
1030
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1031
|
+
a: (props: any) => (
|
|
1032
|
+
<a className="text-[#d6ff3f] underline-offset-2 hover:underline" target="_blank" rel="noreferrer" {...props} />
|
|
1033
|
+
),
|
|
1034
|
+
// react-markdown v9 dropped the legacy `inline` prop, so we infer block
|
|
1035
|
+
// vs inline from className (fenced blocks get `language-…`) and content
|
|
1036
|
+
// (block code carries trailing newlines). When it's a block we render
|
|
1037
|
+
// a raw <code> here and let the `pre` override below own the wrapper —
|
|
1038
|
+
// doing the wrapping ourselves used to nest <pre> inside the <p>
|
|
1039
|
+
// react-markdown emits for surrounding text, tripping React's DOM
|
|
1040
|
+
// nesting validator.
|
|
1041
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1042
|
+
code: ({ className, children, ...props }: any) => {
|
|
1043
|
+
const cls = typeof className === 'string' ? className : '';
|
|
1044
|
+
const text = Array.isArray(children) ? children.join('') : String(children ?? '');
|
|
1045
|
+
const isBlock = cls.startsWith('language-') || /\n/.test(text);
|
|
1046
|
+
if (isBlock) {
|
|
1047
|
+
return (
|
|
1048
|
+
<code className={cn(cls, 'block')} style={{ fontFamily: '"JetBrains Mono", monospace' }} {...props}>
|
|
1049
|
+
{children}
|
|
1050
|
+
</code>
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
return (
|
|
1054
|
+
<code
|
|
1055
|
+
className={cn(
|
|
1056
|
+
'rounded bg-white/10 px-1 py-0.5 text-[12px] text-[#d6ff3f]',
|
|
1057
|
+
cls,
|
|
1058
|
+
)}
|
|
1059
|
+
style={{ fontFamily: '"JetBrains Mono", monospace' }}
|
|
1060
|
+
{...props}
|
|
1061
|
+
>
|
|
1062
|
+
{children}
|
|
1063
|
+
</code>
|
|
1064
|
+
);
|
|
1065
|
+
},
|
|
1066
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1067
|
+
pre: (props: any) => (
|
|
1068
|
+
<pre className="my-2 overflow-x-auto rounded bg-black/40 p-3 text-[12px] leading-snug" style={{ fontFamily: '"JetBrains Mono", monospace' }} {...props} />
|
|
1069
|
+
),
|
|
1070
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1071
|
+
table: (props: any) => (
|
|
1072
|
+
<div className="my-2 overflow-x-auto rounded border border-white/10">
|
|
1073
|
+
<table className="w-full border-collapse text-[12.5px]" {...props} />
|
|
1074
|
+
</div>
|
|
1075
|
+
),
|
|
1076
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1077
|
+
thead: (props: any) => <thead className="bg-white/[0.04]" {...props} />,
|
|
1078
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1079
|
+
th: (props: any) => (
|
|
1080
|
+
<th
|
|
1081
|
+
className="border-b border-white/10 px-2.5 py-1.5 text-left text-[10.5px] uppercase tracking-[0.18em] text-white/70"
|
|
1082
|
+
style={{ fontFamily: '"JetBrains Mono", monospace' }}
|
|
1083
|
+
{...props}
|
|
1084
|
+
/>
|
|
1085
|
+
),
|
|
1086
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1087
|
+
td: (props: any) => <td className="border-b border-white/5 px-2.5 py-1.5 align-top" {...props} />,
|
|
1088
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1089
|
+
blockquote: (props: any) => (
|
|
1090
|
+
<blockquote className="my-2 border-l-2 border-white/20 pl-3 italic text-white/70" {...props} />
|
|
1091
|
+
),
|
|
1092
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1093
|
+
hr: (props: any) => <hr className="my-3 border-white/10" {...props} />,
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
// Tiny re-export so the playground page can show an empty-state CTA.
|
|
1097
|
+
export { RefreshCcw };
|