@nextclaw/ui 0.27.3 → 0.27.5-beta.0
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/CHANGELOG.md +24 -0
- package/dist/.vite/manifest.json +416 -416
- package/dist/assets/{account-panel-DiKpSvqS.js → account-panel-DgqicWMm.js} +1 -1
- package/dist/assets/{agents-lT-bDFex.js → agents-CTSR4ZKb.js} +1 -1
- package/dist/assets/agents-i7167DcF.js +1 -0
- package/dist/assets/{api-BaML_txv.js → api-CXWxExzS.js} +2 -2
- package/dist/assets/app-presenter-provider-DdD2Q7jZ.js +29 -0
- package/dist/assets/{appearance-settings-page-BNIxFwEY.js → appearance-settings-page-CSWYLrsR.js} +1 -1
- package/dist/assets/apps-DUP3aPpu.js +1 -0
- package/dist/assets/{book-open-oG0GrLyy.js → book-open-B3Ac0voD.js} +1 -1
- package/dist/assets/{card-DxCOCY_7.js → card-eFBmxYiI.js} +1 -1
- package/dist/assets/{channels-list-page-UQ1xjKw9.js → channels-list-page-lQOINLW8.js} +1 -1
- package/dist/assets/{circle-dashed-pgOOTUik.js → circle-dashed-KGwElDWV.js} +1 -1
- package/dist/assets/{client-C1Z8ITVa.js → client-BDS360Kz.js} +1 -1
- package/dist/assets/{config-split-page-Cfozkh5B.js → config-split-page-BaHtm9OW.js} +1 -1
- package/dist/assets/{confirm-dialog-BdEQiFoD.js → confirm-dialog-jz2cQKId.js} +1 -1
- package/dist/assets/{context-menu-DVgLoPqH.js → context-menu-C0qywo73.js} +71 -72
- package/dist/assets/{createLucideIcon-D-7Sxfw7.js → createLucideIcon-C3LSyx-e.js} +1 -1
- package/dist/assets/cron-DPBUHn7j.js +1 -0
- package/dist/assets/{desktop-capabilities-page-gakZ5UKX.js → desktop-capabilities-page-CKq2Miye.js} +1 -1
- package/dist/assets/{desktop-update-config-CVUwJyV-.js → desktop-update-config-C_SBtjA1.js} +1 -1
- package/dist/assets/{dialog-InO6RVpm.js → dialog-DMKYZDFu.js} +1 -1
- package/dist/assets/dist-D9h2cWk3.js +41 -0
- package/dist/assets/doc-browser-C7kvXGc_.js +1 -0
- package/dist/assets/{doc-browser-context-Bs122rxO.js → doc-browser-context-CoVMIiU8.js} +1 -1
- package/dist/assets/{download-BN4aCzSx.js → download-C2gYe8n1.js} +1 -1
- package/dist/assets/{extensions-hxS830nS.js → extensions-DXDFcRVC.js} +1 -1
- package/dist/assets/{external-link-NwtWKzE2.js → external-link-DZzwHRHW.js} +1 -1
- package/dist/assets/{form-actions-XP38NiPw.js → form-actions-CZxcWXSm.js} +1 -1
- package/dist/assets/{i18n-Bzo4IocH.js → i18n-ByVUrt9w.js} +2 -2
- package/dist/assets/inbox-C09MQO9k.js +1 -0
- package/dist/assets/{inbox-DofXi7Xv.js → inbox-CI8ATevA.js} +1 -1
- package/dist/assets/{keyboard-shortcuts-settings-page-Cn3zyBq_.js → keyboard-shortcuts-settings-page-BCxB9m69.js} +1 -1
- package/dist/assets/{login-page-CkfjjXD2.js → login-page--Njgbmpm.js} +1 -1
- package/dist/assets/login-page-CzvxaAU8.js +1 -0
- package/dist/assets/{main-BI7BtvLx.js → main-C6VGoYNO.js} +2 -2
- package/dist/assets/main-i18n-catalog.config-_4gMZaNz.js +1 -0
- package/dist/assets/main-kp_0b1bg.css +1 -0
- package/dist/assets/marketplace-CXCLbyho.js +1 -0
- package/dist/assets/mcp-marketplace-page-ZAm380SB.js +1 -0
- package/dist/assets/{model-config-page-BCGCP2Wc.js → model-config-page-CeVvZ29o.js} +1 -1
- package/dist/assets/{navigation-link-tawg3qi2.js → navigation-link-O9Wm0epj.js} +1 -1
- package/dist/assets/panel-app-i18n-catalog.config-B9c87s2X.js +1 -0
- package/dist/assets/panel-apps-BQlyeAId.js +1 -0
- package/dist/assets/{panelStandalone-smtUeY2k.js → panelStandalone-DomappXm.js} +2 -2
- package/dist/assets/plus-C42o6xrD.js +1 -0
- package/dist/assets/{privacy-settings-page-D4TI_vdw.js → privacy-settings-page-CHDv9ppI.js} +1 -1
- package/dist/assets/{projects-Rm2oKUPt.js → projects-BWM-oEo0.js} +1 -1
- package/dist/assets/{provider-models-CWtoV3BJ.js → provider-models-D8WOdsdu.js} +1 -1
- package/dist/assets/{provider-scoped-model-input-DJzdGBE8.js → provider-scoped-model-input-BOPkUOtD.js} +1 -1
- package/dist/assets/{providers-config-page-gzLkTzMP.js → providers-config-page-BilPL0kF.js} +1 -1
- package/dist/assets/{puzzle-CXL_z0vz.js → puzzle-DmKVREoh.js} +1 -1
- package/dist/assets/{pwa-Blsqtn1m.js → pwa-Cp7pPvsZ.js} +1 -1
- package/dist/assets/{refresh-cw-CqsHJPqR.js → refresh-cw-DOg2pWGu.js} +1 -1
- package/dist/assets/remote-DngM8iep.js +1 -0
- package/dist/assets/{rotate-cw-DS-Gca0a.js → rotate-cw-CWFIat29.js} +1 -1
- package/dist/assets/{runtime-config-page-B5CCnrei.js → runtime-config-page-Dx6lWn3_.js} +1 -1
- package/dist/assets/{save-CwxRo7hc.js → save-C1G0ZU-G.js} +1 -1
- package/dist/assets/{search-Drx-sMCE.js → search-Dpgv--de.js} +1 -1
- package/dist/assets/{search-config-page-BjVONAIH.js → search-config-page-DevSv1ZK.js} +1 -1
- package/dist/assets/{secrets-config-page-BB32noHT.js → secrets-config-page-B4jWTV6V.js} +1 -1
- package/dist/assets/security-config-BGEs0SHi.js +1 -0
- package/dist/assets/{select-DHYR109w.js → select-BJ-EjmCz.js} +1 -1
- package/dist/assets/{service-apps-panel-DOKGtwsR.js → service-apps-panel-9khaUSFI.js} +1 -1
- package/dist/assets/{settings-2-DAPjKYtK.js → settings-2-D3-m8a3v.js} +1 -1
- package/dist/assets/{status-dot-xxVxbdQr.js → status-dot-DlELD18e.js} +1 -1
- package/dist/assets/{tag-chip-0lQj9HFa.js → tag-chip-DFzDSvcF.js} +1 -1
- package/dist/assets/{theme-provider-DDJOlUxn.js → theme-provider-BsJ6HxeX.js} +1 -1
- package/dist/assets/{trash-2-B8mbrtCx.js → trash-2-BVQs-0ji.js} +1 -1
- package/dist/assets/{use-config-BpKXDcM_.js → use-config-M1L4xn8c.js} +1 -1
- package/dist/assets/{use-desktop-capabilities-C1bXk7fx.js → use-desktop-capabilities-BO_2CXgu.js} +1 -1
- package/dist/assets/{use-viewport-layout-D6seoW0Z.js → use-viewport-layout-Bnm5W5Qz.js} +1 -1
- package/dist/assets/{x-DLvS4xvV.js → x-waP-WLuT.js} +1 -1
- package/dist/index.html +37 -37
- package/dist/panel-standalone.html +14 -14
- package/package.json +8 -8
- package/src/features/chat/features/conversation/components/__tests__/session-user-questions.test.tsx +98 -0
- package/src/features/chat/features/conversation/components/session-conversation-area.tsx +14 -3
- package/src/features/chat/features/conversation/components/session-user-questions.tsx +177 -0
- package/src/features/chat/features/conversation/hooks/use-session-user-questions.ts +100 -0
- package/src/features/chat/features/message/components/chat-message-list.container.tsx +7 -0
- package/src/features/chat/features/message/utils/__tests__/chat-message-timeline.utils.test.ts +16 -0
- package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +62 -0
- package/src/features/chat/features/message/utils/chat-message-part.utils.ts +12 -0
- package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +3 -3
- package/src/features/chat/features/message/utils/chat-message.utils.ts +62 -9
- package/src/features/chat/features/message/utils/chat-user-question-timeline.utils.ts +42 -0
- package/src/shared/lib/i18n/locales/en-US/chat.json +16 -0
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -0
- package/dist/assets/agents-nyTxTl89.js +0 -1
- package/dist/assets/app-presenter-provider-DSlY9Lz6.js +0 -28
- package/dist/assets/apps-D7Se5AXs.js +0 -1
- package/dist/assets/cron-wSJKKm6g.js +0 -1
- package/dist/assets/dist-BxOjfeaD.js +0 -41
- package/dist/assets/doc-browser-Cuedjp9j.js +0 -1
- package/dist/assets/inbox-CnpKPeFb.js +0 -1
- package/dist/assets/login-page-3Yv6FU7k.js +0 -1
- package/dist/assets/main-D_zmSVS1.css +0 -1
- package/dist/assets/main-i18n-catalog.config-ykBbOfNP.js +0 -1
- package/dist/assets/marketplace-BkdTZexc.js +0 -1
- package/dist/assets/mcp-marketplace-page-BqFum360.js +0 -1
- package/dist/assets/panel-app-i18n-catalog.config-DEFg3R5S.js +0 -1
- package/dist/assets/panel-apps-CRjFqZZD.js +0 -1
- package/dist/assets/plus-v29lPO18.js +0 -1
- package/dist/assets/remote-DTe4pS7P.js +0 -1
- package/dist/assets/security-config--ilpaAEr.js +0 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { ArrowRight, CircleHelp, CircleSlash, Loader2, MessageCircleQuestion, MessageSquareQuote, X } from "lucide-react";
|
|
3
|
+
import type { NcpRunHandle } from "@nextclaw/client-sdk";
|
|
4
|
+
import { nextclawClient } from "@/shared/lib/api";
|
|
5
|
+
import { t } from "@/shared/lib/i18n";
|
|
6
|
+
import { cn } from "@/shared/lib/utils";
|
|
7
|
+
import type { ChatUserQuestionReply } from "@/features/chat/features/message/utils/chat-message.utils";
|
|
8
|
+
import { ChatConversationTrack } from "@/features/chat/components/conversation/chat-conversation-track";
|
|
9
|
+
import { useChatMessageLayoutStore } from "@/features/chat/stores/chat-message-layout.store";
|
|
10
|
+
import { useSessionUserQuestions, type Question, type QuestionContext } from "@/features/chat/features/conversation/hooks/use-session-user-questions";
|
|
11
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip";
|
|
12
|
+
|
|
13
|
+
const context = createContext<QuestionContext | null>(null);
|
|
14
|
+
|
|
15
|
+
export function SessionUserQuestionProvider({ children, sessionId, disabled, onResolved }: {
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
sessionId: string | null;
|
|
18
|
+
disabled: boolean;
|
|
19
|
+
onResolved: (handle: NcpRunHandle) => Promise<void>;
|
|
20
|
+
}) {
|
|
21
|
+
const value = useSessionUserQuestions({ sessionId, disabled, onResolved });
|
|
22
|
+
return <context.Provider value={value}>{children}</context.Provider>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function UserQuestionInlineQuestions({ data }: { data: unknown }) {
|
|
26
|
+
const state = useContext(context);
|
|
27
|
+
const entries = data && typeof data === "object" && !Array.isArray(data) ? (data as { questions?: unknown }).questions : null;
|
|
28
|
+
if (!Array.isArray(entries)) return null;
|
|
29
|
+
return (
|
|
30
|
+
<span className="inline-flex flex-wrap items-center gap-1.5 align-middle">
|
|
31
|
+
{entries.map((entry) => {
|
|
32
|
+
if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || typeof entry.title !== "string") return null;
|
|
33
|
+
const question = state?.questions.find((item) => item.id === entry.id);
|
|
34
|
+
const status = question?.status ?? "pending";
|
|
35
|
+
const label = status === "answered" ? t("chatUserQuestionAnswered") : status === "dismissed" ? t("chatUserQuestionDismissed") : t("chatUserQuestionPending");
|
|
36
|
+
return status === "pending" ? (
|
|
37
|
+
<button key={entry.id} type="button" aria-expanded={state?.openId === entry.id} onClick={(event) => state?.openQuestion(entry.id, event.currentTarget)}
|
|
38
|
+
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-border bg-muted/60 px-2 py-1 text-xs leading-4 text-foreground transition-colors hover:border-[var(--interaction-selection-border)] hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
39
|
+
<MessageCircleQuestion aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
|
40
|
+
<span className="truncate">{entry.title}</span><span className="shrink-0 text-muted-foreground">{label}</span>
|
|
41
|
+
</button>
|
|
42
|
+
) : (
|
|
43
|
+
<span key={entry.id} className="inline-flex max-w-full items-center gap-1.5 rounded-md bg-muted/40 px-2 py-1 text-xs leading-4 text-muted-foreground" title={question?.answer}>
|
|
44
|
+
<span className="truncate">{entry.title}</span><span>· {label}</span>
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
})}
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function UserQuestionReplyReference({ data }: { data: unknown }) {
|
|
53
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
|
|
54
|
+
const reply = data as ChatUserQuestionReply;
|
|
55
|
+
if (typeof reply.questionId !== "string" || typeof reply.title !== "string") return null;
|
|
56
|
+
const dismissed = reply.action === "dismissed";
|
|
57
|
+
const label = dismissed ? t("chatUserQuestionDismissed") : t("chatUserQuestionReplyReference");
|
|
58
|
+
const Icon = dismissed ? CircleSlash : MessageSquareQuote;
|
|
59
|
+
return (
|
|
60
|
+
<blockquote data-chat-question-reference-id={reply.questionId}
|
|
61
|
+
data-chat-question-message-id={reply.questionMessageId ?? undefined}
|
|
62
|
+
aria-label={`${label}:${reply.title}`}
|
|
63
|
+
title={`${label}:${reply.title}`}
|
|
64
|
+
className="mb-2 flex max-w-full items-center gap-1.5 border-l-2 border-current/35 py-0.5 pl-2 text-xs leading-4 opacity-70">
|
|
65
|
+
<Icon aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
|
66
|
+
<span className="min-w-0 truncate">{reply.title}</span>
|
|
67
|
+
</blockquote>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function UserQuestionPanel({ question, pending, state }: { question: Question; pending: readonly Question[]; state: QuestionContext }) {
|
|
72
|
+
const [answer, setAnswer] = useState("");
|
|
73
|
+
const [selectedOption, setSelectedOption] = useState<string | null>(null);
|
|
74
|
+
const [activeHelp, setActiveHelp] = useState<string | null>(null);
|
|
75
|
+
const [submitting, setSubmitting] = useState(false);
|
|
76
|
+
const [error, setError] = useState<string | null>(null);
|
|
77
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
78
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
79
|
+
const submittingRef = useRef(false);
|
|
80
|
+
useEffect(() => { panelRef.current?.focus(); }, []);
|
|
81
|
+
useEffect(() => { if (!question.options?.length) inputRef.current?.focus(); }, [question.options]);
|
|
82
|
+
const resolve = async (action: "answer" | "dismiss", answerValue?: string) => {
|
|
83
|
+
if (!state.sessionId || submittingRef.current || (action === "answer" && !answerValue?.trim())) return;
|
|
84
|
+
submittingRef.current = true;
|
|
85
|
+
setSubmitting(true);
|
|
86
|
+
setError(null);
|
|
87
|
+
setSelectedOption(action === "answer" && question.options?.includes(answerValue ?? "") ? answerValue ?? null : null);
|
|
88
|
+
try {
|
|
89
|
+
const result = await nextclawClient.sessions.resolveUserQuestion(state.sessionId, question.id, {
|
|
90
|
+
action,
|
|
91
|
+
...(action === "answer" ? { answer: answerValue?.trim() } : {}),
|
|
92
|
+
});
|
|
93
|
+
state.closeQuestion();
|
|
94
|
+
await state.refresh();
|
|
95
|
+
if (result.handle) await state.onResolved(result.handle);
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
setSelectedOption(null);
|
|
98
|
+
setError(cause instanceof Error ? cause.message : t("chatUserQuestionFailed"));
|
|
99
|
+
} finally {
|
|
100
|
+
submittingRef.current = false;
|
|
101
|
+
setSubmitting(false);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
return (
|
|
105
|
+
<div ref={panelRef} role="dialog" aria-label={question.title} tabIndex={-1}
|
|
106
|
+
onKeyDown={(event) => { if (event.key === "Escape") { event.stopPropagation(); state.closeQuestion(); } }}
|
|
107
|
+
className="max-h-[min(70vh,30rem)] overflow-y-auto rounded-2xl border border-border bg-card p-3 text-card-foreground shadow-card outline-none sm:p-4">
|
|
108
|
+
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
|
109
|
+
<div className="flex items-center gap-2"><CircleHelp aria-hidden="true" className="h-4 w-4" />{t("chatUserQuestionPanelHeading")}</div>
|
|
110
|
+
<button type="button" aria-label={t("chatUserQuestionClose")} onClick={state.closeQuestion} className="-mr-1 rounded-md p-1 hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><X className="h-4 w-4" /></button>
|
|
111
|
+
</div>
|
|
112
|
+
{pending.length > 1 ? <nav aria-label={t("chatUserQuestionsLabel")} className="mt-3 flex gap-1.5 overflow-x-auto pb-1">
|
|
113
|
+
{pending.map((item) => <button key={item.id} type="button" aria-current={item.id === question.id ? "true" : undefined}
|
|
114
|
+
onClick={() => state.openQuestion(item.id)}
|
|
115
|
+
className="shrink-0 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-[var(--interaction-selection)] aria-[current=true]:text-foreground">{item.title}</button>)}
|
|
116
|
+
</nav> : null}
|
|
117
|
+
<h2 className="mt-3 text-base font-semibold leading-6">{question.title}</h2>
|
|
118
|
+
<TooltipProvider delayDuration={180}>
|
|
119
|
+
<div className="mt-2 space-y-0.5">
|
|
120
|
+
{question.options?.map((option, index) => {
|
|
121
|
+
const description = question.optionDescriptions?.[option];
|
|
122
|
+
return <div key={option} className={cn("group flex min-h-10 w-full items-center rounded-xl transition-colors hover:bg-[var(--interaction-hover)]", question.recommendedOption === option && "bg-[var(--interaction-selection)]")}>
|
|
123
|
+
<button type="button" aria-pressed={selectedOption === option} disabled={state.disabled || submitting} onClick={() => void resolve("answer", option)}
|
|
124
|
+
className="flex min-h-10 min-w-0 flex-1 items-center gap-2.5 rounded-xl px-2 py-1.5 text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
|
|
125
|
+
<span aria-hidden="true" className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border bg-muted/50 text-xs text-muted-foreground">{index < 26 ? String.fromCharCode(65 + index) : index + 1}</span>
|
|
126
|
+
<span className="min-w-0 flex-1">{option}</span>
|
|
127
|
+
{question.recommendedOption === option ? <span className="shrink-0 text-xs text-muted-foreground">{t("chatUserQuestionRecommended")}</span> : null}
|
|
128
|
+
{submitting && selectedOption === option ? <Loader2 aria-label={t("chatUserQuestionSending")} className="h-4 w-4 animate-spin" /> : <ArrowRight aria-hidden="true" className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-opacity", question.recommendedOption === option ? "opacity-70" : "opacity-0 group-hover:opacity-70 group-focus-within:opacity-70")} />}
|
|
129
|
+
</button>
|
|
130
|
+
{description ? <Tooltip open={activeHelp === option} onOpenChange={(open) => setActiveHelp(open ? option : null)}>
|
|
131
|
+
<TooltipTrigger asChild>
|
|
132
|
+
<button type="button" aria-label={`${option}:${t("chatUserQuestionOptionHelp")}`} onClick={() => setActiveHelp(activeHelp === option ? null : option)}
|
|
133
|
+
className="mr-2 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
134
|
+
<CircleHelp aria-hidden="true" className="h-4 w-4" />
|
|
135
|
+
</button>
|
|
136
|
+
</TooltipTrigger>
|
|
137
|
+
<TooltipContent side="top" align="end" className="max-w-64 whitespace-normal break-words text-xs">{description}</TooltipContent>
|
|
138
|
+
</Tooltip> : null}
|
|
139
|
+
</div>;
|
|
140
|
+
})}
|
|
141
|
+
</div>
|
|
142
|
+
</TooltipProvider>
|
|
143
|
+
<div className="mt-2 flex h-10 items-center gap-2.5 px-2">
|
|
144
|
+
<span aria-hidden="true" className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border bg-muted/50 text-xs text-muted-foreground">{(question.options?.length ?? 0) < 26 ? String.fromCharCode(65 + (question.options?.length ?? 0)) : (question.options?.length ?? 0) + 1}</span>
|
|
145
|
+
<input ref={inputRef} aria-label={t("chatUserQuestionAnswerLabel")} value={answer} disabled={state.disabled || submitting} onChange={(event) => setAnswer(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void resolve("answer", answer); } }} placeholder={t("chatUserQuestionAnswerPlaceholder")}
|
|
146
|
+
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50" />
|
|
147
|
+
<button type="button" disabled={state.disabled || submitting} onClick={() => void resolve("dismiss")} className="h-8 shrink-0 rounded-full border border-border px-3 text-xs hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">{t("chatUserQuestionDismiss")}</button>
|
|
148
|
+
<button type="button" disabled={state.disabled || submitting || !answer.trim()} onClick={() => void resolve("answer", answer)} className="h-8 shrink-0 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:bg-muted disabled:text-muted-foreground">
|
|
149
|
+
{submitting && !selectedOption ? t("chatUserQuestionSending") : t("chatUserQuestionSend")}
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
{error ? <p role="alert" className="mt-2 text-xs text-destructive">{error}</p> : null}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function SessionUserQuestions() {
|
|
158
|
+
const state = useContext(context);
|
|
159
|
+
const layout = useChatMessageLayoutStore((snapshot) => snapshot.layout);
|
|
160
|
+
if (!state?.sessionId) return null;
|
|
161
|
+
const pending = state.questions.filter((question) => question.status === "pending");
|
|
162
|
+
const openQuestion = pending.find((question) => question.id === state.openId);
|
|
163
|
+
if (!pending.length && !state.error) return null;
|
|
164
|
+
const content = (
|
|
165
|
+
<div className={cn("relative mx-auto w-full", layout !== "flat" && "max-w-[min(1120px,100%)] px-3 sm:px-4")} aria-label={t("chatUserQuestionsLabel")}>
|
|
166
|
+
{openQuestion ? <UserQuestionPanel key={openQuestion.id} question={openQuestion} pending={pending} state={state} /> : null}
|
|
167
|
+
{pending.length && !openQuestion ? <button type="button" aria-expanded={false} onClick={(event) => state.openQuestion(pending[pending.length - 1]!.id, event.currentTarget)}
|
|
168
|
+
className="inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs text-muted-foreground hover:bg-[var(--interaction-hover)] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
169
|
+
<MessageCircleQuestion aria-hidden="true" className="h-3.5 w-3.5" />{t("chatUserQuestionsPendingCount").replace("{{count}}", String(pending.length))}
|
|
170
|
+
</button> : null}
|
|
171
|
+
{state.error ? <button type="button" onClick={() => void state.refresh()} className="ml-2 text-xs text-destructive underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">{t("chatUserQuestionFailed")} · {t("chatHistoryRetry")}</button> : null}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
return layout === "flat"
|
|
175
|
+
? <ChatConversationTrack width="composer">{content}</ChatConversationTrack>
|
|
176
|
+
: <div className="bg-background">{content}</div>;
|
|
177
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { eventKeys, type NcpRunHandle, type UiNcpSessionUserQuestionsView } from "@nextclaw/client-sdk";
|
|
4
|
+
import { NcpEventType } from "@nextclaw/ncp";
|
|
5
|
+
import { nextclawClient } from "@/shared/lib/api";
|
|
6
|
+
|
|
7
|
+
const QUESTION_QUERY_KEY = "session-user-questions";
|
|
8
|
+
export type Question = UiNcpSessionUserQuestionsView["questions"][number];
|
|
9
|
+
export type QuestionContext = {
|
|
10
|
+
questions: readonly Question[];
|
|
11
|
+
openId: string | null;
|
|
12
|
+
openQuestion: (id: string, trigger?: HTMLElement) => void;
|
|
13
|
+
closeQuestion: () => void;
|
|
14
|
+
sessionId: string | null;
|
|
15
|
+
disabled: boolean;
|
|
16
|
+
onResolved: (handle: NcpRunHandle) => Promise<void>;
|
|
17
|
+
refresh: () => Promise<void>;
|
|
18
|
+
error: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function readSeenIds(sessionId: string): Set<string> {
|
|
22
|
+
try {
|
|
23
|
+
return new Set(JSON.parse(sessionStorage.getItem(`nextclaw-question-seen:${sessionId}`) ?? "[]") as string[]);
|
|
24
|
+
} catch {
|
|
25
|
+
return new Set();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function markSeen(sessionId: string, ids: Set<string>) {
|
|
30
|
+
try {
|
|
31
|
+
sessionStorage.setItem(`nextclaw-question-seen:${sessionId}`, JSON.stringify([...ids]));
|
|
32
|
+
} catch { /* Storage may be disabled. The current mount still tracks seen IDs. */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useSessionUserQuestions({ sessionId, disabled, onResolved }: {
|
|
36
|
+
sessionId: string | null;
|
|
37
|
+
disabled: boolean;
|
|
38
|
+
onResolved: (handle: NcpRunHandle) => Promise<void>;
|
|
39
|
+
}): QuestionContext {
|
|
40
|
+
const queryClient = useQueryClient();
|
|
41
|
+
const query = useQuery({
|
|
42
|
+
queryKey: [QUESTION_QUERY_KEY, sessionId],
|
|
43
|
+
queryFn: () => nextclawClient.sessions.listUserQuestions(sessionId as string),
|
|
44
|
+
enabled: Boolean(sessionId),
|
|
45
|
+
retry: false,
|
|
46
|
+
staleTime: 0,
|
|
47
|
+
});
|
|
48
|
+
const [manualOpen, setManualOpen] = useState<{ sessionId: string; id: string } | null>(null);
|
|
49
|
+
const [suppressed, setSuppressed] = useState<{ sessionId: string; ids: Set<string> } | null>(null);
|
|
50
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
51
|
+
const questions = useMemo(() => query.data?.questions ?? [], [query.data]);
|
|
52
|
+
const pending = questions.filter((question) => question.status === "pending");
|
|
53
|
+
const seen = sessionId ? readSeenIds(sessionId) : new Set<string>();
|
|
54
|
+
if (suppressed?.sessionId === sessionId) for (const id of suppressed.ids) seen.add(id);
|
|
55
|
+
const autoOpenId = pending.findLast((question) => !seen.has(question.id))?.id ?? null;
|
|
56
|
+
const openId = manualOpen?.sessionId === sessionId && pending.some((question) => question.id === manualOpen.id)
|
|
57
|
+
? manualOpen.id
|
|
58
|
+
: autoOpenId;
|
|
59
|
+
const refresh = async () => {
|
|
60
|
+
if (sessionId) await queryClient.invalidateQueries({ queryKey: [QUESTION_QUERY_KEY, sessionId], exact: true });
|
|
61
|
+
};
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!sessionId) return undefined;
|
|
64
|
+
const invalidate = () => void queryClient.invalidateQueries({ queryKey: [QUESTION_QUERY_KEY, sessionId], exact: true });
|
|
65
|
+
const unsubscribeEvent = nextclawClient.eventBus.on(eventKeys.ncpEvent, (event) => {
|
|
66
|
+
if (event.type !== NcpEventType.MessageSent || event.payload.sessionId !== sessionId) return;
|
|
67
|
+
const { message } = event.payload;
|
|
68
|
+
if (typeof message.metadata?.nextclaw_user_question_id === "string" ||
|
|
69
|
+
message.parts.some((part) => part.type === "extension" && part.extensionType === "nextclaw.user-question")) invalidate();
|
|
70
|
+
});
|
|
71
|
+
const unsubscribeQueue = nextclawClient.eventBus.on(eventKeys.sessionRunQueueUpdated, (event) => {
|
|
72
|
+
if (event.sessionKey === sessionId) invalidate();
|
|
73
|
+
});
|
|
74
|
+
return () => { unsubscribeEvent(); unsubscribeQueue(); };
|
|
75
|
+
}, [queryClient, sessionId]);
|
|
76
|
+
return {
|
|
77
|
+
questions,
|
|
78
|
+
openId,
|
|
79
|
+
openQuestion: (id, trigger) => {
|
|
80
|
+
if (!sessionId) return;
|
|
81
|
+
triggerRef.current = trigger ?? null;
|
|
82
|
+
setManualOpen({ sessionId, id });
|
|
83
|
+
},
|
|
84
|
+
closeQuestion: () => {
|
|
85
|
+
if (sessionId) {
|
|
86
|
+
const ids = new Set([...readSeenIds(sessionId), ...pending.map((question) => question.id)]);
|
|
87
|
+
markSeen(sessionId, ids);
|
|
88
|
+
setSuppressed({ sessionId, ids });
|
|
89
|
+
}
|
|
90
|
+
setManualOpen(null);
|
|
91
|
+
triggerRef.current?.focus();
|
|
92
|
+
triggerRef.current = null;
|
|
93
|
+
},
|
|
94
|
+
sessionId,
|
|
95
|
+
disabled,
|
|
96
|
+
onResolved,
|
|
97
|
+
refresh,
|
|
98
|
+
error: Boolean(query.error),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { usePresenter } from "@/features/chat/components/providers/chat-presenter.provider";
|
|
17
17
|
import {
|
|
18
18
|
adaptChatMessage,
|
|
19
|
+
readChatUserQuestionReply,
|
|
19
20
|
type ChatMessageSource,
|
|
20
21
|
} from "@/features/chat/features/message/utils/chat-message.utils";
|
|
21
22
|
import { buildChatMessageProcessSummary } from "@/features/chat/features/message/utils/chat-message-process-summary.utils";
|
|
@@ -70,6 +71,7 @@ import { formatDateTime, t, type I18nLanguage } from "@/shared/lib/i18n";
|
|
|
70
71
|
import { cn } from "@/shared/lib/utils";
|
|
71
72
|
import type { SessionMessageToolPayloadState } from "@/features/chat/features/ncp/hooks/use-ncp-session-message-history";
|
|
72
73
|
import { ChatMessageObservationEvent } from "@/features/chat/features/message/components/chat-message-observation-event";
|
|
74
|
+
import { UserQuestionInlineQuestions, UserQuestionReplyReference } from "@/features/chat/features/conversation/components/session-user-questions";
|
|
73
75
|
import {
|
|
74
76
|
isObservationEventPartExtensionType,
|
|
75
77
|
readObservationEventPartData,
|
|
@@ -166,6 +168,7 @@ class ChatMessageViewModelAdapter {
|
|
|
166
168
|
processSummary,
|
|
167
169
|
executionSummaryLabel: executionPresentation?.summaryLabel,
|
|
168
170
|
moreActions,
|
|
171
|
+
userQuestionReply: readChatUserQuestionReply(message) ?? undefined,
|
|
169
172
|
},
|
|
170
173
|
parts: adaptNcpMessagePartsForChat(message.parts) as ChatMessageSource["parts"],
|
|
171
174
|
};
|
|
@@ -407,6 +410,10 @@ export function ChatMessageListContainer({
|
|
|
407
410
|
}, []);
|
|
408
411
|
const renderCustomPart = useCallback(
|
|
409
412
|
(part: Extract<ChatMessageViewModel["parts"][number], { type: "custom" }>) => {
|
|
413
|
+
if (part.customType === "nextclaw.user-question") {
|
|
414
|
+
return <UserQuestionInlineQuestions data={part.data} />;
|
|
415
|
+
}
|
|
416
|
+
if (part.customType === "nextclaw.user-question-reply") return <UserQuestionReplyReference data={part.data} />;
|
|
410
417
|
if (isObservationEventPartExtensionType(part.customType)) {
|
|
411
418
|
const event = readObservationEventPartData(part.data);
|
|
412
419
|
return event ? <ChatMessageObservationEvent event={event} /> : undefined;
|
package/src/features/chat/features/message/utils/__tests__/chat-message-timeline.utils.test.ts
CHANGED
|
@@ -20,6 +20,22 @@ const visibleMessage = {
|
|
|
20
20
|
} satisfies NcpMessage;
|
|
21
21
|
|
|
22
22
|
describe("chat message timeline visibility", () => {
|
|
23
|
+
it("places an async question at its tool call between assistant text parts", () => {
|
|
24
|
+
const source = { ...visibleMessage, parts: [
|
|
25
|
+
{ type: "text" as const, text: "先说明。" },
|
|
26
|
+
{ type: "tool-invocation" as const, toolCallId: "call-1", toolName: "request_user_input_async", state: "result" as const,
|
|
27
|
+
args: "{}", result: { accepted: true, questionIds: ["q-1"] } },
|
|
28
|
+
{ type: "text" as const, text: "稍后继续。" },
|
|
29
|
+
] } satisfies NcpMessage;
|
|
30
|
+
const question = { ...visibleMessage, id: "assistant-question-1", parts: [
|
|
31
|
+
{ type: "text" as const, text: "城市? (北京 / 上海)" },
|
|
32
|
+
{ type: "extension" as const, extensionType: "nextclaw.user-question", data: { questions: [{ id: "q-1", title: "城市?", options: ["北京", "上海"] }] } },
|
|
33
|
+
] } satisfies NcpMessage;
|
|
34
|
+
const projected = projectVisibleChatMessages([source, question]);
|
|
35
|
+
expect(projected).toHaveLength(1);
|
|
36
|
+
expect(projected[0]?.parts.map((part) => part.type)).toEqual(["text", "extension", "text"]);
|
|
37
|
+
expect(projected[0]?.parts[1]).toMatchObject({ data: { questions: [{ id: "q-1" }] } });
|
|
38
|
+
});
|
|
23
39
|
it("places context compaction between surrounding visible messages", () => {
|
|
24
40
|
const afterMessage = {
|
|
25
41
|
...visibleMessage,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { ToolInvocationStatus, type UiMessage } from "@nextclaw/agent-chat";
|
|
2
|
+
import type { NcpMessage } from "@nextclaw/ncp";
|
|
3
|
+
import { readChatUserQuestionReply } from "@/features/chat/features/message/utils/chat-message.utils";
|
|
2
4
|
import { adapt, toSource, type ChatMessageSource } from "./chat-message-test.utils";
|
|
3
5
|
|
|
4
6
|
it("maps markdown, reasoning, and tool parts into UI view models", () => {
|
|
@@ -116,6 +118,66 @@ it("maps context compaction extensions into stable process parts", () => {
|
|
|
116
118
|
}]);
|
|
117
119
|
});
|
|
118
120
|
|
|
121
|
+
it("keeps assistant prose on both sides of an unanchored question fallback", () => {
|
|
122
|
+
const data = { questions: [{ id: "question-1", title: "Which format?", options: ["PDF", "DOCX"] }] };
|
|
123
|
+
const adapted = adapt([{
|
|
124
|
+
id: "assistant-question-older",
|
|
125
|
+
role: "assistant",
|
|
126
|
+
parts: [
|
|
127
|
+
{ type: "text", text: "Before." },
|
|
128
|
+
{ type: "text", text: "Which format? (PDF / DOCX)" },
|
|
129
|
+
{ type: "extension", extensionType: "nextclaw.user-question", data },
|
|
130
|
+
{ type: "text", text: "After." },
|
|
131
|
+
],
|
|
132
|
+
}]);
|
|
133
|
+
expect(adapted[0]?.parts.map((part) => part.type)).toEqual(["markdown", "custom", "markdown"]);
|
|
134
|
+
expect(adapted[0]?.parts[0]).toMatchObject({ text: "Before." });
|
|
135
|
+
expect(adapted[0]?.parts[2]).toMatchObject({ text: "After." });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("renders a structured user question at its message position without repeating fallback text", () => {
|
|
139
|
+
const data = { questions: [{ id: "question-1", title: "Which format?", options: ["PDF", "DOCX"] }] };
|
|
140
|
+
const adapted = adapt([{
|
|
141
|
+
id: "assistant-question",
|
|
142
|
+
role: "assistant",
|
|
143
|
+
parts: [
|
|
144
|
+
{ type: "text", text: "Which format? (PDF / DOCX)" },
|
|
145
|
+
{ type: "extension", extensionType: "nextclaw.user-question", data },
|
|
146
|
+
],
|
|
147
|
+
}]);
|
|
148
|
+
expect(adapted[0]?.parts).toEqual([{
|
|
149
|
+
type: "custom",
|
|
150
|
+
id: "question-1",
|
|
151
|
+
customType: "nextclaw.user-question",
|
|
152
|
+
data,
|
|
153
|
+
}]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("projects a delayed answer as a quoted question plus the actual answer", () => {
|
|
157
|
+
const message: NcpMessage = {
|
|
158
|
+
id: "answer-1",
|
|
159
|
+
sessionId: "session-1",
|
|
160
|
+
role: "user",
|
|
161
|
+
status: "final",
|
|
162
|
+
timestamp: "2026-09-28T00:00:00.000Z",
|
|
163
|
+
parts: [{ type: "text", text: "[Reply to an earlier question]\nQuestion: Which format?\nAnswer: PDF" }],
|
|
164
|
+
metadata: {
|
|
165
|
+
nextclaw_user_question_id: "q-1",
|
|
166
|
+
nextclaw_user_question_action: "answered",
|
|
167
|
+
nextclaw_user_question_title: "Which format?",
|
|
168
|
+
nextclaw_user_question_message_id: "asked-1",
|
|
169
|
+
nextclaw_user_question_answer: "PDF",
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
const reply = readChatUserQuestionReply(message);
|
|
173
|
+
expect(reply).toMatchObject({ questionId: "q-1", questionMessageId: "asked-1", title: "Which format?", answer: "PDF" });
|
|
174
|
+
const adapted = adapt([{ id: message.id, role: "user", meta: { userQuestionReply: reply! }, parts: [{ type: "text", text: message.parts[0]!.type === "text" ? message.parts[0]!.text : "" }] }]);
|
|
175
|
+
expect(adapted[0]?.parts).toEqual([
|
|
176
|
+
{ type: "custom", id: "q-1", customType: "nextclaw.user-question-reply", data: reply },
|
|
177
|
+
{ type: "markdown", text: "PDF" },
|
|
178
|
+
]);
|
|
179
|
+
});
|
|
180
|
+
|
|
119
181
|
it("maps observation event extensions into visible custom parts", () => {
|
|
120
182
|
const adapted = adapt([{
|
|
121
183
|
id: "observation-event-message-1",
|
|
@@ -250,6 +250,18 @@ function isToolInvocationPart(
|
|
|
250
250
|
function buildExtensionPart(
|
|
251
251
|
part: Extract<ChatMessagePartSource, { type: "extension" }>,
|
|
252
252
|
): Extract<ChatMessagePartViewModel, { type: "custom" }> | null {
|
|
253
|
+
if (part.extensionType === "nextclaw.user-question") {
|
|
254
|
+
const questions = typeof part.data === "object" && part.data !== null
|
|
255
|
+
? (part.data as { questions?: unknown }).questions
|
|
256
|
+
: null;
|
|
257
|
+
if (!Array.isArray(questions) || !questions.length || typeof questions[0]?.id !== "string") return null;
|
|
258
|
+
return {
|
|
259
|
+
type: "custom",
|
|
260
|
+
id: questions[0].id,
|
|
261
|
+
customType: part.extensionType,
|
|
262
|
+
data: part.data,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
253
265
|
if (isObservationEventPartExtensionType(part.extensionType)) {
|
|
254
266
|
const data = readObservationEventPartData(part.data);
|
|
255
267
|
if (!data) return null;
|
|
@@ -13,10 +13,10 @@ import {
|
|
|
13
13
|
readObservationEventPartData,
|
|
14
14
|
type ObservationEventPartData,
|
|
15
15
|
} from "@/features/chat/features/message/utils/chat-message-observation-event.utils";
|
|
16
|
+
import { placeUserQuestionsInAssistantBody } from "@/features/chat/features/message/utils/chat-user-question-timeline.utils";
|
|
16
17
|
|
|
17
18
|
const INHERITED_FROM_SESSION_METADATA_KEY = "inherited_from_session_id";
|
|
18
|
-
export const CONTEXT_COMPACTION_PART_EXTENSION_TYPE =
|
|
19
|
-
"nextclaw.context-compaction";
|
|
19
|
+
export const CONTEXT_COMPACTION_PART_EXTENSION_TYPE = "nextclaw.context-compaction";
|
|
20
20
|
|
|
21
21
|
export type ContextCompactionPartData = {
|
|
22
22
|
id: string;
|
|
@@ -280,7 +280,7 @@ function projectVisibleChatMessageState(
|
|
|
280
280
|
status: "pending",
|
|
281
281
|
};
|
|
282
282
|
}
|
|
283
|
-
return { messages,
|
|
283
|
+
return { ...placeUserQuestionsInAssistantBody(messages, observationEvents), inlineCompactionMessageIds };
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
export function projectVisibleChatMessages(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { adaptChatMessagePart } from "./chat-message-part.utils";
|
|
2
|
+
import type { NcpMessage } from "@nextclaw/ncp";
|
|
2
3
|
import type { ChatInlineTokenSource } from "@/features/chat/features/input/utils/chat-inline-token.utils";
|
|
3
4
|
import type {
|
|
4
5
|
ChatMessageMoreActionsViewModel,
|
|
@@ -26,10 +27,46 @@ export type ChatMessageSource = {
|
|
|
26
27
|
processSummary?: ChatMessageProcessSummarySource;
|
|
27
28
|
executionSummaryLabel?: string;
|
|
28
29
|
moreActions?: ChatMessageMoreActionsViewModel;
|
|
30
|
+
userQuestionReply?: ChatUserQuestionReply;
|
|
29
31
|
};
|
|
30
32
|
parts: ChatMessagePartSource[];
|
|
31
33
|
};
|
|
32
34
|
|
|
35
|
+
export type ChatUserQuestionReply = {
|
|
36
|
+
questionId: string;
|
|
37
|
+
questionMessageId: string | null;
|
|
38
|
+
title: string;
|
|
39
|
+
action: "answered" | "dismissed";
|
|
40
|
+
answer: string | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function readChatUserQuestionReply(message: NcpMessage): ChatUserQuestionReply | null {
|
|
44
|
+
if (message.role !== "user") return null;
|
|
45
|
+
const { metadata } = message;
|
|
46
|
+
const questionId = metadata?.nextclaw_user_question_id;
|
|
47
|
+
const action = metadata?.nextclaw_user_question_action;
|
|
48
|
+
if (typeof questionId !== "string" || (action !== "answered" && action !== "dismissed")) return null;
|
|
49
|
+
const text = message.parts.find((part) => part.type === "text");
|
|
50
|
+
const legacy = text?.type === "text" ? /^↳ ([^\n]+)\n([\s\S]*)$/.exec(text.text) : null;
|
|
51
|
+
const title = typeof metadata?.nextclaw_user_question_title === "string"
|
|
52
|
+
? metadata.nextclaw_user_question_title
|
|
53
|
+
: legacy?.[1];
|
|
54
|
+
if (!title?.trim()) return null;
|
|
55
|
+
const answer = typeof metadata?.nextclaw_user_question_answer === "string"
|
|
56
|
+
? metadata.nextclaw_user_question_answer
|
|
57
|
+
: legacy?.[2] ?? null;
|
|
58
|
+
if (action === "answered" && !answer?.trim()) return null;
|
|
59
|
+
return {
|
|
60
|
+
questionId,
|
|
61
|
+
questionMessageId: typeof metadata?.nextclaw_user_question_message_id === "string"
|
|
62
|
+
? metadata.nextclaw_user_question_message_id
|
|
63
|
+
: null,
|
|
64
|
+
title,
|
|
65
|
+
action,
|
|
66
|
+
answer: action === "answered" ? answer : null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
33
70
|
function resolveMessageTimestamp(message: ChatMessageSource): string {
|
|
34
71
|
const candidate = message.meta?.timestamp;
|
|
35
72
|
if (candidate && Number.isFinite(Date.parse(candidate))) {
|
|
@@ -78,6 +115,30 @@ export function adaptChatMessage(
|
|
|
78
115
|
message: ChatMessageSource,
|
|
79
116
|
params: ChatMessageAdapterParams,
|
|
80
117
|
): ChatMessageViewModel {
|
|
118
|
+
const parts = message.parts
|
|
119
|
+
.map((part) => adaptChatMessagePart({
|
|
120
|
+
part,
|
|
121
|
+
inlineTokens: message.meta?.inlineTokens ?? [],
|
|
122
|
+
texts: params.texts,
|
|
123
|
+
}))
|
|
124
|
+
.filter((part) => part !== null);
|
|
125
|
+
const reply = message.meta?.userQuestionReply;
|
|
126
|
+
const displayParts: ChatMessageViewModel["parts"] = reply
|
|
127
|
+
? [
|
|
128
|
+
{ type: "custom", id: reply.questionId, customType: "nextclaw.user-question-reply", data: reply },
|
|
129
|
+
...(reply.answer ? [{ type: "markdown" as const, text: reply.answer }] : []),
|
|
130
|
+
]
|
|
131
|
+
: parts.filter((part, index) => {
|
|
132
|
+
const next = parts[index + 1];
|
|
133
|
+
if (part.type !== "markdown" || next?.type !== "custom" || next.customType !== "nextclaw.user-question") return true;
|
|
134
|
+
const entries = next.data && typeof next.data === "object" && "questions" in next.data ? next.data.questions : null;
|
|
135
|
+
if (!Array.isArray(entries)) return true;
|
|
136
|
+
const fallback = entries.map((entry) => {
|
|
137
|
+
if (!entry || typeof entry.title !== "string") return "";
|
|
138
|
+
return `${entry.title}${Array.isArray(entry.options) && entry.options.length ? ` (${entry.options.join(" / ")})` : ""}`;
|
|
139
|
+
}).join("\n");
|
|
140
|
+
return part.text !== fallback;
|
|
141
|
+
});
|
|
81
142
|
return {
|
|
82
143
|
id: message.id,
|
|
83
144
|
role: resolveUiRole(message.role),
|
|
@@ -87,15 +148,7 @@ export function adaptChatMessage(
|
|
|
87
148
|
processSummary: message.meta?.processSummary,
|
|
88
149
|
executionSummaryLabel: message.meta?.executionSummaryLabel,
|
|
89
150
|
moreActions: message.meta?.moreActions,
|
|
90
|
-
parts:
|
|
91
|
-
.map((part) =>
|
|
92
|
-
adaptChatMessagePart({
|
|
93
|
-
part,
|
|
94
|
-
inlineTokens: message.meta?.inlineTokens ?? [],
|
|
95
|
-
texts: params.texts,
|
|
96
|
-
}),
|
|
97
|
-
)
|
|
98
|
-
.filter((part) => part !== null),
|
|
151
|
+
parts: displayParts,
|
|
99
152
|
};
|
|
100
153
|
}
|
|
101
154
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { NcpMessage } from "@nextclaw/ncp";
|
|
2
|
+
|
|
3
|
+
function questionIdsFromToolPart(part: NcpMessage["parts"][number]): string[] {
|
|
4
|
+
if (part.type !== "tool-invocation" || part.toolName !== "request_user_input_async" ||
|
|
5
|
+
!part.result || typeof part.result !== "object" || Array.isArray(part.result)) return [];
|
|
6
|
+
const { accepted, questionIds } = part.result as { accepted?: unknown; questionIds?: unknown };
|
|
7
|
+
return accepted === true && Array.isArray(questionIds) && questionIds.every((id) => typeof id === "string")
|
|
8
|
+
? questionIds : [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function questionEntries(part: NcpMessage["parts"][number]): Array<{ id: string }> {
|
|
12
|
+
if (part.type !== "extension" || part.extensionType !== "nextclaw.user-question") return [];
|
|
13
|
+
const entries = part.data && typeof part.data === "object" && "questions" in part.data ? part.data.questions : null;
|
|
14
|
+
return Array.isArray(entries) ? entries.filter((entry): entry is { id: string } => Boolean(entry && typeof entry.id === "string")) : [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function placeUserQuestionsInAssistantBody<T extends { boundaryIndex: number }>(
|
|
18
|
+
messages: NcpMessage[], observationEvents: T[],
|
|
19
|
+
): { messages: NcpMessage[]; observationEvents: T[] } {
|
|
20
|
+
const entriesById = new Map(messages.flatMap((message) => message.parts.flatMap((part) => questionEntries(part).map((entry) => [entry.id, entry] as const))));
|
|
21
|
+
const anchoredIds = new Set(messages.flatMap((message) => message.parts.flatMap((part) =>
|
|
22
|
+
questionIdsFromToolPart(part).filter((id) => entriesById.has(id)))));
|
|
23
|
+
if (!anchoredIds.size) return { messages, observationEvents };
|
|
24
|
+
const removedIndices: number[] = [];
|
|
25
|
+
const projected = messages.flatMap((message, index) => {
|
|
26
|
+
const questionPart = message.parts.find((part) => questionEntries(part).length);
|
|
27
|
+
if (questionPart && message.id.startsWith("assistant-question-")) {
|
|
28
|
+
const remaining = questionEntries(questionPart).filter((entry) => !anchoredIds.has(entry.id));
|
|
29
|
+
if (!remaining.length) { removedIndices.push(index); return []; }
|
|
30
|
+
return [{ ...message, parts: [{ type: "extension" as const, extensionType: "nextclaw.user-question", data: { questions: remaining } }] }];
|
|
31
|
+
}
|
|
32
|
+
const parts = message.parts.flatMap((part): NcpMessage["parts"] => {
|
|
33
|
+
const questions = questionIdsFromToolPart(part).flatMap((id) => entriesById.get(id) ?? []);
|
|
34
|
+
return questions.length
|
|
35
|
+
? [{ type: "extension", extensionType: "nextclaw.user-question", data: { questions } }]
|
|
36
|
+
: [part];
|
|
37
|
+
});
|
|
38
|
+
return [{ ...message, parts }];
|
|
39
|
+
});
|
|
40
|
+
return { messages: projected, observationEvents: observationEvents.map((entry) => ({ ...entry,
|
|
41
|
+
boundaryIndex: entry.boundaryIndex - removedIndices.filter((index) => index < entry.boundaryIndex).length })) };
|
|
42
|
+
}
|
|
@@ -500,6 +500,22 @@
|
|
|
500
500
|
"chatContextInheritanceMessages": "Inherited messages",
|
|
501
501
|
"chatSending": "Sending...",
|
|
502
502
|
"chatQueueSend": "Queue",
|
|
503
|
+
"chatUserQuestionsLabel": "Questions from your agent",
|
|
504
|
+
"chatUserQuestionPanelHeading": "Question",
|
|
505
|
+
"chatUserQuestionsPendingCount": "{{count}} to answer",
|
|
506
|
+
"chatUserQuestionClose": "Close question panel",
|
|
507
|
+
"chatUserQuestionPending": "Waiting for you",
|
|
508
|
+
"chatUserQuestionAnswered": "Answered",
|
|
509
|
+
"chatUserQuestionDismissed": "Skipped",
|
|
510
|
+
"chatUserQuestionAnswerLabel": "Your answer",
|
|
511
|
+
"chatUserQuestionAnswerPlaceholder": "Or write your own reply",
|
|
512
|
+
"chatUserQuestionRecommended": "Recommended",
|
|
513
|
+
"chatUserQuestionOptionHelp": "Option details",
|
|
514
|
+
"chatUserQuestionReplyReference": "Referenced question",
|
|
515
|
+
"chatUserQuestionDismiss": "Skip",
|
|
516
|
+
"chatUserQuestionSend": "Send",
|
|
517
|
+
"chatUserQuestionSending": "Sending…",
|
|
518
|
+
"chatUserQuestionFailed": "Could not update this question",
|
|
503
519
|
"chatQueuedBannerAttachmentFallback": "Attachment message",
|
|
504
520
|
"chatQueuedSubmitting": "Adding to queue…",
|
|
505
521
|
"chatQueuedEdit": "Edit queued input",
|
|
@@ -500,6 +500,22 @@
|
|
|
500
500
|
"chatContextInheritanceMessages": "继承消息数",
|
|
501
501
|
"chatSending": "发送中...",
|
|
502
502
|
"chatQueueSend": "排队发送",
|
|
503
|
+
"chatUserQuestionsLabel": "智能体提出的问题",
|
|
504
|
+
"chatUserQuestionPanelHeading": "问题",
|
|
505
|
+
"chatUserQuestionsPendingCount": "待回答 {{count}}",
|
|
506
|
+
"chatUserQuestionClose": "关闭问题面板",
|
|
507
|
+
"chatUserQuestionPending": "待回答",
|
|
508
|
+
"chatUserQuestionAnswered": "已回答",
|
|
509
|
+
"chatUserQuestionDismissed": "已跳过",
|
|
510
|
+
"chatUserQuestionAnswerLabel": "你的回答",
|
|
511
|
+
"chatUserQuestionAnswerPlaceholder": "或自行填写回复",
|
|
512
|
+
"chatUserQuestionRecommended": "推荐",
|
|
513
|
+
"chatUserQuestionOptionHelp": "选项说明",
|
|
514
|
+
"chatUserQuestionReplyReference": "引用问题",
|
|
515
|
+
"chatUserQuestionDismiss": "跳过",
|
|
516
|
+
"chatUserQuestionSend": "发送",
|
|
517
|
+
"chatUserQuestionSending": "发送中…",
|
|
518
|
+
"chatUserQuestionFailed": "问题更新失败",
|
|
503
519
|
"chatQueuedBannerAttachmentFallback": "附件消息",
|
|
504
520
|
"chatQueuedSubmitting": "正在加入队列…",
|
|
505
521
|
"chatQueuedEdit": "编辑排队输入",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{t as e}from"./agents-lT-bDFex.js";export{e as AgentsPage};
|