cue-console 0.1.15 → 0.1.17

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.
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { spawn } = require("node:child_process");
4
- const fs = require("node:fs");
5
- const path = require("node:path");
3
+ let spawn;
4
+ let fs;
5
+ let path;
6
6
 
7
7
  function printHelp() {
8
8
  process.stdout.write(
@@ -44,6 +44,13 @@ function parseArgs(argv) {
44
44
  }
45
45
 
46
46
  async function main() {
47
+ if (!spawn || !fs || !path) {
48
+ const childProcess = await import("node:child_process");
49
+ spawn = childProcess.spawn;
50
+ fs = await import("node:fs");
51
+ path = await import("node:path");
52
+ }
53
+
47
54
  const { command, port, host, passthrough } = parseArgs(process.argv.slice(2));
48
55
 
49
56
  if (!command) {
@@ -57,12 +64,11 @@ async function main() {
57
64
  process.exit(1);
58
65
  }
59
66
 
60
- let nextBin;
61
- try {
62
- nextBin = require.resolve("next/dist/bin/next");
63
- } catch (e) {
67
+ const pkgRoot = path.resolve(__dirname, "..");
68
+ const nextBin = path.join(pkgRoot, "node_modules", "next", "dist", "bin", "next");
69
+ if (!fs.existsSync(nextBin)) {
64
70
  process.stderr.write(
65
- "Unable to resolve Next.js CLI. Please install dependencies first (e.g. pnpm install).\n"
71
+ "Unable to resolve Next.js CLI. Please install dependencies first (e.g. npm install).\n"
66
72
  );
67
73
  process.exit(1);
68
74
  }
@@ -71,8 +77,6 @@ async function main() {
71
77
  if (port) env.PORT = String(port);
72
78
  if (host) env.HOSTNAME = String(host);
73
79
 
74
- const pkgRoot = path.resolve(__dirname, "..");
75
-
76
80
  const spawnNext = (subcmd) =>
77
81
  new Promise((resolve, reject) => {
78
82
  const child = spawn(process.execPath, [nextBin, subcmd, ...passthrough], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cue-console",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Cue Hub console launcher (Next.js UI)",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": ["mcp", "cue", "console", "nextjs"],
@@ -1,6 +1,7 @@
1
1
  import type { Metadata } from "next";
2
2
  import "@fontsource-variable/source-sans-3";
3
3
  import "./globals.css";
4
+ import { Providers } from "./providers";
4
5
 
5
6
  export const metadata: Metadata = {
6
7
  title: "cue-console",
@@ -18,7 +19,7 @@ export default function RootLayout({
18
19
  className="antialiased"
19
20
  suppressHydrationWarning
20
21
  >
21
- {children}
22
+ <Providers>{children}</Providers>
22
23
  </body>
23
24
  </html>
24
25
  );
package/src/app/page.tsx CHANGED
@@ -12,7 +12,16 @@ export default function Home() {
12
12
  const [selectedType, setSelectedType] = useState<"agent" | "group" | null>(null);
13
13
  const [selectedName, setSelectedName] = useState<string>("");
14
14
  const [showCreateGroup, setShowCreateGroup] = useState(false);
15
- const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
15
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
16
+ try {
17
+ const raw = window.localStorage.getItem("cuehub.sidebarCollapsed");
18
+ if (raw === "1") return true;
19
+ if (raw === "0") return false;
20
+ return false;
21
+ } catch {
22
+ return false;
23
+ }
24
+ });
16
25
 
17
26
  useEffect(() => {
18
27
  let stopped = false;
@@ -105,16 +114,6 @@ export default function Home() {
105
114
  };
106
115
  }, []);
107
116
 
108
- useEffect(() => {
109
- try {
110
- const raw = window.localStorage.getItem("cuehub.sidebarCollapsed");
111
- if (raw === "1") setSidebarCollapsed(true);
112
- if (raw === "0") setSidebarCollapsed(false);
113
- } catch {
114
- // ignore
115
- }
116
- }, []);
117
-
118
117
  useEffect(() => {
119
118
  try {
120
119
  window.localStorage.setItem("cuehub.sidebarCollapsed", sidebarCollapsed ? "1" : "0");
@@ -0,0 +1,8 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { ConfigProvider } from "@/contexts/config-context";
5
+
6
+ export function Providers({ children }: { children: ReactNode }) {
7
+ return <ConfigProvider>{children}</ConfigProvider>;
8
+ }
@@ -1,10 +1,11 @@
1
- import { useMemo } from "react";
1
+ import { useMemo, useState } from "react";
2
2
  import { Badge } from "@/components/ui/badge";
3
3
  import { Button } from "@/components/ui/button";
4
4
  import { cn, getAgentEmoji, formatFullTime, getWaitingDuration } from "@/lib/utils";
5
5
  import { MarkdownRenderer } from "@/components/markdown-renderer";
6
6
  import { PayloadCard } from "@/components/payload-card";
7
7
  import type { CueRequest } from "@/lib/actions";
8
+ import { Copy, Check } from "lucide-react";
8
9
 
9
10
  interface MessageBubbleProps {
10
11
  request: CueRequest;
@@ -44,6 +45,17 @@ export function MessageBubble({
44
45
  onCancel,
45
46
  }: MessageBubbleProps) {
46
47
  const isPending = request.status === "PENDING";
48
+ const [copied, setCopied] = useState(false);
49
+
50
+ const handleCopy = async () => {
51
+ try {
52
+ await navigator.clipboard.writeText(request.prompt || "");
53
+ setCopied(true);
54
+ setTimeout(() => setCopied(false), 2000);
55
+ } catch (err) {
56
+ console.error("Failed to copy:", err);
57
+ }
58
+ };
47
59
 
48
60
  const isPause = useMemo(() => {
49
61
  if (!request.payload) return false;
@@ -133,6 +145,17 @@ export function MessageBubble({
133
145
  </div>
134
146
  <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
135
147
  <span className="shrink-0">{formatFullTime(request.created_at || "")}</span>
148
+ <Button
149
+ variant="ghost"
150
+ size="sm"
151
+ className="h-auto p-1 text-xs"
152
+ onClick={handleCopy}
153
+ disabled={disabled}
154
+ title={copied ? "已复制" : "复制"}
155
+ >
156
+ {copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
157
+ <span className="ml-1">{copied ? "已复制" : "复制"}</span>
158
+ </Button>
136
159
  {isPending && (
137
160
  <>
138
161
  <Badge variant="outline" className="text-xs shrink-0">
@@ -1,12 +1,11 @@
1
1
  "use client";
2
2
 
3
- import { memo, useMemo, type ReactNode } from "react";
3
+ import { memo } from "react";
4
4
  import { Button } from "@/components/ui/button";
5
- import { Badge } from "@/components/ui/badge";
6
- import { cn, formatFullTime, getAgentEmoji, getWaitingDuration } from "@/lib/utils";
7
- import { MarkdownRenderer } from "@/components/markdown-renderer";
8
- import { PayloadCard } from "@/components/payload-card";
9
- import type { AgentTimelineItem, CueRequest, CueResponse } from "@/lib/actions";
5
+ import { cn } from "@/lib/utils";
6
+ import type { AgentTimelineItem } from "@/lib/actions";
7
+ import { MessageBubble } from "@/components/chat/message-bubble";
8
+ import { UserResponseBubble } from "@/components/chat/user-response-bubble";
10
9
 
11
10
  function parseDbTime(dateStr: string) {
12
11
  return new Date((dateStr || "").replace(" ", "T"));
@@ -152,319 +151,3 @@ export const TimelineList = memo(function TimelineList({
152
151
  </>
153
152
  );
154
153
  });
155
-
156
- const MessageBubble = memo(function MessageBubble({
157
- request,
158
- showAgent,
159
- agentNameMap,
160
- avatarUrlMap,
161
- isHistory,
162
- showName,
163
- showAvatar,
164
- compact,
165
- disabled,
166
- currentInput,
167
- isGroup,
168
- onPasteChoice,
169
- onSubmitConfirm,
170
- onMentionAgent,
171
- onReply,
172
- onCancel,
173
- }: {
174
- request: CueRequest;
175
- showAgent?: boolean;
176
- agentNameMap?: Record<string, string>;
177
- avatarUrlMap?: Record<string, string>;
178
- isHistory?: boolean;
179
- showName?: boolean;
180
- showAvatar?: boolean;
181
- compact?: boolean;
182
- disabled?: boolean;
183
- currentInput?: string;
184
- isGroup?: boolean;
185
- onPasteChoice?: (text: string, mode?: "replace" | "append" | "upsert") => void;
186
- onSubmitConfirm?: (
187
- requestId: string,
188
- text: string,
189
- cancelled: boolean
190
- ) => void | Promise<void>;
191
- onMentionAgent?: (agentId: string) => void;
192
- onReply?: () => void;
193
- onCancel?: () => void;
194
- }) {
195
- const isPending = request.status === "PENDING";
196
-
197
- const isPause = useMemo(() => {
198
- if (!request.payload) return false;
199
- try {
200
- const obj = JSON.parse(request.payload) as Record<string, unknown>;
201
- return obj?.type === "confirm" && obj?.variant === "pause";
202
- } catch {
203
- return false;
204
- }
205
- }, [request.payload]);
206
-
207
- const selectedLines = useMemo(() => {
208
- const text = (currentInput || "").trim();
209
- if (!text) return new Set<string>();
210
- return new Set(
211
- text
212
- .split(/\r?\n/)
213
- .map((s) => s.trim())
214
- .filter(Boolean)
215
- );
216
- }, [currentInput]);
217
-
218
- const rawId = request.agent_id || "";
219
- const displayName = (agentNameMap && rawId ? agentNameMap[rawId] || rawId : rawId) || "";
220
- const cardMaxWidth = (showAvatar ?? true) ? "calc(100% - 3rem)" : "100%";
221
- const avatarUrl = rawId && avatarUrlMap ? avatarUrlMap[`agent:${rawId}`] : "";
222
-
223
- return (
224
- <div
225
- className={cn(
226
- "flex max-w-full min-w-0 items-start gap-3",
227
- compact && "gap-2",
228
- isHistory && "opacity-60"
229
- )}
230
- >
231
- {(showAvatar ?? true) ? (
232
- <span
233
- className={cn(
234
- "flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg",
235
- isGroup && request.agent_id && onMentionAgent && "cursor-pointer"
236
- )}
237
- title={
238
- isGroup && request.agent_id && onMentionAgent
239
- ? "Double-click avatar to @mention"
240
- : undefined
241
- }
242
- onDoubleClick={() => {
243
- if (!isGroup) return;
244
- const agentId = request.agent_id;
245
- if (!agentId) return;
246
- onMentionAgent?.(agentId);
247
- }}
248
- >
249
- {avatarUrl ? (
250
- <img src={avatarUrl} alt="" className="h-full w-full rounded-full" />
251
- ) : (
252
- getAgentEmoji(request.agent_id || "")
253
- )}
254
- </span>
255
- ) : (
256
- <span className="h-9 w-9 shrink-0" />
257
- )}
258
- <div className="flex-1 min-w-0 overflow-hidden">
259
- {(showName ?? true) && (showAgent || displayName) && (
260
- <p className="mb-1 text-xs text-muted-foreground truncate">{displayName}</p>
261
- )}
262
- <div
263
- className={cn(
264
- "rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 overflow-hidden",
265
- "glass-surface-soft glass-noise",
266
- isPending ? "ring-1 ring-ring/25" : "ring-1 ring-white/25"
267
- )}
268
- style={{ clipPath: "inset(0 round 1rem)", maxWidth: cardMaxWidth }}
269
- >
270
- <div className="text-sm wrap-anywhere overflow-hidden min-w-0">
271
- <MarkdownRenderer>{request.prompt || ""}</MarkdownRenderer>
272
- </div>
273
- <PayloadCard
274
- raw={request.payload}
275
- disabled={disabled}
276
- onPasteChoice={onPasteChoice}
277
- onSubmitConfirm={(text, cancelled) =>
278
- isPending ? onSubmitConfirm?.(request.request_id, text, cancelled) : undefined
279
- }
280
- selectedLines={selectedLines}
281
- />
282
- </div>
283
- <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
284
- <span className="shrink-0">{formatFullTime(request.created_at || "")}</span>
285
- {isPending && (
286
- <>
287
- <Badge variant="outline" className="text-xs shrink-0">
288
- Waiting {getWaitingDuration(request.created_at || "")}
289
- </Badge>
290
- {!isPause && (
291
- <>
292
- <Badge variant="default" className="text-xs shrink-0">
293
- Pending
294
- </Badge>
295
- {onReply && (
296
- <Button
297
- variant="link"
298
- size="sm"
299
- className="h-auto p-0 text-xs"
300
- onClick={onReply}
301
- disabled={disabled}
302
- >
303
- Reply
304
- </Button>
305
- )}
306
- {onCancel && (
307
- <Button
308
- variant="link"
309
- size="sm"
310
- className="h-auto p-0 text-xs text-destructive"
311
- onClick={onCancel}
312
- disabled={disabled}
313
- >
314
- End
315
- </Button>
316
- )}
317
- </>
318
- )}
319
- </>
320
- )}
321
- {request.status === "COMPLETED" && (
322
- <Badge variant="secondary" className="text-xs shrink-0">
323
- Replied
324
- </Badge>
325
- )}
326
- {request.status === "CANCELLED" && (
327
- <Badge variant="destructive" className="text-xs shrink-0">
328
- Ended
329
- </Badge>
330
- )}
331
- </div>
332
- </div>
333
- </div>
334
- );
335
- });
336
-
337
- const UserResponseBubble = memo(function UserResponseBubble({
338
- response,
339
- showAvatar = true,
340
- compact = false,
341
- onPreview,
342
- }: {
343
- response: CueResponse;
344
- showAvatar?: boolean;
345
- compact?: boolean;
346
- onPreview?: (img: { mime_type: string; base64_data: string }) => void;
347
- }) {
348
- const parsed = JSON.parse(response.response_json || "{}") as {
349
- text?: string;
350
- mentions?: { userId: string; start: number; length: number; display: string }[];
351
- };
352
-
353
- const files = Array.isArray((response as any).files) ? ((response as any).files as any[]) : [];
354
- const imageFiles = files.filter((f) => {
355
- const mime = String(f?.mime_type || "");
356
- return mime.startsWith("image/") && typeof f?.inline_base64 === "string" && f.inline_base64.length > 0;
357
- });
358
- const otherFiles = files.filter((f) => {
359
- const mime = String(f?.mime_type || "");
360
- return !mime.startsWith("image/");
361
- });
362
-
363
- const renderTextWithMentions = (text: string, mentions?: { start: number; length: number }[]) => {
364
- if (!mentions || mentions.length === 0) return text;
365
- const safe = [...mentions]
366
- .filter((m) => m.start >= 0 && m.length > 0 && m.start + m.length <= text.length)
367
- .sort((a, b) => a.start - b.start);
368
-
369
- const nodes: ReactNode[] = [];
370
- let cursor = 0;
371
- for (const m of safe) {
372
- if (m.start < cursor) continue;
373
- if (m.start > cursor) {
374
- nodes.push(text.slice(cursor, m.start));
375
- }
376
- const seg = text.slice(m.start, m.start + m.length);
377
- nodes.push(
378
- <span key={`m-${m.start}`} className="text-emerald-900/90 dark:text-emerald-950 font-semibold">
379
- {seg}
380
- </span>
381
- );
382
- cursor = m.start + m.length;
383
- }
384
- if (cursor < text.length) nodes.push(text.slice(cursor));
385
- return nodes;
386
- };
387
-
388
- if (response.cancelled) {
389
- return (
390
- <div className="flex justify-end gap-3 max-w-full min-w-0">
391
- <div
392
- className="rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 sm:max-w-215 sm:flex-none sm:w-fit overflow-hidden glass-surface-soft glass-noise ring-1 ring-white/25"
393
- style={{
394
- clipPath: "inset(0 round 1rem)",
395
- maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
396
- }}
397
- >
398
- <p className="text-sm text-muted-foreground italic">Conversation ended</p>
399
- <p className="text-xs text-muted-foreground mt-1">{formatFullTime(response.created_at)}</p>
400
- </div>
401
- </div>
402
- );
403
- }
404
-
405
- return (
406
- <div className={cn("flex justify-end gap-3 max-w-full min-w-0", compact && "gap-2")}>
407
- <div
408
- className="rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 sm:max-w-215 sm:flex-none sm:w-fit overflow-hidden glass-surface-soft glass-noise ring-1 ring-white/25"
409
- style={{
410
- clipPath: "inset(0 round 1rem)",
411
- maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
412
- }}
413
- >
414
- {parsed.text && (
415
- <div className="text-sm wrap-anywhere overflow-hidden min-w-0">
416
- {parsed.mentions && parsed.mentions.length > 0 ? (
417
- <p className="whitespace-pre-wrap">{renderTextWithMentions(parsed.text, parsed.mentions)}</p>
418
- ) : (
419
- <MarkdownRenderer>{parsed.text}</MarkdownRenderer>
420
- )}
421
- </div>
422
- )}
423
- {imageFiles.length > 0 && (
424
- <div className="flex flex-wrap gap-2 mt-2 max-w-full">
425
- {imageFiles.map((f, i) => {
426
- const mime = String(f?.mime_type || "image/png");
427
- const b64 = String(f?.inline_base64 || "");
428
- const img = { mime_type: mime, base64_data: b64 };
429
- return (
430
- <img
431
- key={i}
432
- src={`data:${img.mime_type};base64,${img.base64_data}`}
433
- alt=""
434
- className="max-h-32 max-w-full h-auto rounded cursor-pointer"
435
- onClick={() => onPreview?.(img)}
436
- />
437
- );
438
- })}
439
- </div>
440
- )}
441
-
442
- {otherFiles.length > 0 && (
443
- <div className="mt-2 flex flex-col gap-1 max-w-full">
444
- {otherFiles.map((f, i) => {
445
- const fileRef = String(f?.file || "");
446
- const name = fileRef.split("/").filter(Boolean).pop() || fileRef || "file";
447
- return (
448
- <div
449
- key={i}
450
- className="px-2 py-1 rounded-lg bg-white/40 dark:bg-black/20 ring-1 ring-border/40 text-xs text-foreground/80 truncate"
451
- title={fileRef}
452
- >
453
- {name}
454
- </div>
455
- );
456
- })}
457
- </div>
458
- )}
459
- <p className="text-xs opacity-70 mt-1 text-right">{formatFullTime(response.created_at)}</p>
460
- </div>
461
- {showAvatar ? (
462
- <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg">
463
- 👤
464
- </span>
465
- ) : (
466
- <span className="h-9 w-9 shrink-0" />
467
- )}
468
- </div>
469
- );
470
- });
@@ -1,7 +1,8 @@
1
- import type { ReactNode } from "react";
1
+ import { useMemo, type ReactNode } from "react";
2
2
  import { cn, formatFullTime } from "@/lib/utils";
3
3
  import { MarkdownRenderer } from "@/components/markdown-renderer";
4
4
  import type { CueResponse } from "@/lib/actions";
5
+ import { useConfig } from "@/contexts/config-context";
5
6
 
6
7
  interface UserResponseBubbleProps {
7
8
  response: CueResponse;
@@ -16,18 +17,56 @@ export function UserResponseBubble({
16
17
  compact = false,
17
18
  onPreview,
18
19
  }: UserResponseBubbleProps) {
20
+ const { config } = useConfig();
19
21
  const parsed = JSON.parse(response.response_json || "{}") as {
20
22
  text?: string;
21
23
  mentions?: { userId: string; start: number; length: number; display: string }[];
22
24
  };
23
25
 
24
- const files = Array.isArray((response as any).files) ? ((response as any).files as any[]) : [];
26
+ const analysisOnlyInstruction = config.chat_mode_append_text;
27
+ const { analysisOnlyApplied, displayText } = useMemo(() => {
28
+ const text = parsed.text;
29
+ if (typeof text !== "string") return { analysisOnlyApplied: false, displayText: text };
30
+
31
+ const raw = text;
32
+ const lines = raw.split(/\r?\n/);
33
+ let lastNonEmpty = -1;
34
+ for (let i = lines.length - 1; i >= 0; i--) {
35
+ if (lines[i]?.trim().length) {
36
+ lastNonEmpty = i;
37
+ break;
38
+ }
39
+ }
40
+ if (lastNonEmpty === -1) return { analysisOnlyApplied: false, displayText: raw };
41
+
42
+ const tail = (lines[lastNonEmpty] ?? "").trim();
43
+ if (tail !== analysisOnlyInstruction) {
44
+ return { analysisOnlyApplied: false, displayText: raw };
45
+ }
46
+
47
+ let cut = lastNonEmpty;
48
+ if (cut > 0 && (lines[cut - 1] ?? "").trim().length === 0) {
49
+ cut -= 1;
50
+ }
51
+ const stripped = lines.slice(0, cut).join("\n").replace(/\s+$/, "");
52
+ if (stripped.trim().length === 0) {
53
+ return { analysisOnlyApplied: false, displayText: raw };
54
+ }
55
+
56
+ return { analysisOnlyApplied: true, displayText: stripped };
57
+ }, [parsed.text, analysisOnlyInstruction]);
58
+
59
+ const filesRaw = (response as unknown as { files?: unknown }).files;
60
+ const files = Array.isArray(filesRaw) ? filesRaw : [];
25
61
  const imageFiles = files.filter((f) => {
26
- const mime = String(f?.mime_type || "");
27
- return mime.startsWith("image/") && typeof f?.inline_base64 === "string" && f.inline_base64.length > 0;
62
+ const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
63
+ const mime = String(obj?.mime_type || "");
64
+ const b64 = obj?.inline_base64;
65
+ return mime.startsWith("image/") && typeof b64 === "string" && b64.length > 0;
28
66
  });
29
67
  const otherFiles = files.filter((f) => {
30
- const mime = String(f?.mime_type || "");
68
+ const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
69
+ const mime = String(obj?.mime_type || "");
31
70
  return !mime.startsWith("image/");
32
71
  });
33
72
 
@@ -85,22 +124,23 @@ export function UserResponseBubble({
85
124
  maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
86
125
  }}
87
126
  >
88
- {parsed.text && (
127
+ {displayText && (
89
128
  <div className="text-sm wrap-anywhere overflow-hidden min-w-0">
90
129
  {parsed.mentions && parsed.mentions.length > 0 ? (
91
130
  <p className="whitespace-pre-wrap">
92
- {renderTextWithMentions(parsed.text, parsed.mentions)}
131
+ {renderTextWithMentions(displayText, parsed.mentions)}
93
132
  </p>
94
133
  ) : (
95
- <MarkdownRenderer>{parsed.text}</MarkdownRenderer>
134
+ <MarkdownRenderer>{displayText}</MarkdownRenderer>
96
135
  )}
97
136
  </div>
98
137
  )}
99
138
  {imageFiles.length > 0 && (
100
139
  <div className="flex flex-wrap gap-2 mt-2 max-w-full">
101
140
  {imageFiles.map((f, i) => {
102
- const mime = String(f?.mime_type || "image/png");
103
- const b64 = String(f?.inline_base64 || "");
141
+ const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
142
+ const mime = String(obj?.mime_type || "image/png");
143
+ const b64 = String(obj?.inline_base64 || "");
104
144
  const img = { mime_type: mime, base64_data: b64 };
105
145
  return (
106
146
  <img
@@ -118,7 +158,8 @@ export function UserResponseBubble({
118
158
  {otherFiles.length > 0 && (
119
159
  <div className="mt-2 flex flex-col gap-1 max-w-full">
120
160
  {otherFiles.map((f, i) => {
121
- const fileRef = String(f?.file || "");
161
+ const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
162
+ const fileRef = String(obj?.file || "");
122
163
  const name = fileRef.split("/").filter(Boolean).pop() || fileRef || "file";
123
164
  return (
124
165
  <div
@@ -132,7 +173,17 @@ export function UserResponseBubble({
132
173
  })}
133
174
  </div>
134
175
  )}
135
- <p className="text-xs opacity-70 mt-1 text-right">{formatFullTime(response.created_at)}</p>
176
+ <div className="mt-1 flex items-center justify-end gap-2 text-xs opacity-70">
177
+ {analysisOnlyApplied && (
178
+ <span
179
+ className="rounded-full bg-white/40 dark:bg-black/20 px-2 py-0.5 ring-1 ring-border/40"
180
+ title="Chat 模式:只做分析,不做改动(该规则会发送给模型,但不会显示在消息正文里)"
181
+ >
182
+ Chat
183
+ </span>
184
+ )}
185
+ <span>{formatFullTime(response.created_at)}</span>
186
+ </div>
136
187
  </div>
137
188
  {showAvatar ? (
138
189
  <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg">
@@ -64,6 +64,8 @@ export function ChatComposer({
64
64
  canSend,
65
65
  hasPendingRequests,
66
66
  input,
67
+ conversationMode,
68
+ setConversationMode,
67
69
  setInput,
68
70
  images,
69
71
  setImages,
@@ -104,6 +106,8 @@ export function ChatComposer({
104
106
  canSend: boolean;
105
107
  hasPendingRequests: boolean;
106
108
  input: string;
109
+ conversationMode: "chat" | "agent";
110
+ setConversationMode: (mode: "chat" | "agent") => void;
107
111
  setInput: Dispatch<SetStateAction<string>>;
108
112
  images: { mime_type: string; base64_data: string; file_name?: string }[];
109
113
  setImages: Dispatch<SetStateAction<{ mime_type: string; base64_data: string; file_name?: string }[]>>;
@@ -571,6 +575,27 @@ export function ChatComposer({
571
575
  <Plus className="h-4.5 w-4.5" />
572
576
  </Button>
573
577
 
578
+ <Button
579
+ type="button"
580
+ variant="ghost"
581
+ size="sm"
582
+ className={cn(
583
+ "h-8 rounded-xl px-2",
584
+ conversationMode === "chat"
585
+ ? "bg-white/35 text-foreground ring-1 ring-white/25"
586
+ : "text-muted-foreground hover:text-foreground",
587
+ "hover:bg-white/40"
588
+ )}
589
+ onClick={() => {
590
+ if (busy) return;
591
+ setConversationMode(conversationMode === "chat" ? "agent" : "chat");
592
+ }}
593
+ disabled={busy}
594
+ title={conversationMode === "chat" ? "Chat mode" : "Agent mode"}
595
+ >
596
+ {conversationMode === "chat" ? "Chat" : "Agent"}
597
+ </Button>
598
+
574
599
  <Button
575
600
  type="button"
576
601
  variant="ghost"