iris-chatbot 5.2.0 → 5.3.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/package.json +1 -1
- package/template/package-lock.json +2 -2
- package/template/package.json +1 -1
- package/template/src/app/api/chat/route.ts +889 -92
- package/template/src/app/api/contacts/search/route.ts +71 -0
- package/template/src/app/api/tool-approval/route.ts +30 -2
- package/template/src/app/globals.css +223 -1
- package/template/src/components/ChatView.tsx +247 -27
- package/template/src/components/Composer.tsx +11 -8
- package/template/src/components/MessageCard.tsx +549 -29
- package/template/src/components/SettingsModal.tsx +7 -0
- package/template/src/lib/data.ts +5 -0
- package/template/src/lib/tooling/approvals.ts +24 -9
- package/template/src/lib/tooling/tools/communication.ts +178 -31
- package/template/src/lib/types.ts +12 -2
|
@@ -9,11 +9,12 @@ import {
|
|
|
9
9
|
ChevronUp,
|
|
10
10
|
Check,
|
|
11
11
|
Copy,
|
|
12
|
+
Send,
|
|
12
13
|
Pencil,
|
|
13
14
|
PlusCircle,
|
|
14
15
|
X,
|
|
15
16
|
} from "lucide-react";
|
|
16
|
-
import { memo, useMemo, useState } from "react";
|
|
17
|
+
import { memo, useMemo, useRef, useState } from "react";
|
|
17
18
|
import type {
|
|
18
19
|
ChatCitationSource,
|
|
19
20
|
MessageNode,
|
|
@@ -731,6 +732,502 @@ function getTimelineVisual(event: ToolEvent): {
|
|
|
731
732
|
};
|
|
732
733
|
}
|
|
733
734
|
|
|
735
|
+
type DraftTemplate = {
|
|
736
|
+
channel: "email" | "message";
|
|
737
|
+
to: string[];
|
|
738
|
+
cc: string[];
|
|
739
|
+
subject: string;
|
|
740
|
+
body: string;
|
|
741
|
+
attachments: string[];
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
type ContactSuggestion = {
|
|
745
|
+
name: string;
|
|
746
|
+
value: string;
|
|
747
|
+
detail: string;
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
function toStringArray(value: unknown): string[] {
|
|
751
|
+
if (!Array.isArray(value)) {
|
|
752
|
+
return [];
|
|
753
|
+
}
|
|
754
|
+
return value
|
|
755
|
+
.filter((item): item is string => typeof item === "string")
|
|
756
|
+
.map((item) => item.trim())
|
|
757
|
+
.filter(Boolean);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function dedupeStrings(values: string[]): string[] {
|
|
761
|
+
const seen = new Set<string>();
|
|
762
|
+
const out: string[] = [];
|
|
763
|
+
for (const value of values) {
|
|
764
|
+
if (!value || seen.has(value)) {
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
seen.add(value);
|
|
768
|
+
out.push(value);
|
|
769
|
+
}
|
|
770
|
+
return out;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function tokenizeRecipients(value: string): string[] {
|
|
774
|
+
return dedupeStrings(
|
|
775
|
+
value
|
|
776
|
+
.split(/[\n,]/g)
|
|
777
|
+
.map((item) => item.trim())
|
|
778
|
+
.filter(Boolean),
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function parseDraftTemplate(approval: ToolApproval): DraftTemplate | null {
|
|
783
|
+
if (approval.toolName !== "mail_send" && approval.toolName !== "messages_send") {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
const payload = parsePayload(approval.argsJson);
|
|
787
|
+
if (!payload) {
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
const to = toStringArray(payload.to);
|
|
791
|
+
const body = typeof payload.body === "string" ? payload.body : "";
|
|
792
|
+
if (to.length === 0 || !body) {
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
if (approval.toolName === "mail_send") {
|
|
796
|
+
const subject = typeof payload.subject === "string" ? payload.subject : "";
|
|
797
|
+
if (!subject) {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
channel: "email",
|
|
802
|
+
to,
|
|
803
|
+
cc: toStringArray(payload.cc),
|
|
804
|
+
subject,
|
|
805
|
+
body,
|
|
806
|
+
attachments: toStringArray(payload.attachments),
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
channel: "message",
|
|
811
|
+
to,
|
|
812
|
+
cc: [],
|
|
813
|
+
subject: "",
|
|
814
|
+
body,
|
|
815
|
+
attachments: [],
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function buildDraftCopyText(template: DraftTemplate): string {
|
|
820
|
+
if (template.channel === "email") {
|
|
821
|
+
return `Subject: ${template.subject}\n\n${template.body}`;
|
|
822
|
+
}
|
|
823
|
+
return template.body;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function DraftApprovalEditor({
|
|
827
|
+
approval,
|
|
828
|
+
isBusy,
|
|
829
|
+
onResolveApproval,
|
|
830
|
+
}: {
|
|
831
|
+
approval: ToolApproval;
|
|
832
|
+
isBusy: boolean;
|
|
833
|
+
onResolveApproval: (approvalId: string, decision: "approve" | "deny", argsOverride?: Record<string, unknown>) => void;
|
|
834
|
+
}) {
|
|
835
|
+
const parsed = parseDraftTemplate(approval);
|
|
836
|
+
const [toRecipients, setToRecipients] = useState<string[]>(() => parsed?.to ?? []);
|
|
837
|
+
const [ccRecipients, setCcRecipients] = useState<string[]>(() => parsed?.cc ?? []);
|
|
838
|
+
const [toInput, setToInput] = useState("");
|
|
839
|
+
const [ccInput, setCcInput] = useState("");
|
|
840
|
+
const [toSuggestions, setToSuggestions] = useState<ContactSuggestion[]>([]);
|
|
841
|
+
const [ccSuggestions, setCcSuggestions] = useState<ContactSuggestion[]>([]);
|
|
842
|
+
const [showToSuggestions, setShowToSuggestions] = useState(false);
|
|
843
|
+
const [showCcSuggestions, setShowCcSuggestions] = useState(false);
|
|
844
|
+
const [contactsHint, setContactsHint] = useState<string | null>(null);
|
|
845
|
+
const [subject, setSubject] = useState(() => parsed?.subject ?? "");
|
|
846
|
+
const [body, setBody] = useState(() => parsed?.body ?? "");
|
|
847
|
+
const [copiedDraft, setCopiedDraft] = useState(false);
|
|
848
|
+
const toSuggestTimerRef = useRef<number | null>(null);
|
|
849
|
+
const ccSuggestTimerRef = useRef<number | null>(null);
|
|
850
|
+
const toSuggestAbortRef = useRef<AbortController | null>(null);
|
|
851
|
+
const ccSuggestAbortRef = useRef<AbortController | null>(null);
|
|
852
|
+
|
|
853
|
+
if (!parsed) {
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const mergedTo = dedupeStrings([...toRecipients, ...tokenizeRecipients(toInput)]);
|
|
858
|
+
const mergedCc = dedupeStrings([...ccRecipients, ...tokenizeRecipients(ccInput)]);
|
|
859
|
+
const canSend =
|
|
860
|
+
mergedTo.length > 0 &&
|
|
861
|
+
body.trim().length > 0 &&
|
|
862
|
+
(parsed.channel === "message" || subject.trim().length > 0);
|
|
863
|
+
|
|
864
|
+
const commitToInput = () => {
|
|
865
|
+
if (!toInput.trim()) {
|
|
866
|
+
setToSuggestions([]);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
setToRecipients((current) => dedupeStrings([...current, ...tokenizeRecipients(toInput)]));
|
|
870
|
+
setToInput("");
|
|
871
|
+
setToSuggestions([]);
|
|
872
|
+
setShowToSuggestions(false);
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
const commitCcInput = () => {
|
|
876
|
+
if (!ccInput.trim()) {
|
|
877
|
+
setCcSuggestions([]);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
setCcRecipients((current) => dedupeStrings([...current, ...tokenizeRecipients(ccInput)]));
|
|
881
|
+
setCcInput("");
|
|
882
|
+
setCcSuggestions([]);
|
|
883
|
+
setShowCcSuggestions(false);
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const queueSuggestions = (params: { query: string; field: "to" | "cc" }) => {
|
|
887
|
+
const trimmed = params.query.trim();
|
|
888
|
+
const mode = parsed?.channel === "email" ? "email" : "message";
|
|
889
|
+
const timerRef = params.field === "to" ? toSuggestTimerRef : ccSuggestTimerRef;
|
|
890
|
+
const abortRef = params.field === "to" ? toSuggestAbortRef : ccSuggestAbortRef;
|
|
891
|
+
const setSuggestions = params.field === "to" ? setToSuggestions : setCcSuggestions;
|
|
892
|
+
|
|
893
|
+
if (timerRef.current !== null) {
|
|
894
|
+
window.clearTimeout(timerRef.current);
|
|
895
|
+
timerRef.current = null;
|
|
896
|
+
}
|
|
897
|
+
abortRef.current?.abort();
|
|
898
|
+
abortRef.current = null;
|
|
899
|
+
|
|
900
|
+
if (!trimmed || trimmed.length < 2) {
|
|
901
|
+
setSuggestions([]);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
timerRef.current = window.setTimeout(async () => {
|
|
906
|
+
const controller = new AbortController();
|
|
907
|
+
abortRef.current = controller;
|
|
908
|
+
try {
|
|
909
|
+
const response = await fetch("/api/contacts/search", {
|
|
910
|
+
method: "POST",
|
|
911
|
+
headers: { "Content-Type": "application/json" },
|
|
912
|
+
body: JSON.stringify({
|
|
913
|
+
query: trimmed,
|
|
914
|
+
mode,
|
|
915
|
+
}),
|
|
916
|
+
signal: controller.signal,
|
|
917
|
+
});
|
|
918
|
+
const payload = (await response.json().catch(() => ({}))) as {
|
|
919
|
+
ok?: boolean;
|
|
920
|
+
suggestions?: ContactSuggestion[];
|
|
921
|
+
error?: string;
|
|
922
|
+
permissionRequired?: boolean;
|
|
923
|
+
};
|
|
924
|
+
if (!response.ok || payload.ok === false) {
|
|
925
|
+
setSuggestions([]);
|
|
926
|
+
if (payload.permissionRequired) {
|
|
927
|
+
setContactsHint(
|
|
928
|
+
payload.error ||
|
|
929
|
+
"Contacts permission is required for suggestions. Approve when macOS prompts.",
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const suggestions = Array.isArray(payload.suggestions)
|
|
935
|
+
? payload.suggestions
|
|
936
|
+
.map((item) => ({
|
|
937
|
+
name: typeof item?.name === "string" ? item.name.trim() : "",
|
|
938
|
+
value: typeof item?.value === "string" ? item.value.trim() : "",
|
|
939
|
+
detail: typeof item?.detail === "string" ? item.detail.trim() : "",
|
|
940
|
+
}))
|
|
941
|
+
.filter((item) => item.name && item.value)
|
|
942
|
+
: [];
|
|
943
|
+
setContactsHint(null);
|
|
944
|
+
setSuggestions(suggestions);
|
|
945
|
+
} catch (error) {
|
|
946
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
setSuggestions([]);
|
|
950
|
+
}
|
|
951
|
+
}, 170);
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
const addRecipientFromSuggestion = (params: { field: "to" | "cc"; value: string }) => {
|
|
955
|
+
if (params.field === "to") {
|
|
956
|
+
setToRecipients((current) => dedupeStrings([...current, params.value]));
|
|
957
|
+
setToInput("");
|
|
958
|
+
setToSuggestions([]);
|
|
959
|
+
setShowToSuggestions(false);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
setCcRecipients((current) => dedupeStrings([...current, params.value]));
|
|
963
|
+
setCcInput("");
|
|
964
|
+
setCcSuggestions([]);
|
|
965
|
+
setShowCcSuggestions(false);
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
const buildOverrideArgs = (): Record<string, unknown> => {
|
|
969
|
+
const args: Record<string, unknown> = {
|
|
970
|
+
to: mergedTo,
|
|
971
|
+
body,
|
|
972
|
+
};
|
|
973
|
+
if (parsed.channel === "email") {
|
|
974
|
+
args.subject = subject;
|
|
975
|
+
if (mergedCc.length > 0) {
|
|
976
|
+
args.cc = mergedCc;
|
|
977
|
+
}
|
|
978
|
+
if (parsed.attachments.length > 0) {
|
|
979
|
+
args.attachments = parsed.attachments;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return args;
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
return (
|
|
986
|
+
<div className="draft-approval-card">
|
|
987
|
+
<div className="draft-approval-header">
|
|
988
|
+
<div className="draft-approval-title">{parsed.channel === "email" ? "Email" : "Message"} draft</div>
|
|
989
|
+
<div className="draft-approval-actions">
|
|
990
|
+
<button
|
|
991
|
+
className="draft-approval-icon-btn"
|
|
992
|
+
onClick={async () => {
|
|
993
|
+
await navigator.clipboard.writeText(
|
|
994
|
+
buildDraftCopyText({
|
|
995
|
+
...parsed,
|
|
996
|
+
to: mergedTo,
|
|
997
|
+
cc: mergedCc,
|
|
998
|
+
subject,
|
|
999
|
+
body,
|
|
1000
|
+
}),
|
|
1001
|
+
);
|
|
1002
|
+
setCopiedDraft(true);
|
|
1003
|
+
window.setTimeout(() => setCopiedDraft(false), 1200);
|
|
1004
|
+
}}
|
|
1005
|
+
disabled={isBusy}
|
|
1006
|
+
title="Copy draft"
|
|
1007
|
+
aria-label="Copy draft"
|
|
1008
|
+
>
|
|
1009
|
+
{copiedDraft ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
|
1010
|
+
</button>
|
|
1011
|
+
<button
|
|
1012
|
+
className="draft-approval-icon-btn primary"
|
|
1013
|
+
onClick={() => {
|
|
1014
|
+
commitToInput();
|
|
1015
|
+
commitCcInput();
|
|
1016
|
+
onResolveApproval(approval.id, "approve", buildOverrideArgs());
|
|
1017
|
+
}}
|
|
1018
|
+
disabled={isBusy || !canSend}
|
|
1019
|
+
title="Send"
|
|
1020
|
+
aria-label="Send draft"
|
|
1021
|
+
>
|
|
1022
|
+
<Send className="h-4 w-4" />
|
|
1023
|
+
</button>
|
|
1024
|
+
</div>
|
|
1025
|
+
</div>
|
|
1026
|
+
|
|
1027
|
+
<div className="draft-approval-field">
|
|
1028
|
+
<span className="draft-approval-label">To</span>
|
|
1029
|
+
<div className="draft-recipient-field-stack">
|
|
1030
|
+
<div className="draft-recipient-input-wrap">
|
|
1031
|
+
{toRecipients.map((recipient, index) => (
|
|
1032
|
+
<span key={`${recipient}-${index}`} className="draft-recipient-chip">
|
|
1033
|
+
{recipient}
|
|
1034
|
+
<button
|
|
1035
|
+
className="draft-recipient-chip-remove"
|
|
1036
|
+
onClick={() => setToRecipients((current) => current.filter((_, itemIndex) => itemIndex !== index))}
|
|
1037
|
+
aria-label={`Remove ${recipient}`}
|
|
1038
|
+
disabled={isBusy}
|
|
1039
|
+
>
|
|
1040
|
+
<X className="h-3 w-3" />
|
|
1041
|
+
</button>
|
|
1042
|
+
</span>
|
|
1043
|
+
))}
|
|
1044
|
+
<input
|
|
1045
|
+
value={toInput}
|
|
1046
|
+
onChange={(event) => {
|
|
1047
|
+
const next = event.target.value;
|
|
1048
|
+
setToInput(next);
|
|
1049
|
+
queueSuggestions({ query: next, field: "to" });
|
|
1050
|
+
}}
|
|
1051
|
+
onFocus={() => {
|
|
1052
|
+
setShowToSuggestions(true);
|
|
1053
|
+
queueSuggestions({ query: toInput, field: "to" });
|
|
1054
|
+
}}
|
|
1055
|
+
onBlur={() => {
|
|
1056
|
+
window.setTimeout(() => {
|
|
1057
|
+
setShowToSuggestions(false);
|
|
1058
|
+
commitToInput();
|
|
1059
|
+
}, 80);
|
|
1060
|
+
}}
|
|
1061
|
+
onKeyDown={(event) => {
|
|
1062
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
1063
|
+
if (showToSuggestions && toSuggestions.length > 0) {
|
|
1064
|
+
event.preventDefault();
|
|
1065
|
+
addRecipientFromSuggestion({ field: "to", value: toSuggestions[0].value });
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
event.preventDefault();
|
|
1069
|
+
commitToInput();
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (event.key === ",") {
|
|
1073
|
+
event.preventDefault();
|
|
1074
|
+
commitToInput();
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (event.key === "Backspace" && !toInput && toRecipients.length > 0) {
|
|
1078
|
+
setToRecipients((current) => current.slice(0, -1));
|
|
1079
|
+
}
|
|
1080
|
+
}}
|
|
1081
|
+
placeholder="Add recipient"
|
|
1082
|
+
className="draft-recipient-input"
|
|
1083
|
+
disabled={isBusy}
|
|
1084
|
+
/>
|
|
1085
|
+
</div>
|
|
1086
|
+
{showToSuggestions && (toSuggestions.length > 0 || contactsHint) ? (
|
|
1087
|
+
<div className="draft-contact-suggestions">
|
|
1088
|
+
{toSuggestions.map((suggestion) => (
|
|
1089
|
+
<button
|
|
1090
|
+
key={`${suggestion.value}-${suggestion.name}`}
|
|
1091
|
+
className="draft-contact-suggestion-item"
|
|
1092
|
+
onMouseDown={(event) => {
|
|
1093
|
+
event.preventDefault();
|
|
1094
|
+
addRecipientFromSuggestion({ field: "to", value: suggestion.value });
|
|
1095
|
+
}}
|
|
1096
|
+
type="button"
|
|
1097
|
+
>
|
|
1098
|
+
<span className="draft-contact-suggestion-name">{suggestion.name}</span>
|
|
1099
|
+
<span className="draft-contact-suggestion-detail">{suggestion.detail}</span>
|
|
1100
|
+
</button>
|
|
1101
|
+
))}
|
|
1102
|
+
{toSuggestions.length === 0 && contactsHint ? (
|
|
1103
|
+
<div className="draft-contact-suggestion-hint">{contactsHint}</div>
|
|
1104
|
+
) : null}
|
|
1105
|
+
</div>
|
|
1106
|
+
) : null}
|
|
1107
|
+
</div>
|
|
1108
|
+
</div>
|
|
1109
|
+
|
|
1110
|
+
{parsed.channel === "email" ? (
|
|
1111
|
+
<div className="draft-approval-field">
|
|
1112
|
+
<span className="draft-approval-label">Cc</span>
|
|
1113
|
+
<div className="draft-recipient-field-stack">
|
|
1114
|
+
<div className="draft-recipient-input-wrap">
|
|
1115
|
+
{ccRecipients.map((recipient, index) => (
|
|
1116
|
+
<span key={`${recipient}-${index}`} className="draft-recipient-chip">
|
|
1117
|
+
{recipient}
|
|
1118
|
+
<button
|
|
1119
|
+
className="draft-recipient-chip-remove"
|
|
1120
|
+
onClick={() => setCcRecipients((current) => current.filter((_, itemIndex) => itemIndex !== index))}
|
|
1121
|
+
aria-label={`Remove ${recipient}`}
|
|
1122
|
+
disabled={isBusy}
|
|
1123
|
+
>
|
|
1124
|
+
<X className="h-3 w-3" />
|
|
1125
|
+
</button>
|
|
1126
|
+
</span>
|
|
1127
|
+
))}
|
|
1128
|
+
<input
|
|
1129
|
+
value={ccInput}
|
|
1130
|
+
onChange={(event) => {
|
|
1131
|
+
const next = event.target.value;
|
|
1132
|
+
setCcInput(next);
|
|
1133
|
+
queueSuggestions({ query: next, field: "cc" });
|
|
1134
|
+
}}
|
|
1135
|
+
onFocus={() => {
|
|
1136
|
+
setShowCcSuggestions(true);
|
|
1137
|
+
queueSuggestions({ query: ccInput, field: "cc" });
|
|
1138
|
+
}}
|
|
1139
|
+
onBlur={() => {
|
|
1140
|
+
window.setTimeout(() => {
|
|
1141
|
+
setShowCcSuggestions(false);
|
|
1142
|
+
commitCcInput();
|
|
1143
|
+
}, 80);
|
|
1144
|
+
}}
|
|
1145
|
+
onKeyDown={(event) => {
|
|
1146
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
1147
|
+
if (showCcSuggestions && ccSuggestions.length > 0) {
|
|
1148
|
+
event.preventDefault();
|
|
1149
|
+
addRecipientFromSuggestion({ field: "cc", value: ccSuggestions[0].value });
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
event.preventDefault();
|
|
1153
|
+
commitCcInput();
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
if (event.key === ",") {
|
|
1157
|
+
event.preventDefault();
|
|
1158
|
+
commitCcInput();
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
if (event.key === "Backspace" && !ccInput && ccRecipients.length > 0) {
|
|
1162
|
+
setCcRecipients((current) => current.slice(0, -1));
|
|
1163
|
+
}
|
|
1164
|
+
}}
|
|
1165
|
+
placeholder="Add cc"
|
|
1166
|
+
className="draft-recipient-input"
|
|
1167
|
+
disabled={isBusy}
|
|
1168
|
+
/>
|
|
1169
|
+
</div>
|
|
1170
|
+
{showCcSuggestions && (ccSuggestions.length > 0 || contactsHint) ? (
|
|
1171
|
+
<div className="draft-contact-suggestions">
|
|
1172
|
+
{ccSuggestions.map((suggestion) => (
|
|
1173
|
+
<button
|
|
1174
|
+
key={`${suggestion.value}-${suggestion.name}`}
|
|
1175
|
+
className="draft-contact-suggestion-item"
|
|
1176
|
+
onMouseDown={(event) => {
|
|
1177
|
+
event.preventDefault();
|
|
1178
|
+
addRecipientFromSuggestion({ field: "cc", value: suggestion.value });
|
|
1179
|
+
}}
|
|
1180
|
+
type="button"
|
|
1181
|
+
>
|
|
1182
|
+
<span className="draft-contact-suggestion-name">{suggestion.name}</span>
|
|
1183
|
+
<span className="draft-contact-suggestion-detail">{suggestion.detail}</span>
|
|
1184
|
+
</button>
|
|
1185
|
+
))}
|
|
1186
|
+
{ccSuggestions.length === 0 && contactsHint ? (
|
|
1187
|
+
<div className="draft-contact-suggestion-hint">{contactsHint}</div>
|
|
1188
|
+
) : null}
|
|
1189
|
+
</div>
|
|
1190
|
+
) : null}
|
|
1191
|
+
</div>
|
|
1192
|
+
</div>
|
|
1193
|
+
) : null}
|
|
1194
|
+
|
|
1195
|
+
{parsed.channel === "email" ? (
|
|
1196
|
+
<div className="draft-approval-field">
|
|
1197
|
+
<span className="draft-approval-label">Subject</span>
|
|
1198
|
+
<input
|
|
1199
|
+
value={subject}
|
|
1200
|
+
onChange={(event) => setSubject(event.target.value)}
|
|
1201
|
+
className="draft-approval-text-input"
|
|
1202
|
+
disabled={isBusy}
|
|
1203
|
+
/>
|
|
1204
|
+
</div>
|
|
1205
|
+
) : null}
|
|
1206
|
+
|
|
1207
|
+
<div className="draft-approval-field">
|
|
1208
|
+
<span className="draft-approval-label">Message</span>
|
|
1209
|
+
<textarea
|
|
1210
|
+
value={body}
|
|
1211
|
+
onChange={(event) => setBody(event.target.value)}
|
|
1212
|
+
className="draft-approval-textarea"
|
|
1213
|
+
disabled={isBusy}
|
|
1214
|
+
rows={parsed.channel === "email" ? 8 : 5}
|
|
1215
|
+
/>
|
|
1216
|
+
</div>
|
|
1217
|
+
|
|
1218
|
+
<div className="draft-approval-footer">
|
|
1219
|
+
<button
|
|
1220
|
+
className="draft-approval-cancel"
|
|
1221
|
+
onClick={() => onResolveApproval(approval.id, "deny")}
|
|
1222
|
+
disabled={isBusy}
|
|
1223
|
+
>
|
|
1224
|
+
Cancel
|
|
1225
|
+
</button>
|
|
1226
|
+
</div>
|
|
1227
|
+
</div>
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
734
1231
|
function MessageCard({
|
|
735
1232
|
message,
|
|
736
1233
|
onAddThread,
|
|
@@ -755,7 +1252,11 @@ function MessageCard({
|
|
|
755
1252
|
isStreaming?: boolean;
|
|
756
1253
|
toolEvents?: ToolEvent[];
|
|
757
1254
|
approvals?: ToolApproval[];
|
|
758
|
-
onResolveApproval?: (
|
|
1255
|
+
onResolveApproval?: (
|
|
1256
|
+
approvalId: string,
|
|
1257
|
+
decision: "approve" | "deny",
|
|
1258
|
+
argsOverride?: Record<string, unknown>,
|
|
1259
|
+
) => void;
|
|
759
1260
|
approvalBusyIds?: Record<string, boolean>;
|
|
760
1261
|
}) {
|
|
761
1262
|
const [copied, setCopied] = useState(false);
|
|
@@ -966,6 +1467,12 @@ function MessageCard({
|
|
|
966
1467
|
{approvalItems.map((approval) => {
|
|
967
1468
|
const isPending = approval.status === "requested";
|
|
968
1469
|
const isBusy = Boolean(approvalBusyIds?.[approval.id]);
|
|
1470
|
+
const draftTemplate = parseDraftTemplate(approval);
|
|
1471
|
+
const showDraftEditor =
|
|
1472
|
+
isPending &&
|
|
1473
|
+
Boolean(onResolveApproval) &&
|
|
1474
|
+
Boolean(draftTemplate) &&
|
|
1475
|
+
(approval.toolName === "mail_send" || approval.toolName === "messages_send");
|
|
969
1476
|
const callSummary = summarizeToolCall(approval.toolName, parsePayload(approval.argsJson));
|
|
970
1477
|
const approvalLabel =
|
|
971
1478
|
approval.status === "requested"
|
|
@@ -985,33 +1492,46 @@ function MessageCard({
|
|
|
985
1492
|
{approvalLabel}
|
|
986
1493
|
</span>
|
|
987
1494
|
</div>
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
</
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1495
|
+
{showDraftEditor && onResolveApproval ? (
|
|
1496
|
+
<div className="mt-2">
|
|
1497
|
+
<DraftApprovalEditor
|
|
1498
|
+
key={`draft-${approval.id}-${approval.argsJson ?? ""}`}
|
|
1499
|
+
approval={approval}
|
|
1500
|
+
isBusy={isBusy}
|
|
1501
|
+
onResolveApproval={onResolveApproval}
|
|
1502
|
+
/>
|
|
1503
|
+
</div>
|
|
1504
|
+
) : (
|
|
1505
|
+
<>
|
|
1506
|
+
<div className="mt-1 text-sm text-[var(--text-primary)]">{callSummary.title}</div>
|
|
1507
|
+
{callSummary.detail ? (
|
|
1508
|
+
<div className="mt-1 text-xs text-[var(--text-muted)]">{callSummary.detail}</div>
|
|
1509
|
+
) : null}
|
|
1510
|
+
{approval.reason ? (
|
|
1511
|
+
<div className="mt-1 text-xs text-[var(--text-muted)]">{approval.reason}</div>
|
|
1512
|
+
) : null}
|
|
1513
|
+
<div className="mt-2 flex items-center gap-2">
|
|
1514
|
+
{isPending && onResolveApproval ? (
|
|
1515
|
+
<>
|
|
1516
|
+
<button
|
|
1517
|
+
className="rounded-full border border-[var(--border)] bg-[var(--panel-2)] px-3 py-1 text-[10px] text-[var(--text-secondary)] hover:border-[var(--border-strong)]"
|
|
1518
|
+
onClick={() => onResolveApproval(approval.id, "approve")}
|
|
1519
|
+
disabled={isBusy}
|
|
1520
|
+
>
|
|
1521
|
+
Approve
|
|
1522
|
+
</button>
|
|
1523
|
+
<button
|
|
1524
|
+
className="rounded-full border border-[var(--border)] bg-[var(--panel-2)] px-3 py-1 text-[10px] text-[var(--text-secondary)] hover:border-[var(--border-strong)]"
|
|
1525
|
+
onClick={() => onResolveApproval(approval.id, "deny")}
|
|
1526
|
+
disabled={isBusy}
|
|
1527
|
+
>
|
|
1528
|
+
Deny
|
|
1529
|
+
</button>
|
|
1530
|
+
</>
|
|
1531
|
+
) : null}
|
|
1532
|
+
</div>
|
|
1533
|
+
</>
|
|
1534
|
+
)}
|
|
1015
1535
|
</div>
|
|
1016
1536
|
);
|
|
1017
1537
|
})}
|
|
@@ -122,6 +122,11 @@ export default function SettingsModal({
|
|
|
122
122
|
const [toolsEnabled, setToolsEnabled] = useState(localTools.enabled);
|
|
123
123
|
const [approvalMode, setApprovalMode] = useState<ApprovalMode>(localTools.approvalMode);
|
|
124
124
|
const [safetyProfile, setSafetyProfile] = useState<SafetyProfile>(localTools.safetyProfile);
|
|
125
|
+
const [preSendDraftReview, setPreSendDraftReview] = useState(
|
|
126
|
+
typeof localTools.preSendDraftReview === "boolean"
|
|
127
|
+
? localTools.preSendDraftReview
|
|
128
|
+
: DEFAULT_LOCAL_TOOLS_SETTINGS.preSendDraftReview,
|
|
129
|
+
);
|
|
125
130
|
const [allowedRootsText, setAllowedRootsText] = useState((localTools.allowedRoots ?? []).join("\n"));
|
|
126
131
|
const [enableNotes, setEnableNotes] = useState(localTools.enableNotes);
|
|
127
132
|
const [enableApps, setEnableApps] = useState(localTools.enableApps);
|
|
@@ -534,6 +539,7 @@ export default function SettingsModal({
|
|
|
534
539
|
enabled: toolsEnabled,
|
|
535
540
|
approvalMode,
|
|
536
541
|
safetyProfile,
|
|
542
|
+
preSendDraftReview,
|
|
537
543
|
allowedRoots:
|
|
538
544
|
allowedRoots.length > 0 ? allowedRoots : [...DEFAULT_LOCAL_TOOLS_SETTINGS.allowedRoots],
|
|
539
545
|
enableNotes,
|
|
@@ -983,6 +989,7 @@ export default function SettingsModal({
|
|
|
983
989
|
{ label: "Enable music controls", checked: enableMusic, setter: setEnableMusic },
|
|
984
990
|
{ label: "Enable calendar/reminders tools", checked: enableCalendar, setter: setEnableCalendar },
|
|
985
991
|
{ label: "Enable mail/messages tools", checked: enableMail, setter: setEnableMail },
|
|
992
|
+
{ label: "Require pre-send draft review for email/messages", checked: preSendDraftReview, setter: setPreSendDraftReview },
|
|
986
993
|
{ label: "Enable workflow tool", checked: enableWorkflow, setter: setEnableWorkflow },
|
|
987
994
|
{ label: "Enable system controls", checked: enableSystem, setter: setEnableSystem },
|
|
988
995
|
].map((item) => (
|
package/template/src/lib/data.ts
CHANGED
|
@@ -51,6 +51,7 @@ function isLegacyDefaultLocalToolsSettings(localTools: LocalToolsSettings): bool
|
|
|
51
51
|
localTools.enabled === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enabled &&
|
|
52
52
|
localTools.approvalMode === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.approvalMode &&
|
|
53
53
|
localTools.safetyProfile === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.safetyProfile &&
|
|
54
|
+
localTools.preSendDraftReview === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.preSendDraftReview &&
|
|
54
55
|
sameStringArray(localTools.allowedRoots, LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.allowedRoots) &&
|
|
55
56
|
localTools.enableNotes === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enableNotes &&
|
|
56
57
|
localTools.enableApps === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enableApps &&
|
|
@@ -82,6 +83,10 @@ function normalizeLocalToolsSettings(
|
|
|
82
83
|
localTools.approvalMode ?? DEFAULT_LOCAL_TOOLS_SETTINGS.approvalMode,
|
|
83
84
|
safetyProfile:
|
|
84
85
|
localTools.safetyProfile ?? DEFAULT_LOCAL_TOOLS_SETTINGS.safetyProfile,
|
|
86
|
+
preSendDraftReview:
|
|
87
|
+
typeof localTools.preSendDraftReview === "boolean"
|
|
88
|
+
? localTools.preSendDraftReview
|
|
89
|
+
: DEFAULT_LOCAL_TOOLS_SETTINGS.preSendDraftReview,
|
|
85
90
|
allowedRoots:
|
|
86
91
|
Array.isArray(localTools.allowedRoots) && localTools.allowedRoots.length > 0
|
|
87
92
|
? localTools.allowedRoots.filter((root) => typeof root === "string" && root.trim())
|