feat: concurrent input dialog, unlock textarea during loading, fix TS errors
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 12s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 45s

- Add showConcurrentDialog modal when submitting while AI is loading
- Offer interrupt (stop + submit) or queue (poll until done) options
- Remove disabled={thread.isLoading} from textarea and FileUploadButton
- Fix TS2352: cast BaseMessage to unknown first before Record
- Fix TS6133: rename unused toolName param to _toolName
- Fix TS2769: chart_type typed as union literal, not string
- Fix TS2322: separate cmdMap lookup from const declaration in soc-client

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-12 14:31:29 +08:00
co-authored by Claude Sonnet 4.6
parent e8a29e1f89
commit f375220eb6
3 changed files with 89 additions and 9 deletions
@@ -65,7 +65,7 @@ const ANALYSIS_KEYWORDS = /趋势|统计|分析|对比|汇总|多少|走势|变
function hasAnalysisIntent(messages: EnterpriseState["messages"]): boolean { function hasAnalysisIntent(messages: EnterpriseState["messages"]): boolean {
// Walk backwards to find the last human message // Walk backwards to find the last human message
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i] as Record<string, unknown>; const m = messages[i] as unknown as Record<string, unknown>;
const isHuman = const isHuman =
m.role === "user" || m.role === "user" ||
(typeof m._getType === "function" && (m._getType as () => string)() === "human") || (typeof m._getType === "function" && (m._getType as () => string)() === "human") ||
@@ -80,7 +80,7 @@ function hasAnalysisIntent(messages: EnterpriseState["messages"]): boolean {
/** Map error to a suggestion type for the error-result card */ /** Map error to a suggestion type for the error-result card */
function errorSuggestion( function errorSuggestion(
toolName: string, _toolName: string,
error: unknown, error: unknown,
): "retry" | "contact_admin" | "check_input" { ): "retry" | "contact_admin" | "check_input" {
const msg = error instanceof Error ? error.message : String(error); const msg = error instanceof Error ? error.message : String(error);
@@ -597,7 +597,7 @@ export async function toolExecutorNode(
const priorityChart = Object.entries(priorityStats).map( const priorityChart = Object.entries(priorityStats).map(
([cname, value]) => ({ name: cname, value }), ([cname, value]) => ({ name: cname, value }),
); );
const charts: Array<{ chart_type: string; title: string; data: Array<{ name: string; value: number }> }> = [ const charts: Array<{ chart_type: "bar" | "line" | "pie" | "area"; title: string; data: Array<{ name: string; value: number }> }> = [
{ chart_type: "pie", title: "状态分布", data: statusChart }, { chart_type: "pie", title: "状态分布", data: statusChart },
{ chart_type: "bar", title: "优先级分布", data: priorityChart }, { chart_type: "bar", title: "优先级分布", data: priorityChart },
]; ];
@@ -614,7 +614,7 @@ export async function toolExecutorNode(
.map(([cname, value]) => ({ name: cname, value })); .map(([cname, value]) => ({ name: cname, value }));
if (trendData.length >= 1) { if (trendData.length >= 1) {
charts.push({ charts.push({
chart_type: "line", chart_type: "line" as const,
title: "工单创建时间趋势", title: "工单创建时间趋势",
data: trendData, data: trendData,
}); });
@@ -324,11 +324,12 @@ export async function sandboxRun(
throw new Error(`Unsupported language: ${language}. Supported: ${SUPPORTED_LANGUAGES.join(", ")}`); throw new Error(`Unsupported language: ${language}. Supported: ${SUPPORTED_LANGUAGES.join(", ")}`);
} }
const escaped = code.replace(/'/g, "'\\''"); const escaped = code.replace(/'/g, "'\\''");
const cmd: Record<SupportedLang, string> = { const cmdMap: Record<SupportedLang, string> = {
python: `python3 -c '${escaped}'`, python: `python3 -c '${escaped}'`,
javascript: `node -e '${escaped}'`, javascript: `node -e '${escaped}'`,
bash: `bash -c '${escaped}'`, bash: `bash -c '${escaped}'`,
}[language as SupportedLang]; };
const cmd = cmdMap[language as SupportedLang];
let execResp: Response; let execResp: Response;
try { try {
+82 -3
View File
@@ -145,6 +145,10 @@ function App() {
// Pending retry tool name — set by soc:retry-tool, consumed on next submit // Pending retry tool name — set by soc:retry-tool, consumed on next submit
const pendingRetryToolRef = useRef<string | null>(null); const pendingRetryToolRef = useRef<string | null>(null);
// ── Concurrent submit state ───────────────────────────────────────────────
const [showConcurrentDialog, setShowConcurrentDialog] = useState(false);
const pendingSubmitRef = useRef<(() => void) | null>(null);
const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({ const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({
apiUrl: LANGGRAPH_URL, apiUrl: LANGGRAPH_URL,
assistantId: "agent", assistantId: "agent",
@@ -283,7 +287,19 @@ function App() {
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const text = input.trim(); const text = input.trim();
if ((!text && !attachedFile) || thread.isLoading) return; if (!text && !attachedFile) return;
// If AI is still generating, show concurrent choice dialog
if (thread.isLoading) {
pendingSubmitRef.current = () => doSubmit(text);
setShowConcurrentDialog(true);
return;
}
doSubmit(text);
}
async function doSubmit(text: string) {
setInput(""); setInput("");
setSourceLabel(null); setSourceLabel(null);
// Clear draft and update lastActive for this thread // Clear draft and update lastActive for this thread
@@ -354,6 +370,34 @@ function App() {
} }
} }
// ── Concurrent dialog handlers ────────────────────────────────────────────
function handleConcurrentInterrupt() {
setShowConcurrentDialog(false);
thread.stop();
const submit = pendingSubmitRef.current;
pendingSubmitRef.current = null;
setTimeout(() => submit?.(), 300);
}
function handleConcurrentQueue() {
setShowConcurrentDialog(false);
const submit = pendingSubmitRef.current;
pendingSubmitRef.current = null;
const poll = () => {
if (!thread.isLoading) {
submit?.();
} else {
setTimeout(poll, 500);
}
};
setTimeout(poll, 500);
}
function handleConcurrentCancel() {
setShowConcurrentDialog(false);
pendingSubmitRef.current = null;
}
// ── Regenerate last AI message ─────────────────────────────────────────── // ── Regenerate last AI message ───────────────────────────────────────────
function handleRegenerate() { function handleRegenerate() {
// Find last human message // Find last human message
@@ -842,7 +886,7 @@ function App() {
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="flex gap-2"> <form onSubmit={handleSubmit} className="flex gap-2">
<FileUploadButton onFileSelect={setAttachedFile} disabled={thread.isLoading} /> <FileUploadButton onFileSelect={setAttachedFile} />
<textarea <textarea
ref={textareaRef} ref={textareaRef}
className="flex-1 rounded-xl border border-input bg-background px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring placeholder:text-muted-foreground disabled:opacity-50 resize-none overflow-y-auto" className="flex-1 rounded-xl border border-input bg-background px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring placeholder:text-muted-foreground disabled:opacity-50 resize-none overflow-y-auto"
@@ -860,7 +904,6 @@ function App() {
} }
}} }}
onPaste={handlePaste} onPaste={handlePaste}
disabled={thread.isLoading}
autoFocus autoFocus
/> />
{thread.isLoading ? ( {thread.isLoading ? (
@@ -901,6 +944,42 @@ function App() {
</div> </div>
</div> </div>
{/* ── Concurrent submit dialog ── */}
{showConcurrentDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-background border border-border rounded-2xl shadow-xl p-6 w-80 flex flex-col gap-4">
<div className="flex flex-col gap-1">
<p className="font-semibold text-sm">AI 正在处理上一条消息</p>
<p className="text-xs text-muted-foreground">请选择如何处理新消息:</p>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={handleConcurrentInterrupt}
className="w-full rounded-xl bg-destructive text-destructive-foreground px-4 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity flex items-center gap-2"
>
<Square className="size-4 fill-current" />
中断上一条,处理这条
</button>
<button
type="button"
onClick={handleConcurrentQueue}
className="w-full rounded-xl bg-primary text-primary-foreground px-4 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity"
>
加入队列,等上一条完成
</button>
<button
type="button"
onClick={handleConcurrentCancel}
className="w-full rounded-xl border border-border px-4 py-2.5 text-sm font-medium hover:bg-accent transition-colors"
>
取消
</button>
</div>
</div>
</div>
)}
{/* ── Canvas panel (right drawer) ── */} {/* ── Canvas panel (right drawer) ── */}
<CanvasPanel doc={canvasDoc} onClose={() => setCanvasDoc(null)} /> <CanvasPanel doc={canvasDoc} onClose={() => setCanvasDoc(null)} />
</div> </div>