cue-console 0.1.14 → 0.1.16
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/page.tsx +10 -11
- package/src/components/chat/message-bubble.tsx +24 -1
- package/src/components/chat/timeline-list.tsx +37 -8
- package/src/components/chat/user-response-bubble.tsx +13 -7
- package/src/components/chat-composer.tsx +34 -1
- package/src/components/chat-view.tsx +23 -21
- package/src/components/conversation-list.tsx +156 -11
- package/src/components/payload-card/form-view.tsx +2 -3
- 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 +13 -4
- package/src/lib/actions.ts +32 -0
- package/src/lib/avatar.ts +1 -1
- package/src/lib/db.ts +52 -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/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,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { memo, useMemo, type ReactNode } from "react";
|
|
3
|
+
import { memo, useMemo, useState, type ReactNode } from "react";
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
5
|
import { Badge } from "@/components/ui/badge";
|
|
6
6
|
import { cn, formatFullTime, getAgentEmoji, getWaitingDuration } from "@/lib/utils";
|
|
7
7
|
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
|
8
8
|
import { PayloadCard } from "@/components/payload-card";
|
|
9
9
|
import type { AgentTimelineItem, CueRequest, CueResponse } from "@/lib/actions";
|
|
10
|
+
import { Copy, Check } from "lucide-react";
|
|
10
11
|
|
|
11
12
|
function parseDbTime(dateStr: string) {
|
|
12
13
|
return new Date((dateStr || "").replace(" ", "T"));
|
|
@@ -193,6 +194,17 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
193
194
|
onCancel?: () => void;
|
|
194
195
|
}) {
|
|
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
|
+
};
|
|
196
208
|
|
|
197
209
|
const isPause = useMemo(() => {
|
|
198
210
|
if (!request.payload) return false;
|
|
@@ -282,6 +294,17 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
282
294
|
</div>
|
|
283
295
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
284
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>
|
|
285
308
|
{isPending && (
|
|
286
309
|
<>
|
|
287
310
|
<Badge variant="outline" className="text-xs shrink-0">
|
|
@@ -350,13 +373,17 @@ const UserResponseBubble = memo(function UserResponseBubble({
|
|
|
350
373
|
mentions?: { userId: string; start: number; length: number; display: string }[];
|
|
351
374
|
};
|
|
352
375
|
|
|
353
|
-
const
|
|
376
|
+
const filesRaw = (response as unknown as { files?: unknown }).files;
|
|
377
|
+
const files = Array.isArray(filesRaw) ? filesRaw : [];
|
|
354
378
|
const imageFiles = files.filter((f) => {
|
|
355
|
-
const
|
|
356
|
-
|
|
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;
|
|
357
383
|
});
|
|
358
384
|
const otherFiles = files.filter((f) => {
|
|
359
|
-
const
|
|
385
|
+
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
386
|
+
const mime = String(obj?.mime_type || "");
|
|
360
387
|
return !mime.startsWith("image/");
|
|
361
388
|
});
|
|
362
389
|
|
|
@@ -423,8 +450,9 @@ const UserResponseBubble = memo(function UserResponseBubble({
|
|
|
423
450
|
{imageFiles.length > 0 && (
|
|
424
451
|
<div className="flex flex-wrap gap-2 mt-2 max-w-full">
|
|
425
452
|
{imageFiles.map((f, i) => {
|
|
426
|
-
const
|
|
427
|
-
const
|
|
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 || "");
|
|
428
456
|
const img = { mime_type: mime, base64_data: b64 };
|
|
429
457
|
return (
|
|
430
458
|
<img
|
|
@@ -442,7 +470,8 @@ const UserResponseBubble = memo(function UserResponseBubble({
|
|
|
442
470
|
{otherFiles.length > 0 && (
|
|
443
471
|
<div className="mt-2 flex flex-col gap-1 max-w-full">
|
|
444
472
|
{otherFiles.map((f, i) => {
|
|
445
|
-
const
|
|
473
|
+
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
474
|
+
const fileRef = String(obj?.file || "");
|
|
446
475
|
const name = fileRef.split("/").filter(Boolean).pop() || fileRef || "file";
|
|
447
476
|
return (
|
|
448
477
|
<div
|
|
@@ -21,13 +21,17 @@ export function UserResponseBubble({
|
|
|
21
21
|
mentions?: { userId: string; start: number; length: number; display: string }[];
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
const
|
|
24
|
+
const filesRaw = (response as unknown as { files?: unknown }).files;
|
|
25
|
+
const files = Array.isArray(filesRaw) ? filesRaw : [];
|
|
25
26
|
const imageFiles = files.filter((f) => {
|
|
26
|
-
const
|
|
27
|
-
|
|
27
|
+
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
28
|
+
const mime = String(obj?.mime_type || "");
|
|
29
|
+
const b64 = obj?.inline_base64;
|
|
30
|
+
return mime.startsWith("image/") && typeof b64 === "string" && b64.length > 0;
|
|
28
31
|
});
|
|
29
32
|
const otherFiles = files.filter((f) => {
|
|
30
|
-
const
|
|
33
|
+
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
34
|
+
const mime = String(obj?.mime_type || "");
|
|
31
35
|
return !mime.startsWith("image/");
|
|
32
36
|
});
|
|
33
37
|
|
|
@@ -99,8 +103,9 @@ export function UserResponseBubble({
|
|
|
99
103
|
{imageFiles.length > 0 && (
|
|
100
104
|
<div className="flex flex-wrap gap-2 mt-2 max-w-full">
|
|
101
105
|
{imageFiles.map((f, i) => {
|
|
102
|
-
const
|
|
103
|
-
const
|
|
106
|
+
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
107
|
+
const mime = String(obj?.mime_type || "image/png");
|
|
108
|
+
const b64 = String(obj?.inline_base64 || "");
|
|
104
109
|
const img = { mime_type: mime, base64_data: b64 };
|
|
105
110
|
return (
|
|
106
111
|
<img
|
|
@@ -118,7 +123,8 @@ export function UserResponseBubble({
|
|
|
118
123
|
{otherFiles.length > 0 && (
|
|
119
124
|
<div className="mt-2 flex flex-col gap-1 max-w-full">
|
|
120
125
|
{otherFiles.map((f, i) => {
|
|
121
|
-
const
|
|
126
|
+
const obj = f && typeof f === "object" ? (f as Record<string, unknown>) : null;
|
|
127
|
+
const fileRef = String(obj?.file || "");
|
|
122
128
|
const name = fileRef.split("/").filter(Boolean).pop() || fileRef || "file";
|
|
123
129
|
return (
|
|
124
130
|
<div
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
useMemo,
|
|
5
|
+
useRef,
|
|
5
6
|
useState,
|
|
6
7
|
type ChangeEvent,
|
|
7
8
|
type ClipboardEvent,
|
|
@@ -63,6 +64,8 @@ export function ChatComposer({
|
|
|
63
64
|
canSend,
|
|
64
65
|
hasPendingRequests,
|
|
65
66
|
input,
|
|
67
|
+
conversationMode,
|
|
68
|
+
setConversationMode,
|
|
66
69
|
setInput,
|
|
67
70
|
images,
|
|
68
71
|
setImages,
|
|
@@ -103,6 +106,8 @@ export function ChatComposer({
|
|
|
103
106
|
canSend: boolean;
|
|
104
107
|
hasPendingRequests: boolean;
|
|
105
108
|
input: string;
|
|
109
|
+
conversationMode: "chat" | "agent";
|
|
110
|
+
setConversationMode: (mode: "chat" | "agent") => void;
|
|
106
111
|
setInput: Dispatch<SetStateAction<string>>;
|
|
107
112
|
images: { mime_type: string; base64_data: string; file_name?: string }[];
|
|
108
113
|
setImages: Dispatch<SetStateAction<{ mime_type: string; base64_data: string; file_name?: string }[]>>;
|
|
@@ -147,6 +152,7 @@ export function ChatComposer({
|
|
|
147
152
|
}, [onBack]);
|
|
148
153
|
|
|
149
154
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
155
|
+
const isComposingRef = useRef(false);
|
|
150
156
|
|
|
151
157
|
const submitOrQueue = () => {
|
|
152
158
|
if (busy) return;
|
|
@@ -412,6 +418,12 @@ export function ChatComposer({
|
|
|
412
418
|
}
|
|
413
419
|
value={input}
|
|
414
420
|
onPaste={handlePaste}
|
|
421
|
+
onCompositionStart={() => {
|
|
422
|
+
isComposingRef.current = true;
|
|
423
|
+
}}
|
|
424
|
+
onCompositionEnd={() => {
|
|
425
|
+
isComposingRef.current = false;
|
|
426
|
+
}}
|
|
415
427
|
onChange={(e) => {
|
|
416
428
|
const next = e.target.value;
|
|
417
429
|
setInput(next);
|
|
@@ -508,7 +520,7 @@ export function ChatComposer({
|
|
|
508
520
|
}
|
|
509
521
|
}
|
|
510
522
|
|
|
511
|
-
if (e.key === "Enter" && !e.shiftKey) {
|
|
523
|
+
if (e.key === "Enter" && !e.shiftKey && !isComposingRef.current) {
|
|
512
524
|
e.preventDefault();
|
|
513
525
|
submitOrQueue();
|
|
514
526
|
}
|
|
@@ -563,6 +575,27 @@ export function ChatComposer({
|
|
|
563
575
|
<Plus className="h-4.5 w-4.5" />
|
|
564
576
|
</Button>
|
|
565
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
|
+
|
|
566
599
|
<Button
|
|
567
600
|
type="button"
|
|
568
601
|
variant="ghost"
|
|
@@ -77,7 +77,7 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
|
|
|
77
77
|
{ mime_type: string; base64_data: string } | null
|
|
78
78
|
>(null);
|
|
79
79
|
|
|
80
|
-
const { input, images, setInput, setImages } = useInputContext();
|
|
80
|
+
const { input, images, conversationMode, setConversationMode, setInput, setImages } = useInputContext();
|
|
81
81
|
const { busy, error, notice, setBusy, setError, setNotice } = useUIStateContext();
|
|
82
82
|
const deferredInput = useDeferredValue(input);
|
|
83
83
|
const imagesRef = useRef(images);
|
|
@@ -179,7 +179,7 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
|
|
|
179
179
|
|
|
180
180
|
useEffect(() => {
|
|
181
181
|
if (type !== "group") return;
|
|
182
|
-
setGroupTitle(name);
|
|
182
|
+
queueMicrotask(() => setGroupTitle(name));
|
|
183
183
|
}, [name, type]);
|
|
184
184
|
|
|
185
185
|
const {
|
|
@@ -372,6 +372,25 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
|
|
|
372
372
|
setMentions([]);
|
|
373
373
|
}, [type, id]);
|
|
374
374
|
|
|
375
|
+
const loadMore = useCallback(async () => {
|
|
376
|
+
if (!nextCursor) return;
|
|
377
|
+
if (loadingMore) return;
|
|
378
|
+
|
|
379
|
+
const el = scrollRef.current;
|
|
380
|
+
const prevScrollHeight = el?.scrollHeight ?? 0;
|
|
381
|
+
const prevScrollTop = el?.scrollTop ?? 0;
|
|
382
|
+
|
|
383
|
+
const res = await loadMorePage(nextCursor);
|
|
384
|
+
requestAnimationFrame(() => {
|
|
385
|
+
const cur = scrollRef.current;
|
|
386
|
+
if (!cur) return;
|
|
387
|
+
const newScrollHeight = cur.scrollHeight;
|
|
388
|
+
cur.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
nextCursorRef.current = res.cursor;
|
|
392
|
+
}, [loadMorePage, loadingMore, nextCursor]);
|
|
393
|
+
|
|
375
394
|
useEffect(() => {
|
|
376
395
|
const el = scrollRef.current;
|
|
377
396
|
if (!el) return;
|
|
@@ -403,25 +422,6 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
|
|
|
403
422
|
el.scrollTop = el.scrollHeight;
|
|
404
423
|
}, [timeline, isAtBottom]);
|
|
405
424
|
|
|
406
|
-
const loadMore = useCallback(async () => {
|
|
407
|
-
if (!nextCursor) return;
|
|
408
|
-
if (loadingMore) return;
|
|
409
|
-
|
|
410
|
-
const el = scrollRef.current;
|
|
411
|
-
const prevScrollHeight = el?.scrollHeight ?? 0;
|
|
412
|
-
const prevScrollTop = el?.scrollTop ?? 0;
|
|
413
|
-
|
|
414
|
-
const res = await loadMorePage(nextCursor);
|
|
415
|
-
requestAnimationFrame(() => {
|
|
416
|
-
const cur = scrollRef.current;
|
|
417
|
-
if (!cur) return;
|
|
418
|
-
const newScrollHeight = cur.scrollHeight;
|
|
419
|
-
cur.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight);
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
nextCursorRef.current = res.cursor;
|
|
423
|
-
}, [loadMorePage, loadingMore, nextCursor]);
|
|
424
|
-
|
|
425
425
|
const handleSubmitConfirm = useCallback(async (requestId: string, text: string, cancelled: boolean) => {
|
|
426
426
|
if (busy) return;
|
|
427
427
|
setBusy(true);
|
|
@@ -611,6 +611,8 @@ function ChatViewContent({ type, id, name, onBack }: ChatViewProps) {
|
|
|
611
611
|
canSend={canSend}
|
|
612
612
|
hasPendingRequests={hasPendingRequests}
|
|
613
613
|
input={input}
|
|
614
|
+
conversationMode={conversationMode}
|
|
615
|
+
setConversationMode={setConversationMode}
|
|
614
616
|
setInput={setInput}
|
|
615
617
|
images={images}
|
|
616
618
|
setImages={setImages}
|
|
@@ -26,8 +26,11 @@ import {
|
|
|
26
26
|
deleteConversations,
|
|
27
27
|
fetchArchivedConversationCount,
|
|
28
28
|
fetchConversationList,
|
|
29
|
+
fetchPinnedConversationKeys,
|
|
29
30
|
getUserConfig,
|
|
31
|
+
pinConversationByKey,
|
|
30
32
|
setUserConfig,
|
|
33
|
+
unpinConversationByKey,
|
|
31
34
|
unarchiveConversations,
|
|
32
35
|
type ConversationItem,
|
|
33
36
|
} from "@/lib/actions";
|
|
@@ -42,6 +45,7 @@ import {
|
|
|
42
45
|
ChevronLeft,
|
|
43
46
|
ChevronRight,
|
|
44
47
|
MoreHorizontal,
|
|
48
|
+
Pin,
|
|
45
49
|
Settings,
|
|
46
50
|
X,
|
|
47
51
|
} from "lucide-react";
|
|
@@ -60,6 +64,14 @@ function conversationKey(item: Pick<ConversationItem, "type" | "id">) {
|
|
|
60
64
|
return `${item.type}:${item.id}`;
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
type IdleCallbackHandle = number;
|
|
68
|
+
type IdleRequestCallback = () => void;
|
|
69
|
+
type IdleRequestOptions = { timeout?: number };
|
|
70
|
+
type GlobalWithIdleCallbacks = typeof globalThis & {
|
|
71
|
+
requestIdleCallback?: (cb: IdleRequestCallback, opts?: IdleRequestOptions) => IdleCallbackHandle;
|
|
72
|
+
cancelIdleCallback?: (handle: IdleCallbackHandle) => void;
|
|
73
|
+
};
|
|
74
|
+
|
|
63
75
|
interface ConversationListProps {
|
|
64
76
|
selectedId: string | null;
|
|
65
77
|
selectedType: "agent" | "group" | null;
|
|
@@ -103,18 +115,36 @@ export function ConversationList({
|
|
|
103
115
|
|
|
104
116
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
105
117
|
const [soundEnabled, setSoundEnabled] = useState(true);
|
|
118
|
+
const [conversationModeDefault, setConversationModeDefault] = useState<"chat" | "agent">("agent");
|
|
119
|
+
const [pinnedKeys, setPinnedKeys] = useState<string[]>([]);
|
|
106
120
|
|
|
107
121
|
useEffect(() => {
|
|
108
122
|
void (async () => {
|
|
109
123
|
try {
|
|
110
124
|
const cfg = await getUserConfig();
|
|
111
125
|
setSoundEnabled(Boolean(cfg.sound_enabled));
|
|
126
|
+
const nextMode = cfg.conversation_mode_default === "chat" ? "chat" : "agent";
|
|
127
|
+
setConversationModeDefault(nextMode);
|
|
128
|
+
try {
|
|
129
|
+
window.localStorage.setItem("cue-console:conversationModeDefault", nextMode);
|
|
130
|
+
} catch {
|
|
131
|
+
}
|
|
112
132
|
} catch {
|
|
113
|
-
// ignore
|
|
114
133
|
}
|
|
115
134
|
})();
|
|
116
135
|
}, []);
|
|
117
136
|
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
void (async () => {
|
|
139
|
+
try {
|
|
140
|
+
const keys = await fetchPinnedConversationKeys(view);
|
|
141
|
+
setPinnedKeys(Array.isArray(keys) ? keys : []);
|
|
142
|
+
} catch {
|
|
143
|
+
setPinnedKeys([]);
|
|
144
|
+
}
|
|
145
|
+
})();
|
|
146
|
+
}, [view]);
|
|
147
|
+
|
|
118
148
|
const ensureAvatarUrl = useCallback(async (kind: "agent" | "group", rawId: string) => {
|
|
119
149
|
if (!rawId) return;
|
|
120
150
|
const t0 = perfEnabled() ? performance.now() : 0;
|
|
@@ -254,14 +284,16 @@ export function ConversationList({
|
|
|
254
284
|
}
|
|
255
285
|
|
|
256
286
|
const scheduleIdle = (fn: () => void) => {
|
|
257
|
-
|
|
258
|
-
|
|
287
|
+
const g = globalThis as GlobalWithIdleCallbacks;
|
|
288
|
+
if (typeof g.requestIdleCallback === "function") {
|
|
289
|
+
return g.requestIdleCallback(fn, { timeout: 1000 });
|
|
259
290
|
}
|
|
260
291
|
return window.setTimeout(fn, 60);
|
|
261
292
|
};
|
|
262
293
|
const cancelIdle = (handle: number) => {
|
|
263
|
-
|
|
264
|
-
|
|
294
|
+
const g = globalThis as GlobalWithIdleCallbacks;
|
|
295
|
+
if (typeof g.cancelIdleCallback === "function") {
|
|
296
|
+
g.cancelIdleCallback(handle);
|
|
265
297
|
return;
|
|
266
298
|
}
|
|
267
299
|
window.clearTimeout(handle);
|
|
@@ -356,8 +388,30 @@ export function ConversationList({
|
|
|
356
388
|
item.displayName.toLowerCase().includes(search.toLowerCase())
|
|
357
389
|
);
|
|
358
390
|
|
|
359
|
-
const
|
|
360
|
-
|
|
391
|
+
const pinnedSet = useMemo(() => new Set(pinnedKeys), [pinnedKeys]);
|
|
392
|
+
|
|
393
|
+
const groupsAll = filtered.filter((i) => i.type === "group");
|
|
394
|
+
const agentsAll = filtered.filter((i) => i.type === "agent");
|
|
395
|
+
|
|
396
|
+
const groups = useMemo(() => {
|
|
397
|
+
const byKey = new Map(groupsAll.map((g) => [conversationKey(g), g] as const));
|
|
398
|
+
const pinned = pinnedKeys
|
|
399
|
+
.filter((k) => k.startsWith("group:"))
|
|
400
|
+
.map((k) => byKey.get(k))
|
|
401
|
+
.filter(Boolean) as ConversationItem[];
|
|
402
|
+
const rest = groupsAll.filter((g) => !pinnedSet.has(conversationKey(g)));
|
|
403
|
+
return [...pinned, ...rest];
|
|
404
|
+
}, [groupsAll, pinnedKeys, pinnedSet]);
|
|
405
|
+
|
|
406
|
+
const agents = useMemo(() => {
|
|
407
|
+
const byKey = new Map(agentsAll.map((a) => [conversationKey(a), a] as const));
|
|
408
|
+
const pinned = pinnedKeys
|
|
409
|
+
.filter((k) => k.startsWith("agent:"))
|
|
410
|
+
.map((k) => byKey.get(k))
|
|
411
|
+
.filter(Boolean) as ConversationItem[];
|
|
412
|
+
const rest = agentsAll.filter((a) => !pinnedSet.has(conversationKey(a)));
|
|
413
|
+
return [...pinned, ...rest];
|
|
414
|
+
}, [agentsAll, pinnedKeys, pinnedSet]);
|
|
361
415
|
|
|
362
416
|
const groupsPendingTotal = groups.reduce((sum, g) => sum + g.pendingCount, 0);
|
|
363
417
|
const agentsPendingTotal = agents.reduce((sum, a) => sum + a.pendingCount, 0);
|
|
@@ -445,7 +499,7 @@ export function ConversationList({
|
|
|
445
499
|
|
|
446
500
|
useEffect(() => {
|
|
447
501
|
if (pendingDelete.length > 0) {
|
|
448
|
-
setUndoToastKey((v) => v + 1);
|
|
502
|
+
queueMicrotask(() => setUndoToastKey((v) => v + 1));
|
|
449
503
|
}
|
|
450
504
|
}, [pendingDelete.length]);
|
|
451
505
|
|
|
@@ -768,6 +822,7 @@ export function ConversationList({
|
|
|
768
822
|
item={item}
|
|
769
823
|
avatarUrl={avatarUrlMap[`group:${item.id}`]}
|
|
770
824
|
isSelected={selectedId === item.id && selectedType === "group"}
|
|
825
|
+
isPinned={pinnedSet.has(conversationKey(item))}
|
|
771
826
|
bulkMode={bulkMode}
|
|
772
827
|
checked={selectedKeys.has(conversationKey(item))}
|
|
773
828
|
onToggleChecked={() => toggleSelected(conversationKey(item))}
|
|
@@ -810,6 +865,7 @@ export function ConversationList({
|
|
|
810
865
|
item={item}
|
|
811
866
|
avatarUrl={avatarUrlMap[`agent:${item.id}`]}
|
|
812
867
|
isSelected={selectedId === item.id && selectedType === "agent"}
|
|
868
|
+
isPinned={pinnedSet.has(conversationKey(item))}
|
|
813
869
|
bulkMode={bulkMode}
|
|
814
870
|
checked={selectedKeys.has(conversationKey(item))}
|
|
815
871
|
onToggleChecked={() => toggleSelected(conversationKey(item))}
|
|
@@ -934,6 +990,32 @@ export function ConversationList({
|
|
|
934
990
|
style={{ left: menu.x, top: menu.y }}
|
|
935
991
|
onPointerDown={(e) => e.stopPropagation()}
|
|
936
992
|
>
|
|
993
|
+
{(() => {
|
|
994
|
+
const isPinned = pinnedSet.has(menu.key);
|
|
995
|
+
return (
|
|
996
|
+
<button
|
|
997
|
+
className="w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-accent"
|
|
998
|
+
onClick={async () => {
|
|
999
|
+
const key = menu.key;
|
|
1000
|
+
setMenu({ open: false });
|
|
1001
|
+
if (!key) return;
|
|
1002
|
+
if (isPinned) {
|
|
1003
|
+
await unpinConversationByKey(key, view);
|
|
1004
|
+
setPinnedKeys((prev) => prev.filter((k) => k !== key));
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
await pinConversationByKey(key, view);
|
|
1008
|
+
setPinnedKeys((prev) => (prev.includes(key) ? prev : [...prev, key]));
|
|
1009
|
+
}}
|
|
1010
|
+
>
|
|
1011
|
+
<span className="inline-flex items-center gap-2">
|
|
1012
|
+
<Pin className="h-4 w-4" />
|
|
1013
|
+
{isPinned ? "Unpin" : "Pin"}
|
|
1014
|
+
</span>
|
|
1015
|
+
</button>
|
|
1016
|
+
);
|
|
1017
|
+
})()}
|
|
1018
|
+
|
|
937
1019
|
{view === "archived" ? (
|
|
938
1020
|
<button
|
|
939
1021
|
className="w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-accent disabled:opacity-50"
|
|
@@ -1031,7 +1113,6 @@ export function ConversationList({
|
|
|
1031
1113
|
try {
|
|
1032
1114
|
await setUserConfig({ sound_enabled: next });
|
|
1033
1115
|
} catch {
|
|
1034
|
-
// ignore
|
|
1035
1116
|
}
|
|
1036
1117
|
window.dispatchEvent(
|
|
1037
1118
|
new CustomEvent("cue-console:configUpdated", {
|
|
@@ -1043,6 +1124,63 @@ export function ConversationList({
|
|
|
1043
1124
|
{soundEnabled ? "On" : "Off"}
|
|
1044
1125
|
</Button>
|
|
1045
1126
|
</div>
|
|
1127
|
+
|
|
1128
|
+
<div className="mt-4 flex items-center justify-between gap-3">
|
|
1129
|
+
<div className="min-w-0">
|
|
1130
|
+
<div className="text-sm font-medium">Default conversation mode</div>
|
|
1131
|
+
<div className="text-xs text-muted-foreground truncate">
|
|
1132
|
+
Used when opening cue-console (unless a last-used mode exists)
|
|
1133
|
+
</div>
|
|
1134
|
+
</div>
|
|
1135
|
+
<div className="flex items-center gap-2">
|
|
1136
|
+
<Button
|
|
1137
|
+
type="button"
|
|
1138
|
+
variant={conversationModeDefault === "chat" ? "default" : "outline"}
|
|
1139
|
+
onClick={async () => {
|
|
1140
|
+
const next = "chat" as const;
|
|
1141
|
+
setConversationModeDefault(next);
|
|
1142
|
+
try {
|
|
1143
|
+
await setUserConfig({ conversation_mode_default: next });
|
|
1144
|
+
} catch {
|
|
1145
|
+
}
|
|
1146
|
+
try {
|
|
1147
|
+
window.localStorage.setItem("cue-console:conversationModeDefault", next);
|
|
1148
|
+
} catch {
|
|
1149
|
+
}
|
|
1150
|
+
window.dispatchEvent(
|
|
1151
|
+
new CustomEvent("cue-console:configUpdated", {
|
|
1152
|
+
detail: { conversation_mode_default: next },
|
|
1153
|
+
})
|
|
1154
|
+
);
|
|
1155
|
+
}}
|
|
1156
|
+
>
|
|
1157
|
+
Chat
|
|
1158
|
+
</Button>
|
|
1159
|
+
<Button
|
|
1160
|
+
type="button"
|
|
1161
|
+
variant={conversationModeDefault === "agent" ? "default" : "outline"}
|
|
1162
|
+
onClick={async () => {
|
|
1163
|
+
const next = "agent" as const;
|
|
1164
|
+
setConversationModeDefault(next);
|
|
1165
|
+
try {
|
|
1166
|
+
await setUserConfig({ conversation_mode_default: next });
|
|
1167
|
+
} catch {
|
|
1168
|
+
}
|
|
1169
|
+
try {
|
|
1170
|
+
window.localStorage.setItem("cue-console:conversationModeDefault", next);
|
|
1171
|
+
} catch {
|
|
1172
|
+
}
|
|
1173
|
+
window.dispatchEvent(
|
|
1174
|
+
new CustomEvent("cue-console:configUpdated", {
|
|
1175
|
+
detail: { conversation_mode_default: next },
|
|
1176
|
+
})
|
|
1177
|
+
);
|
|
1178
|
+
}}
|
|
1179
|
+
>
|
|
1180
|
+
Agent
|
|
1181
|
+
</Button>
|
|
1182
|
+
</div>
|
|
1183
|
+
</div>
|
|
1046
1184
|
</DialogContent>
|
|
1047
1185
|
</Dialog>
|
|
1048
1186
|
|
|
@@ -1131,6 +1269,7 @@ function ConversationItemCard({
|
|
|
1131
1269
|
item,
|
|
1132
1270
|
avatarUrl,
|
|
1133
1271
|
isSelected,
|
|
1272
|
+
isPinned,
|
|
1134
1273
|
onClick,
|
|
1135
1274
|
bulkMode,
|
|
1136
1275
|
checked,
|
|
@@ -1140,6 +1279,7 @@ function ConversationItemCard({
|
|
|
1140
1279
|
item: ConversationItem;
|
|
1141
1280
|
avatarUrl?: string;
|
|
1142
1281
|
isSelected: boolean;
|
|
1282
|
+
isPinned?: boolean;
|
|
1143
1283
|
onClick: () => void;
|
|
1144
1284
|
bulkMode?: boolean;
|
|
1145
1285
|
checked?: boolean;
|
|
@@ -1155,7 +1295,9 @@ function ConversationItemCard({
|
|
|
1155
1295
|
"backdrop-blur-sm",
|
|
1156
1296
|
isSelected
|
|
1157
1297
|
? "bg-primary/10 text-accent-foreground shadow-sm"
|
|
1158
|
-
:
|
|
1298
|
+
: isPinned
|
|
1299
|
+
? "bg-amber-200/15 hover:bg-amber-200/20"
|
|
1300
|
+
: "hover:bg-white/40"
|
|
1159
1301
|
)}
|
|
1160
1302
|
onClick={onClick}
|
|
1161
1303
|
>
|
|
@@ -1183,7 +1325,10 @@ function ConversationItemCard({
|
|
|
1183
1325
|
</span>
|
|
1184
1326
|
<div className="flex-1 min-w-0">
|
|
1185
1327
|
<div className="flex items-center justify-between gap-2">
|
|
1186
|
-
<span className="text-sm font-medium leading-5">
|
|
1328
|
+
<span className="inline-flex items-center gap-1.5 text-sm font-medium leading-5">
|
|
1329
|
+
{isPinned && <Pin className="h-3.5 w-3.5 shrink-0 text-amber-600/80" />}
|
|
1330
|
+
{truncateText(item.displayName, 18)}
|
|
1331
|
+
</span>
|
|
1187
1332
|
{item.lastTime && (
|
|
1188
1333
|
<span className="text-[11px] text-muted-foreground shrink-0">
|
|
1189
1334
|
{formatTime(item.lastTime)}
|
|
@@ -4,8 +4,6 @@ import { useState } from "react";
|
|
|
4
4
|
import { Badge } from "@/components/ui/badge";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
6
|
import { cn } from "@/lib/utils";
|
|
7
|
-
|
|
8
|
-
import type { OnPasteChoice, ParsedViewModel } from "./types";
|
|
9
7
|
import {
|
|
10
8
|
fieldDisplayName,
|
|
11
9
|
findFieldLine,
|
|
@@ -13,6 +11,7 @@ import {
|
|
|
13
11
|
parseMultiValues,
|
|
14
12
|
toggleValue,
|
|
15
13
|
} from "./utils";
|
|
14
|
+
import type { OnPasteChoice, ParsedViewModel, ParsedChoice } from "./types";
|
|
16
15
|
|
|
17
16
|
export function PayloadFormView({
|
|
18
17
|
vm,
|
|
@@ -100,7 +99,7 @@ export function PayloadFormView({
|
|
|
100
99
|
{options.length > 0 ? (
|
|
101
100
|
<div className="grid grid-cols-1 gap-2">
|
|
102
101
|
{options.map((opt, oidx) => {
|
|
103
|
-
const value = formatChoiceLabel(opt as
|
|
102
|
+
const value = formatChoiceLabel(opt as ParsedChoice);
|
|
104
103
|
const title = value ? `Click to select: ${fieldKey}: ${value}` : undefined;
|
|
105
104
|
const isSelected = allowMultiple && !!value && currentSet.has(value);
|
|
106
105
|
return (
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
import { createContext, useContext, useState, type ReactNode } from "react";
|
|
2
4
|
import type { ImageAttachment } from "@/types/chat";
|
|
3
5
|
|
|
6
|
+
type ConversationMode = "chat" | "agent";
|
|
7
|
+
|
|
4
8
|
interface InputContextValue {
|
|
5
9
|
input: string;
|
|
6
10
|
images: ImageAttachment[];
|
|
11
|
+
conversationMode: ConversationMode;
|
|
7
12
|
setInput: React.Dispatch<React.SetStateAction<string>>;
|
|
8
13
|
setImages: React.Dispatch<React.SetStateAction<ImageAttachment[]>>;
|
|
14
|
+
setConversationMode: (mode: ConversationMode) => void;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
const InputContext = createContext<InputContextValue | null>(null);
|
|
@@ -25,12 +31,32 @@ interface InputProviderProps {
|
|
|
25
31
|
export function InputProvider({ children }: InputProviderProps) {
|
|
26
32
|
const [input, setInput] = useState("");
|
|
27
33
|
const [images, setImages] = useState<ImageAttachment[]>([]);
|
|
34
|
+
const [conversationMode, setConversationModeState] = useState<ConversationMode>(() => {
|
|
35
|
+
try {
|
|
36
|
+
const last = window.localStorage.getItem("cue-console:conversationMode");
|
|
37
|
+
if (last === "agent" || last === "chat") return last;
|
|
38
|
+
const def = window.localStorage.getItem("cue-console:conversationModeDefault");
|
|
39
|
+
return def === "agent" || def === "chat" ? def : "agent";
|
|
40
|
+
} catch {
|
|
41
|
+
return "agent";
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const setConversationMode = (mode: ConversationMode) => {
|
|
46
|
+
setConversationModeState(mode);
|
|
47
|
+
try {
|
|
48
|
+
window.localStorage.setItem("cue-console:conversationMode", mode);
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
};
|
|
28
52
|
|
|
29
53
|
const value: InputContextValue = {
|
|
30
54
|
input,
|
|
31
55
|
images,
|
|
56
|
+
conversationMode,
|
|
32
57
|
setInput,
|
|
33
58
|
setImages,
|
|
59
|
+
setConversationMode,
|
|
34
60
|
};
|
|
35
61
|
|
|
36
62
|
return <InputContext.Provider value={value}>{children}</InputContext.Provider>;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
fetchGroupTimeline,
|
|
8
8
|
type AgentTimelineItem,
|
|
9
9
|
type CueRequest,
|
|
10
|
+
type QueuedMessage,
|
|
10
11
|
} from "@/lib/actions";
|
|
11
12
|
|
|
12
13
|
export function useConversationTimeline({
|
|
@@ -29,7 +30,7 @@ export function useConversationTimeline({
|
|
|
29
30
|
onBootstrap: (res: {
|
|
30
31
|
members: string[];
|
|
31
32
|
agentNameMap: Record<string, string>;
|
|
32
|
-
queue:
|
|
33
|
+
queue: QueuedMessage[];
|
|
33
34
|
timeline: { items: AgentTimelineItem[]; nextCursor: string | null };
|
|
34
35
|
}) => void;
|
|
35
36
|
isPauseRequest: (req: CueRequest) => boolean;
|
|
@@ -4,6 +4,8 @@ import { handleError, logError } from "@/lib/error-handler";
|
|
|
4
4
|
import { useInputContext } from "@/contexts/input-context";
|
|
5
5
|
import { useUIStateContext } from "@/contexts/ui-state-context";
|
|
6
6
|
|
|
7
|
+
type InlineAttachment = Awaited<ReturnType<typeof fileToInlineAttachment>>;
|
|
8
|
+
|
|
7
9
|
interface UseFileHandlerParams {
|
|
8
10
|
inputWrapRef: React.RefObject<HTMLDivElement | null>;
|
|
9
11
|
}
|
|
@@ -23,7 +25,7 @@ export function useFileHandler({ inputWrapRef }: UseFileHandlerParams) {
|
|
|
23
25
|
|
|
24
26
|
const successful = results
|
|
25
27
|
.filter((r) => r.status === "fulfilled")
|
|
26
|
-
.map((r) => (r as PromiseFulfilledResult<
|
|
28
|
+
.map((r) => (r as PromiseFulfilledResult<InlineAttachment>).value);
|
|
27
29
|
|
|
28
30
|
results.forEach((r, i) => {
|
|
29
31
|
if (r.status === "rejected") {
|
|
@@ -321,7 +321,7 @@ export function useMentions({
|
|
|
321
321
|
const queryChanged = prevMentionQueryRef.current !== mentionQuery;
|
|
322
322
|
if (queryChanged) {
|
|
323
323
|
shouldAutoScrollMentionRef.current = true;
|
|
324
|
-
setMentionActive(0);
|
|
324
|
+
queueMicrotask(() => setMentionActive(0));
|
|
325
325
|
el.scrollTop = 0;
|
|
326
326
|
mentionScrollTopRef.current = 0;
|
|
327
327
|
}
|
|
@@ -14,7 +14,7 @@ interface UseMessageSenderParams {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function useMessageSender({ type, pendingRequests, mentions, onSuccess }: UseMessageSenderParams) {
|
|
17
|
-
const { input, images, setInput, setImages } = useInputContext();
|
|
17
|
+
const { input, images, conversationMode, setInput, setImages } = useInputContext();
|
|
18
18
|
const { busy, setBusy, setError } = useUIStateContext();
|
|
19
19
|
const imagesRef = useRef(images);
|
|
20
20
|
|
|
@@ -46,18 +46,27 @@ export function useMessageSender({ type, pendingRequests, mentions, onSuccess }:
|
|
|
46
46
|
|
|
47
47
|
try {
|
|
48
48
|
let result;
|
|
49
|
+
|
|
50
|
+
const analysisOnlyInstruction =
|
|
51
|
+
"只做分析,不要对代码/文件做任何改动。";
|
|
52
|
+
const textToSend =
|
|
53
|
+
conversationMode === "chat"
|
|
54
|
+
? input.trim().length > 0
|
|
55
|
+
? `${input}\n\n${analysisOnlyInstruction}`
|
|
56
|
+
: analysisOnlyInstruction
|
|
57
|
+
: input;
|
|
49
58
|
|
|
50
59
|
if (type === "agent" && targets.targetRequests.length === 1) {
|
|
51
60
|
result = await submitResponse(
|
|
52
61
|
targets.targetRequests[0].request_id,
|
|
53
|
-
|
|
62
|
+
textToSend,
|
|
54
63
|
currentImages,
|
|
55
64
|
mentions
|
|
56
65
|
);
|
|
57
66
|
} else {
|
|
58
67
|
result = await batchRespond(
|
|
59
68
|
targets.targetRequests.map((r) => r.request_id),
|
|
60
|
-
|
|
69
|
+
textToSend,
|
|
61
70
|
currentImages,
|
|
62
71
|
mentions
|
|
63
72
|
);
|
|
@@ -78,7 +87,7 @@ export function useMessageSender({ type, pendingRequests, mentions, onSuccess }:
|
|
|
78
87
|
} finally {
|
|
79
88
|
setBusy(false);
|
|
80
89
|
}
|
|
81
|
-
}, [type, input, mentions, pendingRequests, busy, setBusy, setError, setInput, setImages, onSuccess]);
|
|
90
|
+
}, [type, input, conversationMode, mentions, pendingRequests, busy, setBusy, setError, setInput, setImages, onSuccess]);
|
|
82
91
|
|
|
83
92
|
return { send };
|
|
84
93
|
}
|
package/src/lib/actions.ts
CHANGED
|
@@ -43,6 +43,9 @@ import {
|
|
|
43
43
|
getLastRequestsByAgents,
|
|
44
44
|
getLastResponsesByAgents,
|
|
45
45
|
getPendingCountsByAgents,
|
|
46
|
+
pinConversation,
|
|
47
|
+
unpinConversation,
|
|
48
|
+
listPinnedConversations,
|
|
46
49
|
type ConversationType,
|
|
47
50
|
type CueResponse,
|
|
48
51
|
} from "./db";
|
|
@@ -55,6 +58,7 @@ import { v4 as uuidv4 } from "uuid";
|
|
|
55
58
|
|
|
56
59
|
export type UserConfig = {
|
|
57
60
|
sound_enabled: boolean;
|
|
61
|
+
conversation_mode_default: "chat" | "agent";
|
|
58
62
|
};
|
|
59
63
|
|
|
60
64
|
export type QueuedMessage = {
|
|
@@ -66,6 +70,7 @@ export type QueuedMessage = {
|
|
|
66
70
|
|
|
67
71
|
const defaultUserConfig: UserConfig = {
|
|
68
72
|
sound_enabled: true,
|
|
73
|
+
conversation_mode_default: "agent",
|
|
69
74
|
};
|
|
70
75
|
|
|
71
76
|
function getUserConfigPath(): string {
|
|
@@ -82,6 +87,10 @@ export async function getUserConfig(): Promise<UserConfig> {
|
|
|
82
87
|
typeof parsed.sound_enabled === "boolean"
|
|
83
88
|
? parsed.sound_enabled
|
|
84
89
|
: defaultUserConfig.sound_enabled,
|
|
90
|
+
conversation_mode_default:
|
|
91
|
+
parsed.conversation_mode_default === "chat" || parsed.conversation_mode_default === "agent"
|
|
92
|
+
? parsed.conversation_mode_default
|
|
93
|
+
: defaultUserConfig.conversation_mode_default,
|
|
85
94
|
};
|
|
86
95
|
} catch {
|
|
87
96
|
return defaultUserConfig;
|
|
@@ -93,6 +102,10 @@ export async function setUserConfig(next: Partial<UserConfig>): Promise<UserConf
|
|
|
93
102
|
const merged: UserConfig = {
|
|
94
103
|
sound_enabled:
|
|
95
104
|
typeof next.sound_enabled === "boolean" ? next.sound_enabled : prev.sound_enabled,
|
|
105
|
+
conversation_mode_default:
|
|
106
|
+
next.conversation_mode_default === "chat" || next.conversation_mode_default === "agent"
|
|
107
|
+
? next.conversation_mode_default
|
|
108
|
+
: prev.conversation_mode_default,
|
|
96
109
|
};
|
|
97
110
|
const p = getUserConfigPath();
|
|
98
111
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
@@ -155,6 +168,25 @@ function parseConversationKey(key: string): { type: "agent" | "group"; id: strin
|
|
|
155
168
|
return { type, id };
|
|
156
169
|
}
|
|
157
170
|
|
|
171
|
+
export async function pinConversationByKey(key: string, view: "active" | "archived") {
|
|
172
|
+
const parsed = parseConversationKey(key);
|
|
173
|
+
if (!parsed) return { success: false, error: "Invalid conversation key" } as const;
|
|
174
|
+
pinConversation(parsed.type, parsed.id, view);
|
|
175
|
+
return { success: true } as const;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function unpinConversationByKey(key: string, view: "active" | "archived") {
|
|
179
|
+
const parsed = parseConversationKey(key);
|
|
180
|
+
if (!parsed) return { success: false, error: "Invalid conversation key" } as const;
|
|
181
|
+
unpinConversation(parsed.type, parsed.id, view);
|
|
182
|
+
return { success: true } as const;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function fetchPinnedConversationKeys(view: "active" | "archived"): Promise<string[]> {
|
|
186
|
+
const rows = listPinnedConversations(view);
|
|
187
|
+
return rows.map((r) => `${r.conv_type}:${r.conv_id}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
158
190
|
export async function archiveConversations(keys: string[]) {
|
|
159
191
|
const unique = Array.from(new Set(keys));
|
|
160
192
|
for (const k of unique) {
|
package/src/lib/avatar.ts
CHANGED
|
@@ -114,7 +114,7 @@ export async function thumbsAvatarDataUrl(seed: string): Promise<string> {
|
|
|
114
114
|
|
|
115
115
|
const t1 = perfEnabled() ? performance.now() : 0;
|
|
116
116
|
|
|
117
|
-
const svg = createAvatar(thumbsStyle
|
|
117
|
+
const svg = createAvatar(thumbsStyle, {
|
|
118
118
|
seed,
|
|
119
119
|
}).toString();
|
|
120
120
|
|
package/src/lib/db.ts
CHANGED
|
@@ -217,7 +217,7 @@ function initTables() {
|
|
|
217
217
|
.prepare(`SELECT value FROM schema_meta WHERE key = ?`)
|
|
218
218
|
.get("schema_version") as { value?: string } | undefined;
|
|
219
219
|
const version = String(versionRow?.value ?? "");
|
|
220
|
-
if (version !== "
|
|
220
|
+
if (version !== "3") {
|
|
221
221
|
const reqCountRow = database.prepare(`SELECT COUNT(*) as n FROM cue_requests`).get() as { n: number };
|
|
222
222
|
const respCountRow = database.prepare(`SELECT COUNT(*) as n FROM cue_responses`).get() as { n: number };
|
|
223
223
|
const reqCount = Number(reqCountRow?.n ?? 0);
|
|
@@ -225,7 +225,7 @@ function initTables() {
|
|
|
225
225
|
if (reqCount === 0 && respCount === 0) {
|
|
226
226
|
database
|
|
227
227
|
.prepare(`INSERT INTO schema_meta (key, value) VALUES (?, ?)`)
|
|
228
|
-
.run("schema_version", "
|
|
228
|
+
.run("schema_version", "3");
|
|
229
229
|
} else {
|
|
230
230
|
throw new Error(
|
|
231
231
|
"Database schema is outdated (pre-file storage). Please migrate: cueme migrate\n" +
|
|
@@ -266,6 +266,17 @@ function initTables() {
|
|
|
266
266
|
)
|
|
267
267
|
`);
|
|
268
268
|
|
|
269
|
+
database.exec(`
|
|
270
|
+
CREATE TABLE IF NOT EXISTS conversation_pins (
|
|
271
|
+
conv_type TEXT NOT NULL,
|
|
272
|
+
conv_id TEXT NOT NULL,
|
|
273
|
+
view TEXT NOT NULL,
|
|
274
|
+
pin_order INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
275
|
+
pinned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
276
|
+
UNIQUE(conv_type, conv_id, view)
|
|
277
|
+
)
|
|
278
|
+
`);
|
|
279
|
+
|
|
269
280
|
database.exec(`
|
|
270
281
|
CREATE TABLE IF NOT EXISTS worker_leases (
|
|
271
282
|
lease_key TEXT PRIMARY KEY,
|
|
@@ -303,6 +314,45 @@ function initTables() {
|
|
|
303
314
|
`);
|
|
304
315
|
}
|
|
305
316
|
|
|
317
|
+
export function pinConversation(convType: ConversationType, convId: string, view: "active" | "archived"): void {
|
|
318
|
+
const t = String(convType || "").trim();
|
|
319
|
+
const id = String(convId || "").trim();
|
|
320
|
+
const v = view === "archived" ? "archived" : "active";
|
|
321
|
+
if (!id) return;
|
|
322
|
+
getDb()
|
|
323
|
+
.prepare(
|
|
324
|
+
`INSERT OR IGNORE INTO conversation_pins (conv_type, conv_id, view)
|
|
325
|
+
VALUES (?, ?, ?)`
|
|
326
|
+
)
|
|
327
|
+
.run(t, id, v);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function unpinConversation(convType: ConversationType, convId: string, view: "active" | "archived"): void {
|
|
331
|
+
const t = String(convType || "").trim();
|
|
332
|
+
const id = String(convId || "").trim();
|
|
333
|
+
const v = view === "archived" ? "archived" : "active";
|
|
334
|
+
if (!id) return;
|
|
335
|
+
getDb()
|
|
336
|
+
.prepare(
|
|
337
|
+
`DELETE FROM conversation_pins
|
|
338
|
+
WHERE conv_type = ? AND conv_id = ? AND view = ?`
|
|
339
|
+
)
|
|
340
|
+
.run(t, id, v);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function listPinnedConversations(view: "active" | "archived"): Array<{ conv_type: ConversationType; conv_id: string }> {
|
|
344
|
+
const v = view === "archived" ? "archived" : "active";
|
|
345
|
+
const rows = getDb()
|
|
346
|
+
.prepare(
|
|
347
|
+
`SELECT conv_type, conv_id
|
|
348
|
+
FROM conversation_pins
|
|
349
|
+
WHERE view = ?
|
|
350
|
+
ORDER BY pin_order ASC`
|
|
351
|
+
)
|
|
352
|
+
.all(v) as Array<{ conv_type: ConversationType; conv_id: string }>;
|
|
353
|
+
return rows || [];
|
|
354
|
+
}
|
|
355
|
+
|
|
306
356
|
function nowIso(): string {
|
|
307
357
|
return formatLocalIsoWithOffset(new Date());
|
|
308
358
|
}
|
package/src/lib/error-handler.ts
CHANGED
|
@@ -33,7 +33,7 @@ export function logError(error: unknown, context?: string): void {
|
|
|
33
33
|
console.error(`${prefix} ${message}`, error);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export function withErrorHandling<T extends (...args:
|
|
36
|
+
export function withErrorHandling<T extends (...args: unknown[]) => Promise<unknown>>(
|
|
37
37
|
fn: T,
|
|
38
38
|
context: string
|
|
39
39
|
): T {
|