cue-console 0.1.16 → 0.1.18
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/package.json +1 -1
- package/src/app/layout.tsx +2 -1
- package/src/app/providers.tsx +8 -0
- package/src/components/chat/timeline-list.tsx +5 -351
- package/src/components/chat/user-response-bubble.tsx +50 -5
- package/src/components/chat-composer.tsx +103 -1
- package/src/components/chat-view.tsx +161 -14
- package/src/components/conversation-list.tsx +124 -1
- package/src/contexts/config-context.tsx +90 -0
- package/src/hooks/use-message-sender.ts +4 -3
- package/src/lib/actions.ts +107 -12
- package/src/lib/chat-logic.ts +17 -2
- package/src/lib/db.ts +48 -0
- package/src/lib/user-config.ts +32 -0
package/package.json
CHANGED
package/src/app/layout.tsx
CHANGED
|
@@ -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
|
);
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { memo
|
|
3
|
+
import { memo } from "react";
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import type { AgentTimelineItem, CueRequest, CueResponse } from "@/lib/actions";
|
|
10
|
-
import { Copy, Check } from "lucide-react";
|
|
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";
|
|
11
9
|
|
|
12
10
|
function parseDbTime(dateStr: string) {
|
|
13
11
|
return new Date((dateStr || "").replace(" ", "T"));
|
|
@@ -153,347 +151,3 @@ export const TimelineList = memo(function TimelineList({
|
|
|
153
151
|
</>
|
|
154
152
|
);
|
|
155
153
|
});
|
|
156
|
-
|
|
157
|
-
const MessageBubble = memo(function MessageBubble({
|
|
158
|
-
request,
|
|
159
|
-
showAgent,
|
|
160
|
-
agentNameMap,
|
|
161
|
-
avatarUrlMap,
|
|
162
|
-
isHistory,
|
|
163
|
-
showName,
|
|
164
|
-
showAvatar,
|
|
165
|
-
compact,
|
|
166
|
-
disabled,
|
|
167
|
-
currentInput,
|
|
168
|
-
isGroup,
|
|
169
|
-
onPasteChoice,
|
|
170
|
-
onSubmitConfirm,
|
|
171
|
-
onMentionAgent,
|
|
172
|
-
onReply,
|
|
173
|
-
onCancel,
|
|
174
|
-
}: {
|
|
175
|
-
request: CueRequest;
|
|
176
|
-
showAgent?: boolean;
|
|
177
|
-
agentNameMap?: Record<string, string>;
|
|
178
|
-
avatarUrlMap?: Record<string, string>;
|
|
179
|
-
isHistory?: boolean;
|
|
180
|
-
showName?: boolean;
|
|
181
|
-
showAvatar?: boolean;
|
|
182
|
-
compact?: boolean;
|
|
183
|
-
disabled?: boolean;
|
|
184
|
-
currentInput?: string;
|
|
185
|
-
isGroup?: boolean;
|
|
186
|
-
onPasteChoice?: (text: string, mode?: "replace" | "append" | "upsert") => void;
|
|
187
|
-
onSubmitConfirm?: (
|
|
188
|
-
requestId: string,
|
|
189
|
-
text: string,
|
|
190
|
-
cancelled: boolean
|
|
191
|
-
) => void | Promise<void>;
|
|
192
|
-
onMentionAgent?: (agentId: string) => void;
|
|
193
|
-
onReply?: () => void;
|
|
194
|
-
onCancel?: () => void;
|
|
195
|
-
}) {
|
|
196
|
-
const isPending = request.status === "PENDING";
|
|
197
|
-
const [copied, setCopied] = useState(false);
|
|
198
|
-
|
|
199
|
-
const handleCopy = async () => {
|
|
200
|
-
try {
|
|
201
|
-
await navigator.clipboard.writeText(request.prompt || "");
|
|
202
|
-
setCopied(true);
|
|
203
|
-
setTimeout(() => setCopied(false), 2000);
|
|
204
|
-
} catch (err) {
|
|
205
|
-
console.error("Failed to copy:", err);
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
const isPause = useMemo(() => {
|
|
210
|
-
if (!request.payload) return false;
|
|
211
|
-
try {
|
|
212
|
-
const obj = JSON.parse(request.payload) as Record<string, unknown>;
|
|
213
|
-
return obj?.type === "confirm" && obj?.variant === "pause";
|
|
214
|
-
} catch {
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
}, [request.payload]);
|
|
218
|
-
|
|
219
|
-
const selectedLines = useMemo(() => {
|
|
220
|
-
const text = (currentInput || "").trim();
|
|
221
|
-
if (!text) return new Set<string>();
|
|
222
|
-
return new Set(
|
|
223
|
-
text
|
|
224
|
-
.split(/\r?\n/)
|
|
225
|
-
.map((s) => s.trim())
|
|
226
|
-
.filter(Boolean)
|
|
227
|
-
);
|
|
228
|
-
}, [currentInput]);
|
|
229
|
-
|
|
230
|
-
const rawId = request.agent_id || "";
|
|
231
|
-
const displayName = (agentNameMap && rawId ? agentNameMap[rawId] || rawId : rawId) || "";
|
|
232
|
-
const cardMaxWidth = (showAvatar ?? true) ? "calc(100% - 3rem)" : "100%";
|
|
233
|
-
const avatarUrl = rawId && avatarUrlMap ? avatarUrlMap[`agent:${rawId}`] : "";
|
|
234
|
-
|
|
235
|
-
return (
|
|
236
|
-
<div
|
|
237
|
-
className={cn(
|
|
238
|
-
"flex max-w-full min-w-0 items-start gap-3",
|
|
239
|
-
compact && "gap-2",
|
|
240
|
-
isHistory && "opacity-60"
|
|
241
|
-
)}
|
|
242
|
-
>
|
|
243
|
-
{(showAvatar ?? true) ? (
|
|
244
|
-
<span
|
|
245
|
-
className={cn(
|
|
246
|
-
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg",
|
|
247
|
-
isGroup && request.agent_id && onMentionAgent && "cursor-pointer"
|
|
248
|
-
)}
|
|
249
|
-
title={
|
|
250
|
-
isGroup && request.agent_id && onMentionAgent
|
|
251
|
-
? "Double-click avatar to @mention"
|
|
252
|
-
: undefined
|
|
253
|
-
}
|
|
254
|
-
onDoubleClick={() => {
|
|
255
|
-
if (!isGroup) return;
|
|
256
|
-
const agentId = request.agent_id;
|
|
257
|
-
if (!agentId) return;
|
|
258
|
-
onMentionAgent?.(agentId);
|
|
259
|
-
}}
|
|
260
|
-
>
|
|
261
|
-
{avatarUrl ? (
|
|
262
|
-
<img src={avatarUrl} alt="" className="h-full w-full rounded-full" />
|
|
263
|
-
) : (
|
|
264
|
-
getAgentEmoji(request.agent_id || "")
|
|
265
|
-
)}
|
|
266
|
-
</span>
|
|
267
|
-
) : (
|
|
268
|
-
<span className="h-9 w-9 shrink-0" />
|
|
269
|
-
)}
|
|
270
|
-
<div className="flex-1 min-w-0 overflow-hidden">
|
|
271
|
-
{(showName ?? true) && (showAgent || displayName) && (
|
|
272
|
-
<p className="mb-1 text-xs text-muted-foreground truncate">{displayName}</p>
|
|
273
|
-
)}
|
|
274
|
-
<div
|
|
275
|
-
className={cn(
|
|
276
|
-
"rounded-3xl p-3 sm:p-4 max-w-full flex-1 basis-0 min-w-0 overflow-hidden",
|
|
277
|
-
"glass-surface-soft glass-noise",
|
|
278
|
-
isPending ? "ring-1 ring-ring/25" : "ring-1 ring-white/25"
|
|
279
|
-
)}
|
|
280
|
-
style={{ clipPath: "inset(0 round 1rem)", maxWidth: cardMaxWidth }}
|
|
281
|
-
>
|
|
282
|
-
<div className="text-sm wrap-anywhere overflow-hidden min-w-0">
|
|
283
|
-
<MarkdownRenderer>{request.prompt || ""}</MarkdownRenderer>
|
|
284
|
-
</div>
|
|
285
|
-
<PayloadCard
|
|
286
|
-
raw={request.payload}
|
|
287
|
-
disabled={disabled}
|
|
288
|
-
onPasteChoice={onPasteChoice}
|
|
289
|
-
onSubmitConfirm={(text, cancelled) =>
|
|
290
|
-
isPending ? onSubmitConfirm?.(request.request_id, text, cancelled) : undefined
|
|
291
|
-
}
|
|
292
|
-
selectedLines={selectedLines}
|
|
293
|
-
/>
|
|
294
|
-
</div>
|
|
295
|
-
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
296
|
-
<span className="shrink-0">{formatFullTime(request.created_at || "")}</span>
|
|
297
|
-
<Button
|
|
298
|
-
variant="ghost"
|
|
299
|
-
size="sm"
|
|
300
|
-
className="h-auto p-1 text-xs"
|
|
301
|
-
onClick={handleCopy}
|
|
302
|
-
disabled={disabled}
|
|
303
|
-
title={copied ? "已复制" : "复制"}
|
|
304
|
-
>
|
|
305
|
-
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
|
306
|
-
<span className="ml-1">{copied ? "已复制" : "复制"}</span>
|
|
307
|
-
</Button>
|
|
308
|
-
{isPending && (
|
|
309
|
-
<>
|
|
310
|
-
<Badge variant="outline" className="text-xs shrink-0">
|
|
311
|
-
Waiting {getWaitingDuration(request.created_at || "")}
|
|
312
|
-
</Badge>
|
|
313
|
-
{!isPause && (
|
|
314
|
-
<>
|
|
315
|
-
<Badge variant="default" className="text-xs shrink-0">
|
|
316
|
-
Pending
|
|
317
|
-
</Badge>
|
|
318
|
-
{onReply && (
|
|
319
|
-
<Button
|
|
320
|
-
variant="link"
|
|
321
|
-
size="sm"
|
|
322
|
-
className="h-auto p-0 text-xs"
|
|
323
|
-
onClick={onReply}
|
|
324
|
-
disabled={disabled}
|
|
325
|
-
>
|
|
326
|
-
Reply
|
|
327
|
-
</Button>
|
|
328
|
-
)}
|
|
329
|
-
{onCancel && (
|
|
330
|
-
<Button
|
|
331
|
-
variant="link"
|
|
332
|
-
size="sm"
|
|
333
|
-
className="h-auto p-0 text-xs text-destructive"
|
|
334
|
-
onClick={onCancel}
|
|
335
|
-
disabled={disabled}
|
|
336
|
-
>
|
|
337
|
-
End
|
|
338
|
-
</Button>
|
|
339
|
-
)}
|
|
340
|
-
</>
|
|
341
|
-
)}
|
|
342
|
-
</>
|
|
343
|
-
)}
|
|
344
|
-
{request.status === "COMPLETED" && (
|
|
345
|
-
<Badge variant="secondary" className="text-xs shrink-0">
|
|
346
|
-
Replied
|
|
347
|
-
</Badge>
|
|
348
|
-
)}
|
|
349
|
-
{request.status === "CANCELLED" && (
|
|
350
|
-
<Badge variant="destructive" className="text-xs shrink-0">
|
|
351
|
-
Ended
|
|
352
|
-
</Badge>
|
|
353
|
-
)}
|
|
354
|
-
</div>
|
|
355
|
-
</div>
|
|
356
|
-
</div>
|
|
357
|
-
);
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
const UserResponseBubble = memo(function UserResponseBubble({
|
|
361
|
-
response,
|
|
362
|
-
showAvatar = true,
|
|
363
|
-
compact = false,
|
|
364
|
-
onPreview,
|
|
365
|
-
}: {
|
|
366
|
-
response: CueResponse;
|
|
367
|
-
showAvatar?: boolean;
|
|
368
|
-
compact?: boolean;
|
|
369
|
-
onPreview?: (img: { mime_type: string; base64_data: string }) => void;
|
|
370
|
-
}) {
|
|
371
|
-
const parsed = JSON.parse(response.response_json || "{}") as {
|
|
372
|
-
text?: string;
|
|
373
|
-
mentions?: { userId: string; start: number; length: number; display: string }[];
|
|
374
|
-
};
|
|
375
|
-
|
|
376
|
-
const filesRaw = (response as unknown as { files?: unknown }).files;
|
|
377
|
-
const files = Array.isArray(filesRaw) ? filesRaw : [];
|
|
378
|
-
const imageFiles = files.filter((f) => {
|
|
379
|
-
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
380
|
-
const mime = String(obj?.mime_type || "");
|
|
381
|
-
const b64 = obj?.inline_base64;
|
|
382
|
-
return mime.startsWith("image/") && typeof b64 === "string" && b64.length > 0;
|
|
383
|
-
});
|
|
384
|
-
const otherFiles = files.filter((f) => {
|
|
385
|
-
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
386
|
-
const mime = String(obj?.mime_type || "");
|
|
387
|
-
return !mime.startsWith("image/");
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
const renderTextWithMentions = (text: string, mentions?: { start: number; length: number }[]) => {
|
|
391
|
-
if (!mentions || mentions.length === 0) return text;
|
|
392
|
-
const safe = [...mentions]
|
|
393
|
-
.filter((m) => m.start >= 0 && m.length > 0 && m.start + m.length <= text.length)
|
|
394
|
-
.sort((a, b) => a.start - b.start);
|
|
395
|
-
|
|
396
|
-
const nodes: ReactNode[] = [];
|
|
397
|
-
let cursor = 0;
|
|
398
|
-
for (const m of safe) {
|
|
399
|
-
if (m.start < cursor) continue;
|
|
400
|
-
if (m.start > cursor) {
|
|
401
|
-
nodes.push(text.slice(cursor, m.start));
|
|
402
|
-
}
|
|
403
|
-
const seg = text.slice(m.start, m.start + m.length);
|
|
404
|
-
nodes.push(
|
|
405
|
-
<span key={`m-${m.start}`} className="text-emerald-900/90 dark:text-emerald-950 font-semibold">
|
|
406
|
-
{seg}
|
|
407
|
-
</span>
|
|
408
|
-
);
|
|
409
|
-
cursor = m.start + m.length;
|
|
410
|
-
}
|
|
411
|
-
if (cursor < text.length) nodes.push(text.slice(cursor));
|
|
412
|
-
return nodes;
|
|
413
|
-
};
|
|
414
|
-
|
|
415
|
-
if (response.cancelled) {
|
|
416
|
-
return (
|
|
417
|
-
<div className="flex justify-end gap-3 max-w-full min-w-0">
|
|
418
|
-
<div
|
|
419
|
-
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"
|
|
420
|
-
style={{
|
|
421
|
-
clipPath: "inset(0 round 1rem)",
|
|
422
|
-
maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
|
|
423
|
-
}}
|
|
424
|
-
>
|
|
425
|
-
<p className="text-sm text-muted-foreground italic">Conversation ended</p>
|
|
426
|
-
<p className="text-xs text-muted-foreground mt-1">{formatFullTime(response.created_at)}</p>
|
|
427
|
-
</div>
|
|
428
|
-
</div>
|
|
429
|
-
);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
return (
|
|
433
|
-
<div className={cn("flex justify-end gap-3 max-w-full min-w-0", compact && "gap-2")}>
|
|
434
|
-
<div
|
|
435
|
-
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"
|
|
436
|
-
style={{
|
|
437
|
-
clipPath: "inset(0 round 1rem)",
|
|
438
|
-
maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
|
|
439
|
-
}}
|
|
440
|
-
>
|
|
441
|
-
{parsed.text && (
|
|
442
|
-
<div className="text-sm wrap-anywhere overflow-hidden min-w-0">
|
|
443
|
-
{parsed.mentions && parsed.mentions.length > 0 ? (
|
|
444
|
-
<p className="whitespace-pre-wrap">{renderTextWithMentions(parsed.text, parsed.mentions)}</p>
|
|
445
|
-
) : (
|
|
446
|
-
<MarkdownRenderer>{parsed.text}</MarkdownRenderer>
|
|
447
|
-
)}
|
|
448
|
-
</div>
|
|
449
|
-
)}
|
|
450
|
-
{imageFiles.length > 0 && (
|
|
451
|
-
<div className="flex flex-wrap gap-2 mt-2 max-w-full">
|
|
452
|
-
{imageFiles.map((f, i) => {
|
|
453
|
-
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
454
|
-
const mime = String(obj?.mime_type || "image/png");
|
|
455
|
-
const b64 = String(obj?.inline_base64 || "");
|
|
456
|
-
const img = { mime_type: mime, base64_data: b64 };
|
|
457
|
-
return (
|
|
458
|
-
<img
|
|
459
|
-
key={i}
|
|
460
|
-
src={`data:${img.mime_type};base64,${img.base64_data}`}
|
|
461
|
-
alt=""
|
|
462
|
-
className="max-h-32 max-w-full h-auto rounded cursor-pointer"
|
|
463
|
-
onClick={() => onPreview?.(img)}
|
|
464
|
-
/>
|
|
465
|
-
);
|
|
466
|
-
})}
|
|
467
|
-
</div>
|
|
468
|
-
)}
|
|
469
|
-
|
|
470
|
-
{otherFiles.length > 0 && (
|
|
471
|
-
<div className="mt-2 flex flex-col gap-1 max-w-full">
|
|
472
|
-
{otherFiles.map((f, i) => {
|
|
473
|
-
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
474
|
-
const fileRef = String(obj?.file || "");
|
|
475
|
-
const name = fileRef.split("/").filter(Boolean).pop() || fileRef || "file";
|
|
476
|
-
return (
|
|
477
|
-
<div
|
|
478
|
-
key={i}
|
|
479
|
-
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"
|
|
480
|
-
title={fileRef}
|
|
481
|
-
>
|
|
482
|
-
{name}
|
|
483
|
-
</div>
|
|
484
|
-
);
|
|
485
|
-
})}
|
|
486
|
-
</div>
|
|
487
|
-
)}
|
|
488
|
-
<p className="text-xs opacity-70 mt-1 text-right">{formatFullTime(response.created_at)}</p>
|
|
489
|
-
</div>
|
|
490
|
-
{showAvatar ? (
|
|
491
|
-
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg">
|
|
492
|
-
👤
|
|
493
|
-
</span>
|
|
494
|
-
) : (
|
|
495
|
-
<span className="h-9 w-9 shrink-0" />
|
|
496
|
-
)}
|
|
497
|
-
</div>
|
|
498
|
-
);
|
|
499
|
-
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type
|
|
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,11 +17,45 @@ 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
|
|
|
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
|
+
|
|
24
59
|
const filesRaw = (response as unknown as { files?: unknown }).files;
|
|
25
60
|
const files = Array.isArray(filesRaw) ? filesRaw : [];
|
|
26
61
|
const imageFiles = files.filter((f) => {
|
|
@@ -89,14 +124,14 @@ export function UserResponseBubble({
|
|
|
89
124
|
maxWidth: showAvatar ? "calc(100% - 3rem)" : "100%",
|
|
90
125
|
}}
|
|
91
126
|
>
|
|
92
|
-
{
|
|
127
|
+
{displayText && (
|
|
93
128
|
<div className="text-sm wrap-anywhere overflow-hidden min-w-0">
|
|
94
129
|
{parsed.mentions && parsed.mentions.length > 0 ? (
|
|
95
130
|
<p className="whitespace-pre-wrap">
|
|
96
|
-
{renderTextWithMentions(
|
|
131
|
+
{renderTextWithMentions(displayText, parsed.mentions)}
|
|
97
132
|
</p>
|
|
98
133
|
) : (
|
|
99
|
-
<MarkdownRenderer>{
|
|
134
|
+
<MarkdownRenderer>{displayText}</MarkdownRenderer>
|
|
100
135
|
)}
|
|
101
136
|
</div>
|
|
102
137
|
)}
|
|
@@ -138,7 +173,17 @@ export function UserResponseBubble({
|
|
|
138
173
|
})}
|
|
139
174
|
</div>
|
|
140
175
|
)}
|
|
141
|
-
<
|
|
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>
|
|
142
187
|
</div>
|
|
143
188
|
{showAvatar ? (
|
|
144
189
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-lg">
|
|
@@ -12,9 +12,15 @@ import {
|
|
|
12
12
|
type SetStateAction,
|
|
13
13
|
} from "react";
|
|
14
14
|
import { Button } from "@/components/ui/button";
|
|
15
|
+
import {
|
|
16
|
+
Dialog,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogHeader,
|
|
19
|
+
DialogTitle,
|
|
20
|
+
} from "@/components/ui/dialog";
|
|
15
21
|
import { cn, getAgentEmoji } from "@/lib/utils";
|
|
16
22
|
import { setAgentDisplayName } from "@/lib/actions";
|
|
17
|
-
import { CornerUpLeft, GripVertical, Plus, Send, Trash2, X } from "lucide-react";
|
|
23
|
+
import { Bot, CornerUpLeft, GripVertical, Plus, Send, Trash2, X } from "lucide-react";
|
|
18
24
|
|
|
19
25
|
type MentionDraft = {
|
|
20
26
|
userId: string;
|
|
@@ -71,6 +77,8 @@ export function ChatComposer({
|
|
|
71
77
|
setImages,
|
|
72
78
|
setNotice,
|
|
73
79
|
setPreviewImage,
|
|
80
|
+
botEnabled,
|
|
81
|
+
onToggleBot,
|
|
74
82
|
handleSend,
|
|
75
83
|
enqueueCurrent,
|
|
76
84
|
queue,
|
|
@@ -113,6 +121,8 @@ export function ChatComposer({
|
|
|
113
121
|
setImages: Dispatch<SetStateAction<{ mime_type: string; base64_data: string; file_name?: string }[]>>;
|
|
114
122
|
setNotice: Dispatch<SetStateAction<string | null>>;
|
|
115
123
|
setPreviewImage: Dispatch<SetStateAction<{ mime_type: string; base64_data: string } | null>>;
|
|
124
|
+
botEnabled: boolean;
|
|
125
|
+
onToggleBot: () => Promise<boolean>;
|
|
116
126
|
handleSend: () => void | Promise<void>;
|
|
117
127
|
enqueueCurrent: () => void;
|
|
118
128
|
queue: QueuedMessage[];
|
|
@@ -152,6 +162,8 @@ export function ChatComposer({
|
|
|
152
162
|
}, [onBack]);
|
|
153
163
|
|
|
154
164
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
165
|
+
const [botToggling, setBotToggling] = useState(false);
|
|
166
|
+
const [botConfirmOpen, setBotConfirmOpen] = useState(false);
|
|
155
167
|
const isComposingRef = useRef(false);
|
|
156
168
|
|
|
157
169
|
const submitOrQueue = () => {
|
|
@@ -251,6 +263,54 @@ export function ChatComposer({
|
|
|
251
263
|
</div>
|
|
252
264
|
)}
|
|
253
265
|
|
|
266
|
+
<Dialog open={botConfirmOpen} onOpenChange={setBotConfirmOpen}>
|
|
267
|
+
<DialogContent className="sm:max-w-110">
|
|
268
|
+
<DialogHeader>
|
|
269
|
+
<DialogTitle>Enable bot mode?</DialogTitle>
|
|
270
|
+
</DialogHeader>
|
|
271
|
+
|
|
272
|
+
<div className="text-sm text-muted-foreground space-y-3">
|
|
273
|
+
<p>
|
|
274
|
+
Bot mode will automatically reply to <span className="text-foreground font-medium">cue</span> requests
|
|
275
|
+
in this conversation.
|
|
276
|
+
</p>
|
|
277
|
+
<ul className="list-disc pl-5 space-y-1">
|
|
278
|
+
<li>Only affects the current {type === "group" ? "group" : "agent"} conversation.</li>
|
|
279
|
+
<li>May immediately reply to currently pending cue requests.</li>
|
|
280
|
+
<li>Does not reply to pause confirmations.</li>
|
|
281
|
+
<li>You can turn it off anytime.</li>
|
|
282
|
+
</ul>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<div className="mt-4 flex items-center justify-end gap-2">
|
|
286
|
+
<Button
|
|
287
|
+
type="button"
|
|
288
|
+
variant="outline"
|
|
289
|
+
disabled={botToggling}
|
|
290
|
+
onClick={() => setBotConfirmOpen(false)}
|
|
291
|
+
>
|
|
292
|
+
Cancel
|
|
293
|
+
</Button>
|
|
294
|
+
<Button
|
|
295
|
+
type="button"
|
|
296
|
+
disabled={botToggling}
|
|
297
|
+
onClick={async () => {
|
|
298
|
+
if (botToggling) return;
|
|
299
|
+
setBotToggling(true);
|
|
300
|
+
try {
|
|
301
|
+
await onToggleBot();
|
|
302
|
+
setBotConfirmOpen(false);
|
|
303
|
+
} finally {
|
|
304
|
+
setBotToggling(false);
|
|
305
|
+
}
|
|
306
|
+
}}
|
|
307
|
+
>
|
|
308
|
+
{botToggling ? "Enabling…" : "Enable"}
|
|
309
|
+
</Button>
|
|
310
|
+
</div>
|
|
311
|
+
</DialogContent>
|
|
312
|
+
</Dialog>
|
|
313
|
+
|
|
254
314
|
{/* Image Preview */}
|
|
255
315
|
{images.length > 0 && (
|
|
256
316
|
<div className="flex max-w-full gap-2 overflow-x-auto px-0.5 pt-0.5">
|
|
@@ -614,6 +674,48 @@ export function ChatComposer({
|
|
|
614
674
|
>
|
|
615
675
|
Queue
|
|
616
676
|
</Button>
|
|
677
|
+
|
|
678
|
+
<div className="relative group">
|
|
679
|
+
<Button
|
|
680
|
+
type="button"
|
|
681
|
+
variant="ghost"
|
|
682
|
+
size="icon"
|
|
683
|
+
disabled={busy || botToggling}
|
|
684
|
+
className={cn(
|
|
685
|
+
"relative h-9 w-9 rounded-2xl",
|
|
686
|
+
"hover:bg-white/40",
|
|
687
|
+
botEnabled ? "text-primary" : "text-muted-foreground",
|
|
688
|
+
(busy || botToggling) && "opacity-60 cursor-not-allowed"
|
|
689
|
+
)}
|
|
690
|
+
onClick={async () => {
|
|
691
|
+
if (busy || botToggling) return;
|
|
692
|
+
if (!botEnabled) {
|
|
693
|
+
setBotConfirmOpen(true);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
setBotToggling(true);
|
|
697
|
+
try {
|
|
698
|
+
await onToggleBot();
|
|
699
|
+
} finally {
|
|
700
|
+
setBotToggling(false);
|
|
701
|
+
}
|
|
702
|
+
}}
|
|
703
|
+
aria-label={botEnabled ? "Stop bot" : "Start bot"}
|
|
704
|
+
title={botToggling ? "Turning…" : botEnabled ? "Stop bot" : "Start bot"}
|
|
705
|
+
>
|
|
706
|
+
{botEnabled && (
|
|
707
|
+
<span className="pointer-events-none absolute inset-0 rounded-xl">
|
|
708
|
+
<span className="absolute inset-0 rounded-2xl bg-primary/15 blur-md animate-pulse" />
|
|
709
|
+
</span>
|
|
710
|
+
)}
|
|
711
|
+
<Bot
|
|
712
|
+
className={cn(
|
|
713
|
+
"relative z-10 h-5 w-5",
|
|
714
|
+
botEnabled && "drop-shadow-[0_0_12px_rgba(99,102,241,0.45)]"
|
|
715
|
+
)}
|
|
716
|
+
/>
|
|
717
|
+
</Button>
|
|
718
|
+
</div>
|
|
617
719
|
</div>
|
|
618
720
|
|
|
619
721
|
<Button
|