@arronqzy/react-view 1.0.5 → 1.0.7
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/dist/styles.css +519 -9
- package/package.json +12 -4
- package/src/core/template.tsx +12 -2
- package/src/panel/ReactViewOnlinePreview.tsx +18 -8
- package/src/panel/ReactViewPanel.tsx +386 -170
- package/src/panel/ai/AssistantChatPanel.tsx +895 -0
- package/src/panel/ai/AssistantMarkdown.tsx +256 -0
- package/src/panel/ai/applyAssistantAction.ts +652 -0
- package/src/panel/ai/revealConfigField.ts +137 -0
- package/src/panel/components/ConfigHintIcon.tsx +6 -2
- package/src/panel/components/ElementsLayer.tsx +37 -18
- package/src/panel/components/MaterialSidebar.tsx +157 -109
- package/src/panel/components/MoveableLayer.tsx +7 -6
- package/src/panel/components/PanelConfigSidebar.tsx +434 -389
- package/src/panel/components/ViewElementScopePanel.tsx +9 -5
- package/src/panel/components/WorkspaceConfigSidebar.tsx +5 -2
- package/src/panel/components/WorkspaceProjectNav.tsx +25 -14
- package/src/panel/components/WorkspaceStageSplit.tsx +3 -1
- package/src/panel/components/scope-config/ScopeTemplatePreviewHost.tsx +11 -7
- package/src/panel/components/scope-config/ScopeTemplateUsageHint.tsx +6 -18
- package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.tsx +5 -2
- package/src/panel/components/table/ConditionEditor.tsx +205 -0
- package/src/panel/components/table/JsonCodeEditor.tsx +115 -0
- package/src/panel/components/table/PanelConfigTableSection.tsx +2192 -0
- package/src/panel/components/table/TableNodeContent.tsx +544 -0
- package/src/panel/constants/messages.ts +76 -34
- package/src/panel/hooks/usePanelElements.ts +64 -52
- package/src/panel/hooks/useWorkspaceProjects.ts +11 -8
- package/src/panel/types.ts +4 -0
- package/src/panel/utils/panelElementDefaults.ts +69 -39
- package/src/panel/utils/scope-field-labels.ts +51 -38
- package/src/panel/utils/scope-template-preview.ts +10 -6
- package/src/panel/utils/scope-template-spread.ts +44 -21
- package/src/panel/utils/scope-template-warnings.ts +28 -16
- package/src/panel/utils/scope-template.ts +82 -2
- package/src/tailwind.css +17 -9
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
import { useI18n } from "@arronqzy/i18n/react";
|
|
2
|
+
import {
|
|
3
|
+
ASSISTANT_MODEL_OPTIONS,
|
|
4
|
+
DEFAULT_MODEL_ID,
|
|
5
|
+
WebLLMAssistantRuntime,
|
|
6
|
+
buildSystemPrompt,
|
|
7
|
+
isWebGPUAvailable,
|
|
8
|
+
looksLikePureQuestion,
|
|
9
|
+
parseAssistantAction,
|
|
10
|
+
inferPanelAddFromUserText,
|
|
11
|
+
runAgentLoop,
|
|
12
|
+
buildEditorContext,
|
|
13
|
+
tryLocalFaqReply,
|
|
14
|
+
buildForceReplyPrompt,
|
|
15
|
+
buildPriorChatMessages,
|
|
16
|
+
buildRecentDialogSummary,
|
|
17
|
+
looksLikeUnclearIntent,
|
|
18
|
+
UNCLEAR_INTENT_REPLY,
|
|
19
|
+
inferLocalEditorPlan,
|
|
20
|
+
type ChatMessage,
|
|
21
|
+
type CompactBlueprintEdge,
|
|
22
|
+
type CompactBlueprintNode,
|
|
23
|
+
type CompactPanelElement,
|
|
24
|
+
type InitProgress,
|
|
25
|
+
} from "@arronqzy/webllm-assistant";
|
|
26
|
+
import type { BlueprintGraph } from "@arronqzy/react-blueprint";
|
|
27
|
+
import {
|
|
28
|
+
Button,
|
|
29
|
+
Dialog,
|
|
30
|
+
DialogClose,
|
|
31
|
+
DialogContent,
|
|
32
|
+
DialogDescription,
|
|
33
|
+
DialogHeader,
|
|
34
|
+
DialogTitle,
|
|
35
|
+
Select,
|
|
36
|
+
SelectContent,
|
|
37
|
+
SelectItem,
|
|
38
|
+
SelectTrigger,
|
|
39
|
+
SelectValue,
|
|
40
|
+
Textarea,
|
|
41
|
+
} from "@arronqzy/ui";
|
|
42
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
43
|
+
import {
|
|
44
|
+
applyAssistantAction,
|
|
45
|
+
type ApplyAssistantDeps,
|
|
46
|
+
} from "./applyAssistantAction";
|
|
47
|
+
import { AssistantMarkdown } from "./AssistantMarkdown";
|
|
48
|
+
|
|
49
|
+
type UiMessage = {
|
|
50
|
+
id: string;
|
|
51
|
+
role: "user" | "assistant" | "system";
|
|
52
|
+
content: string;
|
|
53
|
+
/** Collapsed agent step trail (optional). */
|
|
54
|
+
steps?: string[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type AssistantChatPanelProps = {
|
|
58
|
+
deps: ApplyAssistantDeps;
|
|
59
|
+
open: boolean;
|
|
60
|
+
onOpenChange: (open: boolean) => void;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const QUICK_PROMPTS = [
|
|
64
|
+
"panel.ai.quickWhatComponents",
|
|
65
|
+
"panel.ai.quickAddTable",
|
|
66
|
+
] as const;
|
|
67
|
+
|
|
68
|
+
const RECENT_MESSAGE_COUNT = 8;
|
|
69
|
+
|
|
70
|
+
function StatusDot({
|
|
71
|
+
tone,
|
|
72
|
+
}: {
|
|
73
|
+
tone: "ready" | "loading" | "idle" | "error";
|
|
74
|
+
}) {
|
|
75
|
+
const color =
|
|
76
|
+
tone === "ready"
|
|
77
|
+
? "bg-emerald-500"
|
|
78
|
+
: tone === "loading"
|
|
79
|
+
? "bg-amber-400 animate-pulse"
|
|
80
|
+
: tone === "error"
|
|
81
|
+
? "bg-destructive"
|
|
82
|
+
: "bg-muted-foreground/40";
|
|
83
|
+
return <span className={`inline-block h-1.5 w-1.5 shrink-0 rounded-full ${color}`} />;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function AiAvatar({ className = "" }: { className?: string }) {
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[11px] font-semibold text-foreground ${className}`}
|
|
90
|
+
aria-hidden
|
|
91
|
+
>
|
|
92
|
+
AI
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function UserAvatar({ className = "" }: { className?: string }) {
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold ${className}`}
|
|
101
|
+
style={{
|
|
102
|
+
backgroundColor: "hsl(var(--foreground))",
|
|
103
|
+
color: "hsl(var(--background))",
|
|
104
|
+
}}
|
|
105
|
+
aria-hidden
|
|
106
|
+
>
|
|
107
|
+
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
|
108
|
+
<path d="M20 21a8 8 0 0 0-16 0" />
|
|
109
|
+
<circle cx="12" cy="8" r="4" />
|
|
110
|
+
</svg>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Theme-safe bubble colors (avoid Tailwind palette classes that may be purged). */
|
|
116
|
+
const userBubbleStyle: React.CSSProperties = {
|
|
117
|
+
backgroundColor: "hsl(var(--foreground))",
|
|
118
|
+
color: "hsl(var(--background))",
|
|
119
|
+
borderRadius: "18px",
|
|
120
|
+
overflow: "hidden",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const assistantBubbleStyle: React.CSSProperties = {
|
|
124
|
+
backgroundColor: "hsl(var(--muted))",
|
|
125
|
+
color: "hsl(var(--foreground))",
|
|
126
|
+
borderRadius: "18px",
|
|
127
|
+
overflow: "hidden",
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const messageListStyle: React.CSSProperties = {
|
|
131
|
+
display: "flex",
|
|
132
|
+
flexDirection: "column",
|
|
133
|
+
gap: "20px",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
function toCompactElements(
|
|
137
|
+
elements: ReturnType<ApplyAssistantDeps["listElements"]>,
|
|
138
|
+
selectedIds: string[]
|
|
139
|
+
): CompactPanelElement[] {
|
|
140
|
+
return elements.map((el) => {
|
|
141
|
+
const config: Record<string, unknown> = {};
|
|
142
|
+
if (el.name) config.name = el.name;
|
|
143
|
+
if (el.textHtml) config.textHtml = el.textHtml.slice(0, 160);
|
|
144
|
+
if (el.textColor) config.textColor = el.textColor;
|
|
145
|
+
if (el.textFontSize != null) config.textFontSize = el.textFontSize;
|
|
146
|
+
if (el.style) config.style = el.style;
|
|
147
|
+
if (el.chart) {
|
|
148
|
+
config.chart = {
|
|
149
|
+
title: el.chart.title,
|
|
150
|
+
color: el.chart.color,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (el.table) {
|
|
154
|
+
config.table = {
|
|
155
|
+
source: el.table.source,
|
|
156
|
+
emptyText: el.table.emptyText,
|
|
157
|
+
showHeader: el.table.showHeader,
|
|
158
|
+
stripe: el.table.stripe,
|
|
159
|
+
rowHeight: el.table.rowHeight,
|
|
160
|
+
columns: (el.table.columns ?? []).slice(0, 12).map((c) => ({
|
|
161
|
+
field: c.field,
|
|
162
|
+
title: c.title,
|
|
163
|
+
widget: c.widget,
|
|
164
|
+
width: c.width,
|
|
165
|
+
})),
|
|
166
|
+
rowsText:
|
|
167
|
+
typeof el.table.rowsText === "string"
|
|
168
|
+
? el.table.rowsText.length > 400
|
|
169
|
+
? `${el.table.rowsText.slice(0, 400)}…`
|
|
170
|
+
: el.table.rowsText
|
|
171
|
+
: undefined,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (el.geometryShape) config.geometryShape = el.geometryShape;
|
|
175
|
+
if (el.geometryColor) config.geometryColor = el.geometryColor;
|
|
176
|
+
return {
|
|
177
|
+
id: el.id,
|
|
178
|
+
materialType: el.materialType,
|
|
179
|
+
name: el.name,
|
|
180
|
+
x: el.x,
|
|
181
|
+
y: el.y,
|
|
182
|
+
width: el.width,
|
|
183
|
+
height: el.height,
|
|
184
|
+
textHtml: el.textHtml,
|
|
185
|
+
locked: el.locked,
|
|
186
|
+
zIndex: el.zIndex,
|
|
187
|
+
selected: selectedIds.includes(el.id),
|
|
188
|
+
config: Object.keys(config).length ? config : undefined,
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toCompactBlueprint(graph: BlueprintGraph): {
|
|
194
|
+
nodes: CompactBlueprintNode[];
|
|
195
|
+
edges: CompactBlueprintEdge[];
|
|
196
|
+
} {
|
|
197
|
+
return {
|
|
198
|
+
nodes: graph.document.nodes.map((n) => ({
|
|
199
|
+
id: n.id,
|
|
200
|
+
role: n.role,
|
|
201
|
+
nodeType: n.nodeType,
|
|
202
|
+
label: n.label,
|
|
203
|
+
parentId: n.parentId,
|
|
204
|
+
})),
|
|
205
|
+
edges: graph.document.edges.map((e) => ({
|
|
206
|
+
id: e.id,
|
|
207
|
+
source: e.source,
|
|
208
|
+
target: e.target,
|
|
209
|
+
})),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function looksLikeSimpleAddOnly(text: string): boolean {
|
|
214
|
+
if (!inferPanelAddFromUserText(text)) return false;
|
|
215
|
+
return !/然后|并且|再|保存|选中|改成|设置|宽度|高度|蓝图|图层|预览|主题/.test(
|
|
216
|
+
text
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function formatApplyFail(
|
|
221
|
+
message: string,
|
|
222
|
+
t: (key: string, params?: Record<string, string | number>) => string
|
|
223
|
+
) {
|
|
224
|
+
if (message === "layer_locked_or_add_failed") {
|
|
225
|
+
return t("panel.ai.layerLockedAddFailed");
|
|
226
|
+
}
|
|
227
|
+
if (message.startsWith("unknown_material:")) {
|
|
228
|
+
return t("panel.ai.unknownMaterial", {
|
|
229
|
+
type: message.replace("unknown_material:", ""),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return message;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function AssistantChatPanel({
|
|
236
|
+
deps,
|
|
237
|
+
open,
|
|
238
|
+
onOpenChange,
|
|
239
|
+
}: AssistantChatPanelProps) {
|
|
240
|
+
const { t } = useI18n();
|
|
241
|
+
const runtimeRef = useRef<WebLLMAssistantRuntime | null>(null);
|
|
242
|
+
if (!runtimeRef.current) runtimeRef.current = new WebLLMAssistantRuntime();
|
|
243
|
+
const depsRef = useRef(deps);
|
|
244
|
+
depsRef.current = deps;
|
|
245
|
+
|
|
246
|
+
const [modelId, setModelId] = useState(DEFAULT_MODEL_ID);
|
|
247
|
+
const [status, setStatus] = useState(runtimeRef.current.status);
|
|
248
|
+
const [progress, setProgress] = useState<InitProgress>({ progress: 0, text: "" });
|
|
249
|
+
const [input, setInput] = useState("");
|
|
250
|
+
const [busy, setBusy] = useState(false);
|
|
251
|
+
const [agentStepHint, setAgentStepHint] = useState<string | null>(null);
|
|
252
|
+
const [historyExpanded, setHistoryExpanded] = useState(false);
|
|
253
|
+
const [messages, setMessages] = useState<UiMessage[]>([
|
|
254
|
+
{
|
|
255
|
+
id: "welcome",
|
|
256
|
+
role: "system",
|
|
257
|
+
content: t("panel.ai.welcome"),
|
|
258
|
+
},
|
|
259
|
+
]);
|
|
260
|
+
const listRef = useRef<HTMLDivElement | null>(null);
|
|
261
|
+
const messagesRef = useRef(messages);
|
|
262
|
+
messagesRef.current = messages;
|
|
263
|
+
|
|
264
|
+
const webgpuOk = useMemo(() => isWebGPUAvailable(), []);
|
|
265
|
+
|
|
266
|
+
/** Prior Q/A for the model (excludes welcome/system; excludes the in-flight user turn). */
|
|
267
|
+
const getPriorChat = useCallback(() => {
|
|
268
|
+
return buildPriorChatMessages(messagesRef.current, {
|
|
269
|
+
maxTurns: 6,
|
|
270
|
+
maxCharsPerMessage: 480,
|
|
271
|
+
});
|
|
272
|
+
}, []);
|
|
273
|
+
|
|
274
|
+
const getRecentDialog = useCallback(() => {
|
|
275
|
+
return buildRecentDialogSummary(messagesRef.current, {
|
|
276
|
+
maxTurns: 4,
|
|
277
|
+
maxCharsPerMessage: 220,
|
|
278
|
+
});
|
|
279
|
+
}, []);
|
|
280
|
+
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (!listRef.current) return;
|
|
283
|
+
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
284
|
+
}, [messages, open, agentStepHint]);
|
|
285
|
+
|
|
286
|
+
const ensureReady = useCallback(async () => {
|
|
287
|
+
const runtime = runtimeRef.current!;
|
|
288
|
+
if (runtime.status === "ready" && runtime.currentModelId === modelId) {
|
|
289
|
+
setStatus("ready");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
setStatus("loading");
|
|
293
|
+
await runtime.init(modelId, (p) => {
|
|
294
|
+
setProgress(p);
|
|
295
|
+
setStatus("loading");
|
|
296
|
+
});
|
|
297
|
+
setStatus(runtime.status);
|
|
298
|
+
}, [modelId]);
|
|
299
|
+
|
|
300
|
+
const pushMessage = (msg: Omit<UiMessage, "id">) => {
|
|
301
|
+
setMessages((prev) => [
|
|
302
|
+
...prev,
|
|
303
|
+
{ ...msg, id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}` },
|
|
304
|
+
]);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const buildObservation = useCallback(() => {
|
|
308
|
+
const d = depsRef.current;
|
|
309
|
+
const selectedIds = d.getSelectedIds();
|
|
310
|
+
const bp = toCompactBlueprint(d.getBlueprintGraph());
|
|
311
|
+
return {
|
|
312
|
+
elements: toCompactElements(d.listElements(), selectedIds),
|
|
313
|
+
selectedIds,
|
|
314
|
+
blueprintNodes: bp.nodes,
|
|
315
|
+
blueprintEdges: bp.edges,
|
|
316
|
+
layers: d.getLayers(),
|
|
317
|
+
activeLayerId: d.getActiveLayerId(),
|
|
318
|
+
zoom: d.getZoom(),
|
|
319
|
+
blueprintOpen: d.getBlueprintOpen(),
|
|
320
|
+
workspace: d.getWorkspace(),
|
|
321
|
+
};
|
|
322
|
+
}, []);
|
|
323
|
+
|
|
324
|
+
const send = useCallback(
|
|
325
|
+
async (text: string) => {
|
|
326
|
+
const trimmed = text.trim();
|
|
327
|
+
if (!trimmed || busy) return;
|
|
328
|
+
if (!webgpuOk) {
|
|
329
|
+
pushMessage({ role: "system", content: t("panel.ai.webgpuRequired") });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
setInput("");
|
|
334
|
+
// Capture prior turns before enqueueing this user message.
|
|
335
|
+
const priorChat = getPriorChat();
|
|
336
|
+
const recentDialog = getRecentDialog();
|
|
337
|
+
pushMessage({ role: "user", content: trimmed });
|
|
338
|
+
setBusy(true);
|
|
339
|
+
setAgentStepHint(null);
|
|
340
|
+
try {
|
|
341
|
+
// Deterministic FAQ first (capability / components / table setup).
|
|
342
|
+
const localFaq = tryLocalFaqReply(trimmed);
|
|
343
|
+
if (localFaq) {
|
|
344
|
+
pushMessage({ role: "assistant", content: localFaq });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Vague greetings / short junk — ask instead of mutating.
|
|
349
|
+
if (looksLikeUnclearIntent(trimmed)) {
|
|
350
|
+
pushMessage({
|
|
351
|
+
role: "assistant",
|
|
352
|
+
content: t("panel.ai.unclearIntent") || UNCLEAR_INTENT_REPLY,
|
|
353
|
+
});
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// High-confidence local tools (add / resize / delete / save …) — skip model.
|
|
358
|
+
const selectedIds = depsRef.current.getSelectedIds() ?? [];
|
|
359
|
+
const localPlan = inferLocalEditorPlan(trimmed, { selectedIds });
|
|
360
|
+
if (localPlan) {
|
|
361
|
+
const stepLines: string[] = [];
|
|
362
|
+
for (let i = 0; i < localPlan.actions.length; i += 1) {
|
|
363
|
+
const action = localPlan.actions[i]!;
|
|
364
|
+
if (action.type === "reply") {
|
|
365
|
+
pushMessage({ role: "assistant", content: action.message });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
setAgentStepHint(
|
|
369
|
+
t("panel.ai.stepRunning", {
|
|
370
|
+
step: String(i + 1),
|
|
371
|
+
type: action.type,
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
const result = await applyAssistantAction(action, depsRef.current);
|
|
375
|
+
stepLines.push(
|
|
376
|
+
t("panel.ai.stepLine", {
|
|
377
|
+
step: String(i + 1),
|
|
378
|
+
type: action.type,
|
|
379
|
+
result: result.ok
|
|
380
|
+
? result.message
|
|
381
|
+
: `✗ ${formatApplyFail(result.message, t)}`,
|
|
382
|
+
})
|
|
383
|
+
);
|
|
384
|
+
if (!result.ok) {
|
|
385
|
+
pushMessage({
|
|
386
|
+
role: "assistant",
|
|
387
|
+
content: formatApplyFail(result.message, t),
|
|
388
|
+
steps: stepLines.length > 1 ? stepLines : undefined,
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
pushMessage({
|
|
394
|
+
role: "assistant",
|
|
395
|
+
content: localPlan.doneMessage,
|
|
396
|
+
steps: stepLines.length > 1 ? stepLines : undefined,
|
|
397
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
await ensureReady();
|
|
402
|
+
const runtime = runtimeRef.current!;
|
|
403
|
+
|
|
404
|
+
if (looksLikePureQuestion(trimmed)) {
|
|
405
|
+
const ctx = buildEditorContext({
|
|
406
|
+
...buildObservation(),
|
|
407
|
+
userGoal: trimmed,
|
|
408
|
+
recentDialog,
|
|
409
|
+
});
|
|
410
|
+
const history: ChatMessage[] = [
|
|
411
|
+
{ role: "system", content: buildSystemPrompt(ctx) },
|
|
412
|
+
...priorChat,
|
|
413
|
+
{ role: "user", content: trimmed },
|
|
414
|
+
];
|
|
415
|
+
let parsed = parseAssistantAction(await runtime.chat(history));
|
|
416
|
+
if (parsed.ok && parsed.action.type !== "reply") {
|
|
417
|
+
// Small models often mutate the page on Q&A — force a reply-only turn.
|
|
418
|
+
history.push({
|
|
419
|
+
role: "assistant",
|
|
420
|
+
content: JSON.stringify(parsed.action),
|
|
421
|
+
});
|
|
422
|
+
history.push({ role: "user", content: buildForceReplyPrompt() });
|
|
423
|
+
parsed = parseAssistantAction(await runtime.chat(history));
|
|
424
|
+
}
|
|
425
|
+
if (!parsed.ok) {
|
|
426
|
+
pushMessage({
|
|
427
|
+
role: "assistant",
|
|
428
|
+
content: `${t("panel.ai.parseFailed")}\n\n\`\`\`\n${parsed.rawText.slice(0, 800)}\n\`\`\``,
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (parsed.action.type === "reply") {
|
|
433
|
+
pushMessage({ role: "assistant", content: parsed.action.message });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
// Still not a reply — refuse to mutate; ask clearly instead.
|
|
437
|
+
pushMessage({
|
|
438
|
+
role: "assistant",
|
|
439
|
+
content: t("panel.ai.unclearIntent") || UNCLEAR_INTENT_REPLY,
|
|
440
|
+
});
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const bootstrap = inferPanelAddFromUserText(trimmed);
|
|
445
|
+
const stepLines: string[] = [];
|
|
446
|
+
|
|
447
|
+
const loopResult = await runAgentLoop({
|
|
448
|
+
userGoal: trimmed,
|
|
449
|
+
priorChat,
|
|
450
|
+
getObservation: () => ({
|
|
451
|
+
...buildObservation(),
|
|
452
|
+
recentDialog,
|
|
453
|
+
}),
|
|
454
|
+
chat: (msgs) => runtime.chat(msgs),
|
|
455
|
+
apply: async (action) => applyAssistantAction(action, depsRef.current),
|
|
456
|
+
bootstrapAction: bootstrap,
|
|
457
|
+
finishAfterBootstrap: looksLikeSimpleAddOnly(trimmed),
|
|
458
|
+
onStep: (record) => {
|
|
459
|
+
stepLines.push(
|
|
460
|
+
t("panel.ai.stepLine", {
|
|
461
|
+
step: String(record.step),
|
|
462
|
+
type: record.action.type,
|
|
463
|
+
result: record.resultOk
|
|
464
|
+
? record.resultMessage
|
|
465
|
+
: `✗ ${formatApplyFail(record.resultMessage, t)}`,
|
|
466
|
+
})
|
|
467
|
+
);
|
|
468
|
+
setAgentStepHint(
|
|
469
|
+
t("panel.ai.stepRunning", {
|
|
470
|
+
step: String(record.step),
|
|
471
|
+
type: record.action.type,
|
|
472
|
+
})
|
|
473
|
+
);
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
setAgentStepHint(null);
|
|
478
|
+
|
|
479
|
+
const reasonText =
|
|
480
|
+
loopResult.stoppedReason === "max_steps"
|
|
481
|
+
? t("panel.ai.stoppedMaxSteps")
|
|
482
|
+
: loopResult.stoppedReason === "fail"
|
|
483
|
+
? t("panel.ai.stoppedFail")
|
|
484
|
+
: loopResult.stoppedReason === "parse_fail"
|
|
485
|
+
? t("panel.ai.stoppedParseFail")
|
|
486
|
+
: "";
|
|
487
|
+
|
|
488
|
+
const final =
|
|
489
|
+
loopResult.finalMessage ||
|
|
490
|
+
(loopResult.stoppedReason === "done"
|
|
491
|
+
? t("panel.ai.done")
|
|
492
|
+
: t("panel.ai.applyFailed"));
|
|
493
|
+
|
|
494
|
+
pushMessage({
|
|
495
|
+
role: "assistant",
|
|
496
|
+
content: reasonText ? `${final}\n\n_${reasonText}_` : final,
|
|
497
|
+
steps: stepLines.length > 1 ? stepLines.slice(-6) : undefined,
|
|
498
|
+
});
|
|
499
|
+
} catch (err) {
|
|
500
|
+
const code = err instanceof Error ? err.message : String(err);
|
|
501
|
+
if (code === "webgpu_unavailable") {
|
|
502
|
+
setStatus("unsupported");
|
|
503
|
+
pushMessage({ role: "system", content: t("panel.ai.webgpuRequired") });
|
|
504
|
+
} else {
|
|
505
|
+
setStatus(runtimeRef.current?.status ?? "error");
|
|
506
|
+
pushMessage({
|
|
507
|
+
role: "system",
|
|
508
|
+
content: `${t("panel.ai.error")}: ${code}`,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
} finally {
|
|
512
|
+
setAgentStepHint(null);
|
|
513
|
+
setBusy(false);
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
[buildObservation, busy, ensureReady, getPriorChat, getRecentDialog, t, webgpuOk]
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
const loadModelOnly = async () => {
|
|
520
|
+
if (!webgpuOk) {
|
|
521
|
+
setStatus("unsupported");
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
setBusy(true);
|
|
525
|
+
try {
|
|
526
|
+
await ensureReady();
|
|
527
|
+
pushMessage({ role: "system", content: t("panel.ai.modelReady") });
|
|
528
|
+
} catch (err) {
|
|
529
|
+
pushMessage({
|
|
530
|
+
role: "system",
|
|
531
|
+
content: `${t("panel.ai.error")}: ${err instanceof Error ? err.message : String(err)}`,
|
|
532
|
+
});
|
|
533
|
+
} finally {
|
|
534
|
+
setBusy(false);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const visibleMessages = useMemo(() => {
|
|
539
|
+
// ChatGPT-style timeline: keep chronological order, drop the initial welcome
|
|
540
|
+
// once the user has started chatting so turns read as Q → A → Q → A.
|
|
541
|
+
const hasUserTurn = messages.some((m) => m.role === "user");
|
|
542
|
+
const timeline = hasUserTurn
|
|
543
|
+
? messages.filter((m) => !(m.id === "welcome" && m.role === "system"))
|
|
544
|
+
: messages;
|
|
545
|
+
if (historyExpanded || timeline.length <= RECENT_MESSAGE_COUNT) {
|
|
546
|
+
return timeline;
|
|
547
|
+
}
|
|
548
|
+
return timeline.slice(-RECENT_MESSAGE_COUNT);
|
|
549
|
+
}, [historyExpanded, messages]);
|
|
550
|
+
|
|
551
|
+
const hiddenCount = useMemo(() => {
|
|
552
|
+
const hasUserTurn = messages.some((m) => m.role === "user");
|
|
553
|
+
const timeline = hasUserTurn
|
|
554
|
+
? messages.filter((m) => !(m.id === "welcome" && m.role === "system"))
|
|
555
|
+
: messages;
|
|
556
|
+
return Math.max(0, timeline.length - visibleMessages.length);
|
|
557
|
+
}, [messages, visibleMessages.length]);
|
|
558
|
+
|
|
559
|
+
const statusTone =
|
|
560
|
+
status === "ready"
|
|
561
|
+
? "ready"
|
|
562
|
+
: status === "loading"
|
|
563
|
+
? "loading"
|
|
564
|
+
: status === "unsupported" || status === "error"
|
|
565
|
+
? "error"
|
|
566
|
+
: "idle";
|
|
567
|
+
|
|
568
|
+
const statusText =
|
|
569
|
+
status === "ready"
|
|
570
|
+
? t("panel.ai.statusReady")
|
|
571
|
+
: status === "loading"
|
|
572
|
+
? t("panel.ai.statusLoading", {
|
|
573
|
+
pct: Math.round((progress.progress || 0) * 100),
|
|
574
|
+
})
|
|
575
|
+
: status === "unsupported"
|
|
576
|
+
? t("panel.ai.statusUnsupported")
|
|
577
|
+
: t("panel.ai.statusIdle");
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
const isEmptyChat =
|
|
581
|
+
visibleMessages.every((m) => m.role === "system") && !agentStepHint;
|
|
582
|
+
|
|
583
|
+
return (
|
|
584
|
+
<Dialog open={open} onOpenChange={onOpenChange} modal={false}>
|
|
585
|
+
<DialogContent
|
|
586
|
+
showOverlay={false}
|
|
587
|
+
showClose={false}
|
|
588
|
+
minWidth={640}
|
|
589
|
+
minHeight={420}
|
|
590
|
+
className="pointer-events-auto flex h-[min(72vh,580px)] w-[min(92vw,720px)] max-w-[720px] flex-col gap-0 overflow-hidden rounded-xl border border-border bg-background p-0 text-foreground shadow-2xl opacity-100"
|
|
591
|
+
style={{
|
|
592
|
+
display: "flex",
|
|
593
|
+
flexDirection: "column",
|
|
594
|
+
height: "min(72vh, 580px)",
|
|
595
|
+
maxHeight: "72vh",
|
|
596
|
+
width: "min(92vw, 720px)",
|
|
597
|
+
backgroundColor: "hsl(var(--background))",
|
|
598
|
+
opacity: 1,
|
|
599
|
+
isolation: "isolate",
|
|
600
|
+
}}
|
|
601
|
+
onPointerDownOutside={(e) => e.preventDefault()}
|
|
602
|
+
onInteractOutside={(e) => e.preventDefault()}
|
|
603
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
604
|
+
>
|
|
605
|
+
<div
|
|
606
|
+
className="flex h-full min-h-0 w-full flex-col overflow-hidden bg-background"
|
|
607
|
+
style={{ backgroundColor: "hsl(var(--background))" }}
|
|
608
|
+
>
|
|
609
|
+
<DialogHeader className="shrink-0 flex-row items-center gap-3 space-y-0 border-b border-border bg-background px-4 py-3 text-left">
|
|
610
|
+
<div className="min-w-0 flex-1">
|
|
611
|
+
<DialogTitle className="text-[15px] font-semibold tracking-tight text-foreground">
|
|
612
|
+
{t("panel.ai.title")}
|
|
613
|
+
</DialogTitle>
|
|
614
|
+
<DialogDescription className="mt-0.5 line-clamp-1 text-[12px] text-muted-foreground">
|
|
615
|
+
{t("panel.ai.welcome")}
|
|
616
|
+
</DialogDescription>
|
|
617
|
+
</div>
|
|
618
|
+
<div className="flex shrink-0 items-center gap-2" data-no-drag>
|
|
619
|
+
<div className="hidden items-center gap-1.5 rounded-md border border-border bg-muted/40 px-2.5 py-1 sm:flex">
|
|
620
|
+
<StatusDot tone={statusTone} />
|
|
621
|
+
<span className="text-[11px] text-muted-foreground">{statusText}</span>
|
|
622
|
+
</div>
|
|
623
|
+
<Select
|
|
624
|
+
value={modelId}
|
|
625
|
+
onValueChange={setModelId}
|
|
626
|
+
disabled={busy || status === "loading"}
|
|
627
|
+
>
|
|
628
|
+
<SelectTrigger className="h-8 w-[96px] rounded-md border-border bg-background text-[12px] shadow-none">
|
|
629
|
+
<SelectValue />
|
|
630
|
+
</SelectTrigger>
|
|
631
|
+
<SelectContent position="popper" sideOffset={6}>
|
|
632
|
+
{ASSISTANT_MODEL_OPTIONS.map((opt) => (
|
|
633
|
+
<SelectItem key={opt.id} value={opt.id} className="text-[12px]">
|
|
634
|
+
{t(opt.labelKey)}
|
|
635
|
+
</SelectItem>
|
|
636
|
+
))}
|
|
637
|
+
</SelectContent>
|
|
638
|
+
</Select>
|
|
639
|
+
{status !== "ready" ? (
|
|
640
|
+
<Button
|
|
641
|
+
type="button"
|
|
642
|
+
variant="outline"
|
|
643
|
+
size="sm"
|
|
644
|
+
className="h-8 rounded-md px-3 text-[12px]"
|
|
645
|
+
disabled={busy || !webgpuOk}
|
|
646
|
+
onClick={() => void loadModelOnly()}
|
|
647
|
+
>
|
|
648
|
+
{t("panel.ai.downloadModel")}
|
|
649
|
+
</Button>
|
|
650
|
+
) : null}
|
|
651
|
+
<DialogClose
|
|
652
|
+
data-no-drag
|
|
653
|
+
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
654
|
+
aria-label="Close"
|
|
655
|
+
>
|
|
656
|
+
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
|
657
|
+
<path d="M18 6 6 18" />
|
|
658
|
+
<path d="m6 6 12 12" />
|
|
659
|
+
</svg>
|
|
660
|
+
</DialogClose>
|
|
661
|
+
</div>
|
|
662
|
+
</DialogHeader>
|
|
663
|
+
|
|
664
|
+
{status === "loading" ? (
|
|
665
|
+
<div className="h-0.5 shrink-0 overflow-hidden bg-muted">
|
|
666
|
+
<div
|
|
667
|
+
className="h-full bg-primary/70 transition-all"
|
|
668
|
+
style={{
|
|
669
|
+
width: `${Math.max(4, Math.round((progress.progress || 0) * 100))}%`,
|
|
670
|
+
}}
|
|
671
|
+
/>
|
|
672
|
+
</div>
|
|
673
|
+
) : null}
|
|
674
|
+
|
|
675
|
+
{!webgpuOk ? (
|
|
676
|
+
<div className="shrink-0 border-b border-destructive/20 bg-destructive/10 px-4 py-2 text-[12px] text-destructive">
|
|
677
|
+
{t("panel.ai.webgpuRequired")}
|
|
678
|
+
</div>
|
|
679
|
+
) : null}
|
|
680
|
+
|
|
681
|
+
<div
|
|
682
|
+
ref={listRef}
|
|
683
|
+
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden bg-background"
|
|
684
|
+
style={{ backgroundColor: "hsl(var(--background))" }}
|
|
685
|
+
>
|
|
686
|
+
<div className="mx-auto flex min-h-full w-full max-w-[720px] flex-col px-4 py-4">
|
|
687
|
+
{isEmptyChat ? (
|
|
688
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-5 py-6 text-center">
|
|
689
|
+
<AiAvatar className="h-12 w-12 text-[14px]" />
|
|
690
|
+
<div className="space-y-1.5">
|
|
691
|
+
<h2 className="text-[20px] font-semibold tracking-tight text-foreground">
|
|
692
|
+
{t("panel.ai.title")}
|
|
693
|
+
</h2>
|
|
694
|
+
<p className="mx-auto max-w-[420px] text-[13px] leading-5 text-muted-foreground">
|
|
695
|
+
{t("panel.ai.welcome")}
|
|
696
|
+
</p>
|
|
697
|
+
</div>
|
|
698
|
+
<div className="flex max-w-[520px] flex-wrap justify-center gap-2">
|
|
699
|
+
{QUICK_PROMPTS.map((key) => (
|
|
700
|
+
<button
|
|
701
|
+
key={key}
|
|
702
|
+
type="button"
|
|
703
|
+
className="rounded-full border border-border bg-muted/30 px-3.5 py-1.5 text-[12px] text-foreground transition hover:bg-muted disabled:opacity-50"
|
|
704
|
+
disabled={busy}
|
|
705
|
+
onClick={() => void send(t(key))}
|
|
706
|
+
data-no-drag
|
|
707
|
+
>
|
|
708
|
+
{t(key)}
|
|
709
|
+
</button>
|
|
710
|
+
))}
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
) : (
|
|
714
|
+
<div style={messageListStyle}>
|
|
715
|
+
{hiddenCount > 0 ? (
|
|
716
|
+
<button
|
|
717
|
+
type="button"
|
|
718
|
+
className="mx-auto text-[12px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
|
719
|
+
onClick={() => setHistoryExpanded((v) => !v)}
|
|
720
|
+
data-no-drag
|
|
721
|
+
>
|
|
722
|
+
{historyExpanded
|
|
723
|
+
? t("panel.ai.historyCollapse")
|
|
724
|
+
: t("panel.ai.historyExpand", { count: hiddenCount })}
|
|
725
|
+
</button>
|
|
726
|
+
) : null}
|
|
727
|
+
|
|
728
|
+
{visibleMessages.map((m) => {
|
|
729
|
+
if (m.role === "system") {
|
|
730
|
+
return (
|
|
731
|
+
<div
|
|
732
|
+
key={m.id}
|
|
733
|
+
className="mx-auto max-w-[85%] px-3.5 py-2 text-center text-[12px] leading-5 text-muted-foreground"
|
|
734
|
+
style={{
|
|
735
|
+
backgroundColor: "hsl(var(--muted))",
|
|
736
|
+
borderRadius: 16,
|
|
737
|
+
}}
|
|
738
|
+
>
|
|
739
|
+
{m.content}
|
|
740
|
+
</div>
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
if (m.role === "user") {
|
|
744
|
+
return (
|
|
745
|
+
<div
|
|
746
|
+
key={m.id}
|
|
747
|
+
className="flex w-full flex-col items-end"
|
|
748
|
+
style={{ marginBottom: 2 }}
|
|
749
|
+
>
|
|
750
|
+
<div className="flex max-w-[85%] items-end gap-2">
|
|
751
|
+
<div
|
|
752
|
+
className="px-4 py-2.5 text-[14px] leading-6 shadow-sm"
|
|
753
|
+
style={userBubbleStyle}
|
|
754
|
+
>
|
|
755
|
+
<p className="whitespace-pre-wrap break-words">{m.content}</p>
|
|
756
|
+
</div>
|
|
757
|
+
<UserAvatar />
|
|
758
|
+
</div>
|
|
759
|
+
</div>
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
return (
|
|
763
|
+
<div
|
|
764
|
+
key={m.id}
|
|
765
|
+
className="flex w-full flex-col items-start"
|
|
766
|
+
style={{ marginBottom: 2 }}
|
|
767
|
+
>
|
|
768
|
+
<div className="flex max-w-[90%] items-end gap-2">
|
|
769
|
+
<AiAvatar />
|
|
770
|
+
<div
|
|
771
|
+
className="border border-border px-4 py-2.5 shadow-sm"
|
|
772
|
+
style={assistantBubbleStyle}
|
|
773
|
+
>
|
|
774
|
+
<AssistantMarkdown content={m.content} />
|
|
775
|
+
{m.steps?.length ? (
|
|
776
|
+
<details className="mt-2 border-t border-border/60 pt-2" data-no-drag>
|
|
777
|
+
<summary className="cursor-pointer select-none text-[12px] text-muted-foreground hover:text-foreground">
|
|
778
|
+
{t("panel.ai.stepsSummary")}
|
|
779
|
+
</summary>
|
|
780
|
+
<ul className="mt-1.5 space-y-1 border-l border-border pl-2.5 text-[12px] leading-5 text-muted-foreground">
|
|
781
|
+
{m.steps.map((line, i) => (
|
|
782
|
+
<li key={i}>{line}</li>
|
|
783
|
+
))}
|
|
784
|
+
</ul>
|
|
785
|
+
</details>
|
|
786
|
+
) : null}
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
</div>
|
|
790
|
+
);
|
|
791
|
+
})}
|
|
792
|
+
|
|
793
|
+
{agentStepHint ? (
|
|
794
|
+
<div
|
|
795
|
+
className="flex items-center gap-2 self-start border border-border px-3 py-2 text-[13px] text-muted-foreground"
|
|
796
|
+
style={{ borderRadius: 12 }}
|
|
797
|
+
>
|
|
798
|
+
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500" />
|
|
799
|
+
{agentStepHint}
|
|
800
|
+
</div>
|
|
801
|
+
) : null}
|
|
802
|
+
</div>
|
|
803
|
+
)}
|
|
804
|
+
</div>
|
|
805
|
+
</div>
|
|
806
|
+
|
|
807
|
+
<div
|
|
808
|
+
className="shrink-0 border-t border-border bg-background px-4 py-3"
|
|
809
|
+
style={{ backgroundColor: "hsl(var(--background))" }}
|
|
810
|
+
>
|
|
811
|
+
<div className="mx-auto w-full max-w-[720px] space-y-2">
|
|
812
|
+
{!isEmptyChat ? (
|
|
813
|
+
<div className="flex flex-wrap gap-1.5">
|
|
814
|
+
{QUICK_PROMPTS.map((key) => (
|
|
815
|
+
<button
|
|
816
|
+
key={key}
|
|
817
|
+
type="button"
|
|
818
|
+
className="rounded-full border border-border bg-muted/30 px-2.5 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
|
819
|
+
disabled={busy}
|
|
820
|
+
onClick={() => void send(t(key))}
|
|
821
|
+
data-no-drag
|
|
822
|
+
>
|
|
823
|
+
{t(key)}
|
|
824
|
+
</button>
|
|
825
|
+
))}
|
|
826
|
+
</div>
|
|
827
|
+
) : null}
|
|
828
|
+
<form
|
|
829
|
+
className="relative overflow-hidden rounded-2xl border border-border bg-muted/15 transition-colors focus-within:border-sky-500/40 focus-within:bg-background"
|
|
830
|
+
onSubmit={(e) => {
|
|
831
|
+
e.preventDefault();
|
|
832
|
+
void send(input);
|
|
833
|
+
}}
|
|
834
|
+
>
|
|
835
|
+
<Textarea
|
|
836
|
+
value={input}
|
|
837
|
+
onChange={(e) => setInput(e.target.value)}
|
|
838
|
+
placeholder={t("panel.ai.inputPlaceholder")}
|
|
839
|
+
className="min-h-[68px] max-h-[160px] w-full resize-none border-0 bg-transparent px-3.5 pb-12 pt-3 text-[14px] leading-5 text-foreground shadow-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0 md:text-[14px]"
|
|
840
|
+
disabled={busy}
|
|
841
|
+
data-no-drag
|
|
842
|
+
onKeyDown={(e) => {
|
|
843
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
844
|
+
e.preventDefault();
|
|
845
|
+
void send(input);
|
|
846
|
+
}
|
|
847
|
+
}}
|
|
848
|
+
/>
|
|
849
|
+
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 px-2.5 pb-2.5">
|
|
850
|
+
<span className="pl-1 text-[11px] text-muted-foreground">
|
|
851
|
+
{t("panel.ai.inputHint")}
|
|
852
|
+
</span>
|
|
853
|
+
<Button
|
|
854
|
+
type="submit"
|
|
855
|
+
size="icon"
|
|
856
|
+
disabled={busy || !input.trim()}
|
|
857
|
+
data-no-drag
|
|
858
|
+
aria-label={t("panel.ai.send")}
|
|
859
|
+
className="h-9 w-9 rounded-full shadow-sm disabled:opacity-40"
|
|
860
|
+
style={
|
|
861
|
+
busy || !input.trim()
|
|
862
|
+
? undefined
|
|
863
|
+
: {
|
|
864
|
+
backgroundColor: "hsl(var(--foreground))",
|
|
865
|
+
color: "hsl(var(--background))",
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
>
|
|
869
|
+
{busy ? (
|
|
870
|
+
<span className="text-[12px]">…</span>
|
|
871
|
+
) : (
|
|
872
|
+
<svg
|
|
873
|
+
viewBox="0 0 24 24"
|
|
874
|
+
className="h-4 w-4"
|
|
875
|
+
fill="none"
|
|
876
|
+
stroke="currentColor"
|
|
877
|
+
strokeWidth="2.4"
|
|
878
|
+
strokeLinecap="round"
|
|
879
|
+
strokeLinejoin="round"
|
|
880
|
+
aria-hidden
|
|
881
|
+
>
|
|
882
|
+
<path d="M12 19V5" />
|
|
883
|
+
<path d="m5 12 7-7 7 7" />
|
|
884
|
+
</svg>
|
|
885
|
+
)}
|
|
886
|
+
</Button>
|
|
887
|
+
</div>
|
|
888
|
+
</form>
|
|
889
|
+
</div>
|
|
890
|
+
</div>
|
|
891
|
+
</div>
|
|
892
|
+
</DialogContent>
|
|
893
|
+
</Dialog>
|
|
894
|
+
);
|
|
895
|
+
}
|