@coffer-org/plugin-webchat 6.0.0 → 7.0.1
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/dist/{ChatWidget-DFVDexpD.js → ChatWidget-DkEKl1-2.js} +406 -113
- package/dist/runtime/actions.d.ts +17 -1
- package/dist/runtime/actions.js +72 -9
- package/dist/runtime/chain-store.d.ts +20 -4
- package/dist/runtime/chain-store.js +86 -9
- package/dist/runtime/channel-registry.d.ts +9 -0
- package/dist/runtime/channel-registry.js +48 -0
- package/dist/runtime/config.js +0 -1
- package/dist/runtime/connector.d.ts +3 -2
- package/dist/runtime/connector.js +77 -42
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +6 -3
- package/dist/runtime/send.d.ts +8 -7
- package/dist/runtime/send.js +61 -29
- package/dist/schema.js +336 -21
- package/dist/web.js +1 -1
- package/locales/en.json +3 -0
- package/locales/ru.json +3 -0
- package/locales/uk.json +3 -0
- package/package.json +3 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as __fedReact from "react";
|
|
2
|
-
import { Dialog, DialogContent, classifySiteHref, uploadFile, useMinWidth } from "@coffer-org/web-ui";
|
|
2
|
+
import { Dialog, DialogContent, Icon, classifySiteHref, toneVar, uploadFile, useMinWidth } from "@coffer-org/web-ui";
|
|
3
3
|
import { createContext, createElement, forwardRef, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { useLocation, useNavigate } from "react-router-dom";
|
|
5
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -466,7 +466,7 @@ var useLucideContext = () => useContext(LucideContext);
|
|
|
466
466
|
* This source code is licensed under the ISC license.
|
|
467
467
|
* See the LICENSE file in the root directory of this source tree.
|
|
468
468
|
*/
|
|
469
|
-
var Icon = forwardRef(({ color, size, strokeWidth, absoluteStrokeWidth, className = "", children, iconNode, ...rest }, ref) => {
|
|
469
|
+
var Icon$1 = forwardRef(({ color, size, strokeWidth, absoluteStrokeWidth, className = "", children, iconNode, ...rest }, ref) => {
|
|
470
470
|
const { size: contextSize = 24, strokeWidth: contextStrokeWidth = 2, absoluteStrokeWidth: contextAbsoluteStrokeWidth = false, color: contextColor = "currentColor", className: contextClass = "" } = useLucideContext() ?? {};
|
|
471
471
|
const calculatedStrokeWidth = absoluteStrokeWidth ?? contextAbsoluteStrokeWidth ? Number(strokeWidth ?? contextStrokeWidth) * 24 / Number(size ?? contextSize) : strokeWidth ?? contextStrokeWidth;
|
|
472
472
|
return createElement("svg", {
|
|
@@ -490,7 +490,7 @@ var Icon = forwardRef(({ color, size, strokeWidth, absoluteStrokeWidth, classNam
|
|
|
490
490
|
* See the LICENSE file in the root directory of this source tree.
|
|
491
491
|
*/
|
|
492
492
|
var createLucideIcon = (iconName, iconNode) => {
|
|
493
|
-
const Component = forwardRef(({ className, ...props }, ref) => createElement(Icon, {
|
|
493
|
+
const Component = forwardRef(({ className, ...props }, ref) => createElement(Icon$1, {
|
|
494
494
|
ref,
|
|
495
495
|
iconNode,
|
|
496
496
|
className: mergeClasses(`lucide-${toKebabCase(toPascalCase(iconName))}`, `lucide-${iconName}`, className),
|
|
@@ -802,8 +802,9 @@ async function* readSse(body) {
|
|
|
802
802
|
* separate authorization here; the gate is on the server. */
|
|
803
803
|
var BASE = "/api/plugins/webchat/user";
|
|
804
804
|
/** POST a member-level webchat action with a JSON body: the one request path every call
|
|
805
|
-
* in this file (except the SSE stream in `
|
|
806
|
-
* on any non-OK response — every caller below relies
|
|
805
|
+
* in this file (except the SSE stream in `openChannel` and `stopTurn`'s empty-body POST)
|
|
806
|
+
* goes through. Throws `Error(action)` on any non-OK response — every caller below relies
|
|
807
|
+
* on that to build its own error message. */
|
|
807
808
|
async function postAction(action, body) {
|
|
808
809
|
const r = await fetch(`${BASE}/${action}`, {
|
|
809
810
|
method: "POST",
|
|
@@ -842,35 +843,98 @@ async function selectAgent(convId, patch) {
|
|
|
842
843
|
...patch
|
|
843
844
|
})).selection;
|
|
844
845
|
}
|
|
846
|
+
/** Mark a conversation private, or reopen it. Only ever meaningful for the
|
|
847
|
+
* caller's OWN conversation — the server refuses (returns the unchanged
|
|
848
|
+
* visibility) for anything else, so the UI only offers this control on the
|
|
849
|
+
* viewer's own rows in the first place. */
|
|
850
|
+
async function setThreadVisibility(convId, makePrivate) {
|
|
851
|
+
return postAction("setVisibility", {
|
|
852
|
+
convId,
|
|
853
|
+
private: makePrivate
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
/** Records the user turn and starts the agent turn running — an ordinary POST that
|
|
857
|
+
* resolves as soon as the turn has STARTED, not once it finishes (`sendAction`,
|
|
858
|
+
* `../runtime/send.ts`). It streams nothing any more: every live update — the
|
|
859
|
+
* answer as it is written, its completion, an error — arrives on the
|
|
860
|
+
* conversation's own channel (`openChannel`, below), which `use-chat.ts` keeps
|
|
861
|
+
* open for as long as this conversation is on screen, whether or not THIS call
|
|
862
|
+
* is what started the turn currently running. */
|
|
845
863
|
async function sendMessage(req) {
|
|
846
|
-
|
|
864
|
+
return postAction("send", {
|
|
865
|
+
convId: req.convId,
|
|
866
|
+
text: req.text,
|
|
867
|
+
replyTo: req.replyTo,
|
|
868
|
+
context: req.context,
|
|
869
|
+
...req.attachments?.length ? { attachments: req.attachments } : {}
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
/** Abort the turn currently running on `convId`, if there is one (`stopAction`,
|
|
873
|
+
* `../runtime/actions.ts`). That action returns `Promise<void>` — the route sends
|
|
874
|
+
* an empty 200 body, the same uniform non-answer whether a live turn was
|
|
875
|
+
* actually aborted, none was running, or the caller may not write to the
|
|
876
|
+
* conversation — so this deliberately does NOT go through `postAction`, whose
|
|
877
|
+
* `.json()` would throw on that empty body. */
|
|
878
|
+
async function stopTurn(convId) {
|
|
879
|
+
if (!(await fetch(`${BASE}/stop`, {
|
|
847
880
|
method: "POST",
|
|
848
881
|
headers: { "content-type": "application/json" },
|
|
849
|
-
body: JSON.stringify({
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
882
|
+
body: JSON.stringify({ convId })
|
|
883
|
+
})).ok) throw new Error("stop");
|
|
884
|
+
}
|
|
885
|
+
/** Subscribe to `convId`'s channel and hold the connection open, delivering every
|
|
886
|
+
* frame to `handlers` until the server ends the stream (the conversation went
|
|
887
|
+
* private on this viewer, `setVisibility`'s eviction; a restart; a network drop)
|
|
888
|
+
* or `signal` aborts it. Resolves (does not reject) when the server ends the
|
|
889
|
+
* stream cleanly, same as a plain fetch whose body simply runs out — rejects only
|
|
890
|
+
* on a genuine transport failure or `signal` aborting. Either way it is
|
|
891
|
+
* `use-chat.ts`'s job to reconnect with backoff while the conversation is still
|
|
892
|
+
* the one on screen: there is no `Last-Event-ID` bookkeeping and no server-side
|
|
893
|
+
* buffer here, by design (see the design doc's "Reconnect, and the race that
|
|
894
|
+
* comes with it") — missed frames are recovered by re-reading history, never by
|
|
895
|
+
* replaying the stream. */
|
|
896
|
+
async function openChannel(convId, handlers, signal) {
|
|
897
|
+
const r = await fetch(`${BASE}/channel/stream`, {
|
|
898
|
+
method: "POST",
|
|
899
|
+
headers: { "content-type": "application/json" },
|
|
900
|
+
body: JSON.stringify({ convId }),
|
|
901
|
+
signal
|
|
857
902
|
});
|
|
858
|
-
if (!r.ok || !r.body) {
|
|
859
|
-
req.onError(`http ${r.status}`);
|
|
860
|
-
return;
|
|
861
|
-
}
|
|
903
|
+
if (!r.ok || !r.body) throw new Error(`http ${r.status}`);
|
|
862
904
|
for await (const frame of readSse(r.body)) try {
|
|
863
905
|
const data = JSON.parse(frame.data);
|
|
864
|
-
if (frame.event === "
|
|
865
|
-
|
|
866
|
-
|
|
906
|
+
if (frame.event === "user") handlers.onUser({
|
|
907
|
+
msgId: String(data["msgId"] ?? ""),
|
|
908
|
+
text: String(data["text"] ?? ""),
|
|
909
|
+
ts: typeof data["ts"] === "number" ? data["ts"] : 0,
|
|
910
|
+
...Array.isArray(data["attachments"]) ? { attachments: data["attachments"] } : {}
|
|
911
|
+
});
|
|
912
|
+
else if (frame.event === "message") handlers.onMessage({
|
|
913
|
+
msgId: String(data["msgId"] ?? ""),
|
|
914
|
+
text: String(data["text"] ?? "")
|
|
915
|
+
});
|
|
916
|
+
else if (frame.event === "delta") handlers.onDelta({
|
|
917
|
+
msgId: String(data["msgId"] ?? ""),
|
|
918
|
+
text: String(data["text"] ?? "")
|
|
919
|
+
});
|
|
920
|
+
else if (frame.event === "reasoning") handlers.onReasoning({
|
|
921
|
+
msgId: String(data["msgId"] ?? ""),
|
|
922
|
+
text: String(data["text"] ?? "")
|
|
923
|
+
});
|
|
924
|
+
else if (frame.event === "done") handlers.onDone({
|
|
867
925
|
msgId: data["msgId"] ?? null,
|
|
868
926
|
parentMsgId: String(data["parentMsgId"] ?? ""),
|
|
869
927
|
suggestions: Array.isArray(data["suggestions"]) ? data["suggestions"] : null
|
|
870
928
|
});
|
|
871
|
-
else if (frame.event === "error")
|
|
929
|
+
else if (frame.event === "error") handlers.onError({
|
|
930
|
+
message: data["message"] ?? null,
|
|
931
|
+
parentMsgId: typeof data["parentMsgId"] === "string" ? data["parentMsgId"] : null
|
|
932
|
+
});
|
|
872
933
|
} catch (e) {
|
|
873
|
-
|
|
934
|
+
handlers.onError({
|
|
935
|
+
message: `malformed frame: ${String(e)}`,
|
|
936
|
+
parentMsgId: null
|
|
937
|
+
});
|
|
874
938
|
}
|
|
875
939
|
}
|
|
876
940
|
//#endregion
|
|
@@ -878,6 +942,23 @@ async function sendMessage(req) {
|
|
|
878
942
|
function newId() {
|
|
879
943
|
return crypto.randomUUID();
|
|
880
944
|
}
|
|
945
|
+
/** The message id a frame reports as its OUTCOME, for dedup against history on
|
|
946
|
+
* flush. `error` is never persisted (a failed turn stores nothing), so it is
|
|
947
|
+
* never a duplicate of anything a history read could carry — its
|
|
948
|
+
* `parentMsgId` exists only to scope the send-lock clear below to the
|
|
949
|
+
* viewer's own turn, a different question from dedup. */
|
|
950
|
+
function frameHistoryId(frame) {
|
|
951
|
+
switch (frame.kind) {
|
|
952
|
+
case "user":
|
|
953
|
+
case "message":
|
|
954
|
+
case "delta":
|
|
955
|
+
case "reasoning": return frame.data.msgId;
|
|
956
|
+
case "done": return frame.data.msgId;
|
|
957
|
+
case "error": return null;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
var RECONNECT_BASE_MS = 1e3;
|
|
961
|
+
var RECONNECT_MAX_MS = 3e4;
|
|
881
962
|
function useChat() {
|
|
882
963
|
const [threads, setThreads] = useState([]);
|
|
883
964
|
const [convId, setConvId] = useState(null);
|
|
@@ -894,15 +975,16 @@ function useChat() {
|
|
|
894
975
|
const [starters, setStarters] = useState([]);
|
|
895
976
|
const [suggestions, setSuggestions] = useState(null);
|
|
896
977
|
const headRef = useRef(null);
|
|
897
|
-
const abortRef = useRef(null);
|
|
898
978
|
const sendingRef = useRef(false);
|
|
979
|
+
const myTurnMsgIdRef = useRef(null);
|
|
980
|
+
const pendingTextRef = useRef("");
|
|
981
|
+
const pendingReasoningRef = useRef(null);
|
|
899
982
|
const turnRef = useRef(0);
|
|
900
983
|
const aliveRef = useRef(true);
|
|
901
984
|
useEffect(() => {
|
|
902
985
|
aliveRef.current = true;
|
|
903
986
|
return () => {
|
|
904
987
|
aliveRef.current = false;
|
|
905
|
-
abortRef.current?.abort();
|
|
906
988
|
turnRef.current++;
|
|
907
989
|
};
|
|
908
990
|
}, []);
|
|
@@ -925,6 +1007,157 @@ function useChat() {
|
|
|
925
1007
|
cancelled = true;
|
|
926
1008
|
};
|
|
927
1009
|
}, [convId]);
|
|
1010
|
+
/** Apply one channel frame to state. Called either live (as it arrives, once a
|
|
1011
|
+
* connection has finished its history flush) or from the buffer a fresh
|
|
1012
|
+
* (re)connect built while history was in flight — `historyIds` is non-null
|
|
1013
|
+
* ONLY for the latter, and gates every frame on it: a frame whose outcome the
|
|
1014
|
+
* just-fetched history already carries is dropped rather than reapplied, the
|
|
1015
|
+
* one piece of ordering the design doc calls out by name. Live frames never
|
|
1016
|
+
* need this check — their message id cannot already be in a history snapshot
|
|
1017
|
+
* read before they existed. */
|
|
1018
|
+
const applyFrame = useCallback((frame, historyIds) => {
|
|
1019
|
+
const outcomeId = frameHistoryId(frame);
|
|
1020
|
+
const isDuplicate = historyIds !== null && outcomeId !== null && historyIds.has(outcomeId);
|
|
1021
|
+
switch (frame.kind) {
|
|
1022
|
+
case "user": {
|
|
1023
|
+
if (isDuplicate) break;
|
|
1024
|
+
const { msgId, text, ts, attachments } = frame.data;
|
|
1025
|
+
setMessages((prev) => prev.some((m) => m.msgId === msgId) ? prev : [...prev, {
|
|
1026
|
+
msgId,
|
|
1027
|
+
role: "user",
|
|
1028
|
+
text,
|
|
1029
|
+
ts,
|
|
1030
|
+
...attachments?.length ? { attachments } : {}
|
|
1031
|
+
}]);
|
|
1032
|
+
break;
|
|
1033
|
+
}
|
|
1034
|
+
case "message":
|
|
1035
|
+
case "delta":
|
|
1036
|
+
if (isDuplicate) break;
|
|
1037
|
+
pendingTextRef.current = frame.data.text;
|
|
1038
|
+
setPending(frame.data.text);
|
|
1039
|
+
break;
|
|
1040
|
+
case "reasoning":
|
|
1041
|
+
if (isDuplicate) break;
|
|
1042
|
+
pendingReasoningRef.current = frame.data.text;
|
|
1043
|
+
setPendingReasoning(frame.data.text);
|
|
1044
|
+
break;
|
|
1045
|
+
case "done": {
|
|
1046
|
+
const { msgId, parentMsgId, suggestions: nextSuggestions } = frame.data;
|
|
1047
|
+
headRef.current = msgId ?? parentMsgId;
|
|
1048
|
+
setSuggestions(nextSuggestions ?? null);
|
|
1049
|
+
if (!isDuplicate && msgId && pendingTextRef.current) {
|
|
1050
|
+
const text = pendingTextRef.current;
|
|
1051
|
+
const reasoning = pendingReasoningRef.current;
|
|
1052
|
+
setMessages((prev) => prev.some((m) => m.msgId === msgId) ? prev : [...prev, {
|
|
1053
|
+
msgId,
|
|
1054
|
+
role: "assistant",
|
|
1055
|
+
text,
|
|
1056
|
+
ts: Math.floor(Date.now() / 1e3),
|
|
1057
|
+
...reasoning ? { reasoning } : {}
|
|
1058
|
+
}]);
|
|
1059
|
+
}
|
|
1060
|
+
pendingTextRef.current = "";
|
|
1061
|
+
pendingReasoningRef.current = null;
|
|
1062
|
+
setPending(null);
|
|
1063
|
+
setPendingReasoning(null);
|
|
1064
|
+
if (myTurnMsgIdRef.current !== null && myTurnMsgIdRef.current === parentMsgId) {
|
|
1065
|
+
sendingRef.current = false;
|
|
1066
|
+
myTurnMsgIdRef.current = null;
|
|
1067
|
+
}
|
|
1068
|
+
loadThreads();
|
|
1069
|
+
break;
|
|
1070
|
+
}
|
|
1071
|
+
case "error":
|
|
1072
|
+
setError(frame.data.message ?? "error");
|
|
1073
|
+
pendingTextRef.current = "";
|
|
1074
|
+
pendingReasoningRef.current = null;
|
|
1075
|
+
setPending(null);
|
|
1076
|
+
setPendingReasoning(null);
|
|
1077
|
+
if (myTurnMsgIdRef.current !== null && myTurnMsgIdRef.current === frame.data.parentMsgId) {
|
|
1078
|
+
sendingRef.current = false;
|
|
1079
|
+
myTurnMsgIdRef.current = null;
|
|
1080
|
+
}
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1083
|
+
}, [loadThreads]);
|
|
1084
|
+
useEffect(() => {
|
|
1085
|
+
if (!convId) return;
|
|
1086
|
+
const chatConvId = convId;
|
|
1087
|
+
let cancelled = false;
|
|
1088
|
+
let reconnectTimer = null;
|
|
1089
|
+
let attempt = 0;
|
|
1090
|
+
const ctl = new AbortController();
|
|
1091
|
+
async function connectOnce() {
|
|
1092
|
+
if (cancelled) return;
|
|
1093
|
+
let buffering = true;
|
|
1094
|
+
let receivedFrame = false;
|
|
1095
|
+
const buffer = [];
|
|
1096
|
+
const dispatch = (frame) => {
|
|
1097
|
+
if (cancelled) return;
|
|
1098
|
+
receivedFrame = true;
|
|
1099
|
+
if (buffering) buffer.push(frame);
|
|
1100
|
+
else applyFrame(frame, null);
|
|
1101
|
+
};
|
|
1102
|
+
const settled = openChannel(chatConvId, {
|
|
1103
|
+
onUser: (data) => dispatch({
|
|
1104
|
+
kind: "user",
|
|
1105
|
+
data
|
|
1106
|
+
}),
|
|
1107
|
+
onMessage: (data) => dispatch({
|
|
1108
|
+
kind: "message",
|
|
1109
|
+
data
|
|
1110
|
+
}),
|
|
1111
|
+
onDelta: (data) => dispatch({
|
|
1112
|
+
kind: "delta",
|
|
1113
|
+
data
|
|
1114
|
+
}),
|
|
1115
|
+
onReasoning: (data) => dispatch({
|
|
1116
|
+
kind: "reasoning",
|
|
1117
|
+
data
|
|
1118
|
+
}),
|
|
1119
|
+
onDone: (data) => dispatch({
|
|
1120
|
+
kind: "done",
|
|
1121
|
+
data
|
|
1122
|
+
}),
|
|
1123
|
+
onError: (data) => dispatch({
|
|
1124
|
+
kind: "error",
|
|
1125
|
+
data
|
|
1126
|
+
})
|
|
1127
|
+
}, ctl.signal).then(() => true, () => false);
|
|
1128
|
+
try {
|
|
1129
|
+
const h = await fetchHistory(chatConvId);
|
|
1130
|
+
if (cancelled) return;
|
|
1131
|
+
headRef.current = h.headMsgId;
|
|
1132
|
+
const historyIds = new Set(h.messages.map((m) => m.msgId));
|
|
1133
|
+
setMessages((prev) => {
|
|
1134
|
+
const ownPendingId = myTurnMsgIdRef.current;
|
|
1135
|
+
const stillUnconfirmed = prev.filter((m) => !historyIds.has(m.msgId) && (m.msgId.startsWith("local-") || m.msgId === ownPendingId));
|
|
1136
|
+
return stillUnconfirmed.length ? [...h.messages, ...stillUnconfirmed] : h.messages;
|
|
1137
|
+
});
|
|
1138
|
+
setSuggestions(h.messages.at(-1)?.suggestions ?? null);
|
|
1139
|
+
buffering = false;
|
|
1140
|
+
for (const frame of buffer) applyFrame(frame, historyIds);
|
|
1141
|
+
} catch (e) {
|
|
1142
|
+
if (cancelled) return;
|
|
1143
|
+
setError(e.message);
|
|
1144
|
+
buffering = false;
|
|
1145
|
+
for (const frame of buffer) applyFrame(frame, null);
|
|
1146
|
+
}
|
|
1147
|
+
await settled;
|
|
1148
|
+
if (cancelled) return;
|
|
1149
|
+
if (receivedFrame) attempt = 0;
|
|
1150
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS);
|
|
1151
|
+
attempt++;
|
|
1152
|
+
reconnectTimer = setTimeout(() => void connectOnce(), delay);
|
|
1153
|
+
}
|
|
1154
|
+
connectOnce();
|
|
1155
|
+
return () => {
|
|
1156
|
+
cancelled = true;
|
|
1157
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
1158
|
+
ctl.abort();
|
|
1159
|
+
};
|
|
1160
|
+
}, [convId, applyFrame]);
|
|
928
1161
|
const chooseAgent = useCallback((next) => {
|
|
929
1162
|
const previous = selection;
|
|
930
1163
|
setSelection(next);
|
|
@@ -933,52 +1166,61 @@ function useChat() {
|
|
|
933
1166
|
setError(err instanceof Error ? err.message : String(err));
|
|
934
1167
|
});
|
|
935
1168
|
}, [convId, selection]);
|
|
1169
|
+
const togglePrivate = useCallback((convId, next) => {
|
|
1170
|
+
const previous = threads.find((t) => t.convId === convId)?.private ?? false;
|
|
1171
|
+
setThreads((prev) => prev.map((t) => t.convId === convId ? {
|
|
1172
|
+
...t,
|
|
1173
|
+
private: next
|
|
1174
|
+
} : t));
|
|
1175
|
+
setThreadVisibility(convId, next).then((r) => {
|
|
1176
|
+
setThreads((prev) => prev.map((t) => t.convId === convId ? {
|
|
1177
|
+
...t,
|
|
1178
|
+
private: r.visibility === "private"
|
|
1179
|
+
} : t));
|
|
1180
|
+
}).catch(() => {
|
|
1181
|
+
setThreads((prev) => prev.map((t) => t.convId === convId ? {
|
|
1182
|
+
...t,
|
|
1183
|
+
private: previous
|
|
1184
|
+
} : t));
|
|
1185
|
+
});
|
|
1186
|
+
}, [threads]);
|
|
936
1187
|
const attachmentsAllowed = useMemo(() => {
|
|
937
|
-
const
|
|
938
|
-
if (!
|
|
939
|
-
return
|
|
1188
|
+
const media = agents.find((a) => a.id === selection.agentId)?.media;
|
|
1189
|
+
if (!media) return true;
|
|
1190
|
+
return media.image.accepts.length > 0 || media.document.accepts.length > 0;
|
|
940
1191
|
}, [agents, selection]);
|
|
941
1192
|
const newConversation = useCallback(() => {
|
|
942
|
-
abortRef.current?.abort();
|
|
943
|
-
abortRef.current = null;
|
|
944
1193
|
turnRef.current++;
|
|
1194
|
+
sendingRef.current = false;
|
|
1195
|
+
myTurnMsgIdRef.current = null;
|
|
945
1196
|
headRef.current = null;
|
|
946
1197
|
setConvId(newId());
|
|
947
1198
|
setMessages([]);
|
|
948
1199
|
setPending(null);
|
|
949
1200
|
setPendingReasoning(null);
|
|
1201
|
+
pendingTextRef.current = "";
|
|
1202
|
+
pendingReasoningRef.current = null;
|
|
950
1203
|
setError(null);
|
|
951
1204
|
setSuggestions(null);
|
|
952
1205
|
}, []);
|
|
953
1206
|
const open = useCallback(async (id) => {
|
|
954
|
-
abortRef.current?.abort();
|
|
955
|
-
abortRef.current = null;
|
|
956
1207
|
turnRef.current++;
|
|
957
|
-
|
|
1208
|
+
sendingRef.current = false;
|
|
1209
|
+
myTurnMsgIdRef.current = null;
|
|
958
1210
|
setConvId(id);
|
|
959
|
-
setMessages([]);
|
|
960
1211
|
headRef.current = null;
|
|
1212
|
+
setMessages([]);
|
|
961
1213
|
setPending(null);
|
|
962
1214
|
setPendingReasoning(null);
|
|
1215
|
+
pendingTextRef.current = "";
|
|
1216
|
+
pendingReasoningRef.current = null;
|
|
963
1217
|
setError(null);
|
|
964
1218
|
setSuggestions(null);
|
|
965
|
-
try {
|
|
966
|
-
const h = await fetchHistory(id);
|
|
967
|
-
if (turnRef.current !== myTurn) return;
|
|
968
|
-
headRef.current = h.headMsgId;
|
|
969
|
-
setMessages(h.messages);
|
|
970
|
-
setSuggestions(h.messages.at(-1)?.suggestions ?? null);
|
|
971
|
-
} catch (e) {
|
|
972
|
-
if (turnRef.current !== myTurn) return;
|
|
973
|
-
setError(e.message);
|
|
974
|
-
}
|
|
975
1219
|
}, []);
|
|
976
1220
|
const stop = useCallback(() => {
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
setPendingReasoning(null);
|
|
981
|
-
}, []);
|
|
1221
|
+
if (!convId) return;
|
|
1222
|
+
stopTurn(convId).catch(() => {});
|
|
1223
|
+
}, [convId]);
|
|
982
1224
|
return {
|
|
983
1225
|
threads,
|
|
984
1226
|
convId,
|
|
@@ -993,6 +1235,7 @@ function useChat() {
|
|
|
993
1235
|
suggestions,
|
|
994
1236
|
attachmentsAllowed,
|
|
995
1237
|
chooseAgent,
|
|
1238
|
+
togglePrivate,
|
|
996
1239
|
send: useCallback((text, context, attachments = []) => {
|
|
997
1240
|
if (!text.trim() || sendingRef.current) return false;
|
|
998
1241
|
sendingRef.current = true;
|
|
@@ -1001,74 +1244,47 @@ function useChat() {
|
|
|
1001
1244
|
if (convId === null) setConvId(id);
|
|
1002
1245
|
setError(null);
|
|
1003
1246
|
setSuggestions(null);
|
|
1247
|
+
const localMsgId = `local-${newId()}`;
|
|
1004
1248
|
setMessages((prev) => [...prev, {
|
|
1005
|
-
msgId:
|
|
1249
|
+
msgId: localMsgId,
|
|
1006
1250
|
role: "user",
|
|
1007
1251
|
text,
|
|
1008
1252
|
ts: Math.floor(Date.now() / 1e3),
|
|
1009
1253
|
...attachments.length ? { attachments } : {}
|
|
1010
1254
|
}]);
|
|
1011
1255
|
setPending("");
|
|
1012
|
-
const ctl = new AbortController();
|
|
1013
|
-
abortRef.current = ctl;
|
|
1014
|
-
let acc = "";
|
|
1015
|
-
let accReasoning = "";
|
|
1016
1256
|
(async () => {
|
|
1017
1257
|
try {
|
|
1018
|
-
await sendMessage({
|
|
1258
|
+
const { msgId } = await sendMessage({
|
|
1019
1259
|
convId: id,
|
|
1020
1260
|
text,
|
|
1021
1261
|
replyTo: headRef.current,
|
|
1022
1262
|
context,
|
|
1023
|
-
attachments
|
|
1024
|
-
signal: ctl.signal,
|
|
1025
|
-
onDelta: (t) => {
|
|
1026
|
-
if (turnRef.current !== myTurn) return;
|
|
1027
|
-
acc = t;
|
|
1028
|
-
setPending(t);
|
|
1029
|
-
},
|
|
1030
|
-
onReasoning: (t) => {
|
|
1031
|
-
if (turnRef.current !== myTurn) return;
|
|
1032
|
-
accReasoning = t;
|
|
1033
|
-
setPendingReasoning(t);
|
|
1034
|
-
},
|
|
1035
|
-
onDone: ({ msgId, parentMsgId, suggestions: nextSuggestions }) => {
|
|
1036
|
-
if (turnRef.current !== myTurn) return;
|
|
1037
|
-
headRef.current = msgId ?? parentMsgId;
|
|
1038
|
-
setSuggestions(nextSuggestions ?? null);
|
|
1039
|
-
if (acc) setMessages((prev) => [...prev, {
|
|
1040
|
-
msgId: msgId ?? `local-${newId()}`,
|
|
1041
|
-
role: "assistant",
|
|
1042
|
-
text: acc,
|
|
1043
|
-
ts: Math.floor(Date.now() / 1e3),
|
|
1044
|
-
...accReasoning ? { reasoning: accReasoning } : {}
|
|
1045
|
-
}]);
|
|
1046
|
-
setPending(null);
|
|
1047
|
-
setPendingReasoning(null);
|
|
1048
|
-
},
|
|
1049
|
-
onError: (message) => {
|
|
1050
|
-
if (turnRef.current !== myTurn) return;
|
|
1051
|
-
setError(message);
|
|
1052
|
-
setPending(null);
|
|
1053
|
-
setPendingReasoning(null);
|
|
1054
|
-
}
|
|
1263
|
+
attachments
|
|
1055
1264
|
});
|
|
1056
|
-
|
|
1057
|
-
if (
|
|
1058
|
-
|
|
1265
|
+
if (turnRef.current !== myTurn) return;
|
|
1266
|
+
if (msgId === null) {
|
|
1267
|
+
sendingRef.current = false;
|
|
1059
1268
|
setPending(null);
|
|
1060
1269
|
setPendingReasoning(null);
|
|
1270
|
+
return;
|
|
1061
1271
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1272
|
+
setMessages((prev) => prev.some((m) => m.msgId === msgId) ? prev.filter((m) => m.msgId !== localMsgId) : prev.map((m) => m.msgId === localMsgId ? {
|
|
1273
|
+
...m,
|
|
1274
|
+
msgId
|
|
1275
|
+
} : m));
|
|
1276
|
+
myTurnMsgIdRef.current = msgId;
|
|
1277
|
+
} catch (e) {
|
|
1064
1278
|
if (turnRef.current === myTurn) {
|
|
1065
|
-
|
|
1066
|
-
|
|
1279
|
+
sendingRef.current = false;
|
|
1280
|
+
setError(e.message);
|
|
1281
|
+
setPending(null);
|
|
1282
|
+
setPendingReasoning(null);
|
|
1067
1283
|
}
|
|
1068
1284
|
}
|
|
1069
1285
|
})();
|
|
1070
1286
|
return true;
|
|
1071
|
-
}, [convId
|
|
1287
|
+
}, [convId]),
|
|
1072
1288
|
open,
|
|
1073
1289
|
newConversation,
|
|
1074
1290
|
loadThreads,
|
|
@@ -1077,10 +1293,47 @@ function useChat() {
|
|
|
1077
1293
|
}
|
|
1078
1294
|
//#endregion
|
|
1079
1295
|
//#region src/ui/use-page-context.ts
|
|
1080
|
-
/**
|
|
1081
|
-
*
|
|
1082
|
-
|
|
1083
|
-
|
|
1296
|
+
/** The record page's own heading — a BEST-EFFORT read of the app's own rendered DOM,
|
|
1297
|
+
* not a data path. `RecordContent` (`packages/web/display/index.tsx`) marks the
|
|
1298
|
+
* TITLE element itself `role="heading" aria-level="1"` (the row around it, which also
|
|
1299
|
+
* holds the avatar, carries no role — otherwise the avatar's own text would be read
|
|
1300
|
+
* as part of the name); today that is the only element in the whole app carrying it,
|
|
1301
|
+
* so this selector cannot pick up some OTHER record's heading by mistake. It exists at
|
|
1302
|
+
* all because the chat overlay is a sibling of the routed page (mounted in the app
|
|
1303
|
+
* shell's own slot), never a descendant of it, so there is no React tree — no prop, no
|
|
1304
|
+
* context — to read the value from, and this package (`plugin-webchat`) has no data
|
|
1305
|
+
* path of its own to the open record either. Reading the painted DOM instead of
|
|
1306
|
+
* fetching the record: the SPA already has it open and displaying this text, and a
|
|
1307
|
+
* server-side lookup would be a query per turn for a value the browser is holding.
|
|
1308
|
+
*
|
|
1309
|
+
* WHEN it is read is the whole safety argument, so it is stated rather than assumed:
|
|
1310
|
+
* reading the DOM during React's RENDER phase sees the PREVIOUSLY COMMITTED tree, so a
|
|
1311
|
+
* navigation from one record to another produced the new path and id beside the OLD
|
|
1312
|
+
* record's name — a fact stated WRONG, which the base prompt tells the model to trust.
|
|
1313
|
+
* Hence `usePageContext` returns a READER the composer calls in its send handler: an
|
|
1314
|
+
* event handler runs after commit, so the heading and the path it is reported with
|
|
1315
|
+
* come from the same painted page. That is also what the design asks for — the page
|
|
1316
|
+
* the user was on AT THE MOMENT OF SENDING, and nowhere else.
|
|
1317
|
+
*
|
|
1318
|
+
* What is guaranteed, then: the title is read from the DOM as it stands when the turn
|
|
1319
|
+
* is sent. What is NOT guaranteed is that the title is present — a heading not yet
|
|
1320
|
+
* painted (a record still loading) and an untitled record's Dash placeholder both
|
|
1321
|
+
* yield no title, and an absent fact is the harmless case. One case yields a WRONG
|
|
1322
|
+
* one: if the app ever grows a SECOND element with this exact role/level (a preview
|
|
1323
|
+
* dialog, a relation picker, a plugin's own record renderer), `querySelector` returns
|
|
1324
|
+
* whichever comes first in document order, not necessarily the record the user is
|
|
1325
|
+
* actually looking at. Whoever eventually gives the overlay a real data path to the
|
|
1326
|
+
* open record should read this as the reason it was ever DOM-based, not rediscover it. */
|
|
1327
|
+
var EMPTY_TITLE = "—";
|
|
1328
|
+
function recordHeading() {
|
|
1329
|
+
if (typeof document === "undefined") return void 0;
|
|
1330
|
+
const text = document.querySelector("[role=\"heading\"][aria-level=\"1\"]")?.textContent?.trim();
|
|
1331
|
+
return text && text !== EMPTY_TITLE ? text : void 0;
|
|
1332
|
+
}
|
|
1333
|
+
/** SPA routes: `/:library/:type[/:id]`; system branches (settings, browse) are
|
|
1334
|
+
* returned by path only. Pure but for the heading read, which is why it takes the
|
|
1335
|
+
* path rather than calling the router itself. */
|
|
1336
|
+
function pageContextAt(pathname) {
|
|
1084
1337
|
const parts = pathname.split("/").filter(Boolean);
|
|
1085
1338
|
const system = /* @__PURE__ */ new Set([
|
|
1086
1339
|
"settings",
|
|
@@ -1091,13 +1344,25 @@ function usePageContext() {
|
|
|
1091
1344
|
]);
|
|
1092
1345
|
if (parts.length < 2 || system.has(parts[0])) return { path: pathname };
|
|
1093
1346
|
const [library, type, id] = parts;
|
|
1347
|
+
const isRecord = Boolean(id && id !== "new");
|
|
1348
|
+
const title = isRecord ? recordHeading() : void 0;
|
|
1094
1349
|
return {
|
|
1095
1350
|
path: pathname,
|
|
1096
1351
|
library,
|
|
1097
1352
|
type,
|
|
1098
|
-
...
|
|
1353
|
+
...isRecord ? { id } : {},
|
|
1354
|
+
...title ? { title } : {}
|
|
1099
1355
|
};
|
|
1100
1356
|
}
|
|
1357
|
+
/** Current-page context for the agent, as a reader called AT SEND TIME rather than a
|
|
1358
|
+
* value computed while rendering — see the heading comment above for why that
|
|
1359
|
+
* distinction is the difference between an absent title and a wrong one. The path
|
|
1360
|
+
* comes from the render that is currently committed, which is the same tree the
|
|
1361
|
+
* reader's heading lookup walks. */
|
|
1362
|
+
function usePageContext() {
|
|
1363
|
+
const { pathname } = useLocation();
|
|
1364
|
+
return useCallback(() => pageContextAt(pathname), [pathname]);
|
|
1365
|
+
}
|
|
1101
1366
|
//#endregion
|
|
1102
1367
|
//#region ../../node_modules/devlop/lib/development.js
|
|
1103
1368
|
var AssertionError = class extends Error {
|
|
@@ -23819,11 +24084,11 @@ function ChatMessages({ messages, pending, pendingReasoning, trailing }) {
|
|
|
23819
24084
|
var Bubble = memo(function Bubble({ role, text, reasoning, attachments }) {
|
|
23820
24085
|
const { t } = useTranslation();
|
|
23821
24086
|
if (role === "user") return /* @__PURE__ */ jsxs("div", {
|
|
23822
|
-
className: "self-end max-w-[85%] rounded-2xl
|
|
24087
|
+
className: "self-end max-w-[85%] rounded-2xl border border-accent bg-elevated px-3 py-2 text-[0.8125rem] whitespace-pre-wrap break-words",
|
|
23823
24088
|
children: [attachments?.length ? /* @__PURE__ */ jsx("div", {
|
|
23824
24089
|
className: "mb-1.5 flex flex-wrap gap-1",
|
|
23825
24090
|
children: attachments.map((a) => /* @__PURE__ */ jsx("span", {
|
|
23826
|
-
className: "inline-flex max-w-full items-center rounded-md bg-bg
|
|
24091
|
+
className: "inline-flex max-w-full items-center rounded-md bg-bg px-1.5 py-0.5 text-xs",
|
|
23827
24092
|
title: a.name,
|
|
23828
24093
|
children: a.label ?? a.name
|
|
23829
24094
|
}, a.name))
|
|
@@ -23984,7 +24249,8 @@ function AgentPicker({ agents, defaultAgentId, selection, onChange }) {
|
|
|
23984
24249
|
children: /* @__PURE__ */ jsx(Star, {
|
|
23985
24250
|
size: 16,
|
|
23986
24251
|
"aria-hidden": true,
|
|
23987
|
-
className: n <= shown ? "fill-
|
|
24252
|
+
className: n <= shown ? "fill-current" : "text-border",
|
|
24253
|
+
style: n <= shown ? { color: toneVar("yellow") } : void 0
|
|
23988
24254
|
})
|
|
23989
24255
|
}) }, `${r.agentId}/${r.presetId}`);
|
|
23990
24256
|
})
|
|
@@ -24177,7 +24443,7 @@ function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploa
|
|
|
24177
24443
|
}
|
|
24178
24444
|
//#endregion
|
|
24179
24445
|
//#region src/ui/ChatThreadList.tsx
|
|
24180
|
-
function ChatThreadList({ threads, activeId, onOpen, onNew, className = "w-[220px] shrink-0" }) {
|
|
24446
|
+
function ChatThreadList({ threads, activeId, onOpen, onNew, onTogglePrivate, className = "w-[220px] shrink-0" }) {
|
|
24181
24447
|
const { t } = useTranslation();
|
|
24182
24448
|
return /* @__PURE__ */ jsxs("aside", {
|
|
24183
24449
|
"aria-label": t("webchat.threads"),
|
|
@@ -24192,11 +24458,36 @@ function ChatThreadList({ threads, activeId, onOpen, onNew, className = "w-[220p
|
|
|
24192
24458
|
]
|
|
24193
24459
|
}), /* @__PURE__ */ jsx("div", {
|
|
24194
24460
|
className: "flex-1 overflow-y-auto",
|
|
24195
|
-
children: threads.map((th) =>
|
|
24196
|
-
|
|
24197
|
-
|
|
24198
|
-
|
|
24199
|
-
|
|
24461
|
+
children: threads.map((th) => {
|
|
24462
|
+
const active = th.convId === activeId;
|
|
24463
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
24464
|
+
className: `flex items-center gap-1 pl-1 pr-2 ${active ? "bg-elevated" : "hover:bg-elevated"}`,
|
|
24465
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
24466
|
+
onClick: () => onOpen(th.convId),
|
|
24467
|
+
className: `min-w-0 flex-1 py-2 pl-2 text-left text-sm ${active ? "font-medium" : ""}`,
|
|
24468
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
24469
|
+
className: "block truncate",
|
|
24470
|
+
children: th.title || t("webchat.newConversation")
|
|
24471
|
+
}), (th.mine || th.ownerName) && /* @__PURE__ */ jsx("span", {
|
|
24472
|
+
className: "block truncate text-xs text-muted",
|
|
24473
|
+
children: th.mine ? t("webchat.you") : th.ownerName
|
|
24474
|
+
})]
|
|
24475
|
+
}), th.mine && /* @__PURE__ */ jsx("button", {
|
|
24476
|
+
type: "button",
|
|
24477
|
+
"aria-label": th.private ? t("webchat.makePublic") : t("webchat.makePrivate"),
|
|
24478
|
+
title: th.private ? t("webchat.makePublic") : t("webchat.makePrivate"),
|
|
24479
|
+
onClick: (e) => {
|
|
24480
|
+
e.stopPropagation();
|
|
24481
|
+
onTogglePrivate(th.convId, !th.private);
|
|
24482
|
+
},
|
|
24483
|
+
className: "shrink-0 rounded p-1 text-muted hover:bg-surface hover:text-text focus-visible:outline-2 focus-visible:outline-accent",
|
|
24484
|
+
children: /* @__PURE__ */ jsx(Icon, {
|
|
24485
|
+
name: th.private ? "lucide:shield" : "lucide:globe",
|
|
24486
|
+
size: 14
|
|
24487
|
+
})
|
|
24488
|
+
})]
|
|
24489
|
+
}, th.convId);
|
|
24490
|
+
})
|
|
24200
24491
|
})]
|
|
24201
24492
|
});
|
|
24202
24493
|
}
|
|
@@ -24271,7 +24562,7 @@ function ChatDropSurface({ children, onFiles, className }) {
|
|
|
24271
24562
|
"data-testid": "chat-drop-overlay",
|
|
24272
24563
|
className: "pointer-events-none absolute inset-0 z-50 grid place-items-center bg-surface/95 p-6 text-center backdrop-blur-sm",
|
|
24273
24564
|
children: /* @__PURE__ */ jsxs("div", {
|
|
24274
|
-
className: "flex h-full w-full flex-col items-center justify-center rounded-2xl border-2 border-dashed border-accent
|
|
24565
|
+
className: "flex h-full w-full flex-col items-center justify-center rounded-2xl border-2 border-dashed border-accent text-accent",
|
|
24275
24566
|
children: [
|
|
24276
24567
|
/* @__PURE__ */ jsx(Upload, { size: 34 }),
|
|
24277
24568
|
/* @__PURE__ */ jsx("span", {
|
|
@@ -24316,7 +24607,7 @@ function stepMode(mode, wide, delta) {
|
|
|
24316
24607
|
function ChatWidget() {
|
|
24317
24608
|
const { t } = useTranslation();
|
|
24318
24609
|
const chat = useChat();
|
|
24319
|
-
const
|
|
24610
|
+
const readPage = usePageContext();
|
|
24320
24611
|
const [mode, setMode] = useState("bubble");
|
|
24321
24612
|
const [threadsOpen, setThreadsOpen] = useState(false);
|
|
24322
24613
|
const [attachments, setAttachments] = useState([]);
|
|
@@ -24413,6 +24704,7 @@ function ChatWidget() {
|
|
|
24413
24704
|
if (retryFiles.length) pickFiles(retryFiles);
|
|
24414
24705
|
}, [pickFiles, retryFiles]);
|
|
24415
24706
|
const sendTurn = useCallback((text, refs) => {
|
|
24707
|
+
const page = readPage();
|
|
24416
24708
|
const accepted = refs.length ? chat.send(text, page, refs) : chat.send(text, page);
|
|
24417
24709
|
if (accepted) {
|
|
24418
24710
|
setAttachments([]);
|
|
@@ -24420,7 +24712,7 @@ function ChatWidget() {
|
|
|
24420
24712
|
setRetryFiles([]);
|
|
24421
24713
|
}
|
|
24422
24714
|
return accepted;
|
|
24423
|
-
}, [chat,
|
|
24715
|
+
}, [chat, readPage]);
|
|
24424
24716
|
const hasConversation = chat.messages.length > 0 || chat.pending !== null;
|
|
24425
24717
|
const header = /* @__PURE__ */ jsxs("div", {
|
|
24426
24718
|
className: "flex shrink-0 items-center justify-between border-b border-border px-3 py-2",
|
|
@@ -24504,7 +24796,7 @@ function ChatWidget() {
|
|
|
24504
24796
|
if (mode === "bubble") return /* @__PURE__ */ jsx("button", {
|
|
24505
24797
|
"aria-label": t("webchat.open"),
|
|
24506
24798
|
onClick: () => setMode(restingMode(wide)),
|
|
24507
|
-
className: "fixed bottom-20 right-4 z-40 grid h-14 w-14 place-items-center rounded-full bg-accent text-
|
|
24799
|
+
className: "fixed bottom-20 right-4 z-40 grid h-14 w-14 place-items-center rounded-full bg-accent text-on-accent ring-1 ring-border2 shadow-lg nav:bottom-4",
|
|
24508
24800
|
children: /* @__PURE__ */ jsx(MessageCircle, { size: 24 })
|
|
24509
24801
|
});
|
|
24510
24802
|
if (mode === "panel") return /* @__PURE__ */ jsxs(ChatDropSurface, {
|
|
@@ -24541,6 +24833,7 @@ function ChatWidget() {
|
|
|
24541
24833
|
setThreadsOpen(false);
|
|
24542
24834
|
},
|
|
24543
24835
|
onNew: startNew,
|
|
24836
|
+
onTogglePrivate: chat.togglePrivate,
|
|
24544
24837
|
className: wide ? "w-[220px] shrink-0" : "w-full"
|
|
24545
24838
|
}), (wide || !threadsOpen) && conversation]
|
|
24546
24839
|
})]
|