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.
- package/bin/cue-console.js +14 -10
- package/package.json +1 -1
- package/src/app/layout.tsx +2 -1
- package/src/app/page.tsx +10 -11
- package/src/app/providers.tsx +8 -0
- package/src/components/chat/message-bubble.tsx +24 -1
- package/src/components/chat/timeline-list.tsx +5 -322
- package/src/components/chat/user-response-bubble.tsx +63 -12
- package/src/components/chat-composer.tsx +25 -0
- package/src/components/chat-view.tsx +84 -34
- package/src/components/conversation-list.tsx +234 -11
- package/src/components/payload-card/form-view.tsx +2 -3
- package/src/contexts/config-context.tsx +77 -0
- package/src/contexts/input-context.tsx +26 -0
- package/src/hooks/use-conversation-timeline.ts +2 -1
- package/src/hooks/use-file-handler.ts +3 -1
- package/src/hooks/use-mentions.ts +1 -1
- package/src/hooks/use-message-sender.ts +14 -4
- package/src/lib/actions.ts +66 -0
- package/src/lib/avatar.ts +1 -1
- package/src/lib/chat-logic.ts +17 -2
- package/src/lib/db.ts +100 -2
- package/src/lib/error-handler.ts +1 -1
package/bin/cue-console.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
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
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
|
);
|
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(
|
|
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");
|
|
@@ -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
|
|
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";
|
|
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
|
|
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
|
|
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
|
|
27
|
-
|
|
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
|
|
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
|
-
{
|
|
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(
|
|
131
|
+
{renderTextWithMentions(displayText, parsed.mentions)}
|
|
93
132
|
</p>
|
|
94
133
|
) : (
|
|
95
|
-
<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
|
|
103
|
-
const
|
|
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
|
|
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
|
-
<
|
|
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"
|