@arronqzy/react-view 1.0.3 → 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.
Files changed (43) hide show
  1. package/dist/styles.css +555 -15
  2. package/package.json +14 -7
  3. package/readme.md +74 -5
  4. package/src/core/template.tsx +12 -2
  5. package/src/panel/ReactViewOnlinePreview.tsx +18 -8
  6. package/src/panel/ReactViewPanel.tsx +403 -177
  7. package/src/panel/ai/AssistantChatPanel.tsx +895 -0
  8. package/src/panel/ai/AssistantMarkdown.tsx +256 -0
  9. package/src/panel/ai/applyAssistantAction.ts +652 -0
  10. package/src/panel/ai/revealConfigField.ts +137 -0
  11. package/src/panel/components/ConfigHintIcon.tsx +50 -0
  12. package/src/panel/components/ElementsLayer.tsx +39 -22
  13. package/src/panel/components/MaterialSidebar.tsx +157 -109
  14. package/src/panel/components/MoveableLayer.tsx +75 -61
  15. package/src/panel/components/PanelCanvas.tsx +81 -64
  16. package/src/panel/components/PanelConfigSidebar.tsx +471 -448
  17. package/src/panel/components/SelectLayer.tsx +1 -0
  18. package/src/panel/components/ViewElementScopePanel.tsx +13 -12
  19. package/src/panel/components/WorkspaceConfigSidebar.tsx +5 -2
  20. package/src/panel/components/WorkspaceProjectNav.tsx +25 -14
  21. package/src/panel/components/WorkspaceStageSplit.tsx +3 -1
  22. package/src/panel/components/scope-config/ScopeConfigContext.tsx +9 -0
  23. package/src/panel/components/scope-config/ScopeTemplatePreviewHost.tsx +242 -0
  24. package/src/panel/components/scope-config/ScopeTemplateUsageHint.tsx +12 -0
  25. package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.tsx +6 -3
  26. package/src/panel/components/table/ConditionEditor.tsx +205 -0
  27. package/src/panel/components/table/JsonCodeEditor.tsx +115 -0
  28. package/src/panel/components/table/PanelConfigTableSection.tsx +2192 -0
  29. package/src/panel/components/table/TableNodeContent.tsx +544 -0
  30. package/src/panel/constants/messages.ts +76 -34
  31. package/src/panel/hooks/usePanelElements.ts +64 -52
  32. package/src/panel/hooks/useRafThrottledScroll.ts +30 -0
  33. package/src/panel/hooks/useWorkspaceProjects.ts +11 -8
  34. package/src/panel/types.ts +6 -0
  35. package/src/panel/utils/chartOptionBuilder.ts +12 -1
  36. package/src/panel/utils/panelElementDefaults.ts +69 -39
  37. package/src/panel/utils/scope-field-labels.ts +51 -37
  38. package/src/panel/utils/scope-template-chart.ts +92 -0
  39. package/src/panel/utils/scope-template-preview.ts +128 -0
  40. package/src/panel/utils/scope-template-spread.ts +252 -0
  41. package/src/panel/utils/scope-template-warnings.ts +131 -11
  42. package/src/panel/utils/scope-template.ts +105 -3
  43. package/src/tailwind.css +36 -15
@@ -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
+ }