feat: AIOps智能助手支持停止生成(AbortController中止流式,保留部分内容+已停止提示)

This commit is contained in:
attacker committed 2026-06-18 07:07:01 +08:00
1 parent 5d19878b28
commit ca8ebb1634
1 file changed
+53 -14
@@ -40,6 +40,7 @@ import {
Loader2, Loader2,
CheckCircle2, CheckCircle2,
Wifi, Wifi,
Square,
} from 'lucide-react'; } from 'lucide-react';
// LLM 提供商列表(OpenAI 兼容接口) // Logan.Li // LLM 提供商列表(OpenAI 兼容接口) // Logan.Li
@@ -133,6 +134,7 @@ interface Message {
content: string; content: string;
timestamp: string; timestamp: string;
isError?: boolean; isError?: boolean;
isStopped?: boolean;
} }
// 格式化时间为 HH:MM // Logan.Li // 格式化时间为 HH:MM // Logan.Li
@@ -145,6 +147,10 @@ export function AIOpsAssistant() {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// 用于中止流式生成(点击"停止"按钮时 abort) // Logan.Li
const abortRef = useRef<AbortController | null>(null);
// 组件卸载时中止进行中的流式请求,避免 setState on unmounted // Logan.Li
useEffect(() => () => abortRef.current?.abort(), []);
const [isConfigOpen, setIsConfigOpen] = useState(false); const [isConfigOpen, setIsConfigOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
@@ -251,6 +257,9 @@ export function AIOpsAssistant() {
]; ];
setMessages(newMessages); setMessages(newMessages);
setIsLoading(true); setIsLoading(true);
// 创建 AbortController 以支持\"停止生成\" // Logan.Li
const controller = new AbortController();
abortRef.current = controller;
// 用于在收到第一个 chunk 前显示"思考中"占位 // 用于在收到第一个 chunk 前显示"思考中"占位
let firstChunkArrived = false; let firstChunkArrived = false;
try { try {
@@ -266,22 +275,38 @@ export function AIOpsAssistant() {
m.id === assistantId ? { ...m, content: m.content + delta } : m, m.id === assistantId ? { ...m, content: m.content + delta } : m,
), ),
); );
}); }, controller.signal);
} catch (e) { } catch (e) {
const errMsg = e instanceof Error ? e.message : '对话失败'; // 用户主动中止:保留已生成的部分内容,不当作错误 // Logan.Li
setMessages((prev) => if (controller.signal.aborted) {
prev.map((m) => setMessages((prev) =>
m.id === assistantId prev.map((m) =>
? { m.id === assistantId
...m, ? {
content: firstChunkArrived ? m.content + `\n\n⚠️ ${errMsg}` : `⚠️ ${errMsg}`, ...m,
isError: !firstChunkArrived, content: m.content || '(已停止生成)',
} isStopped: true,
: m, }
), : m,
); ),
);
} else {
const errMsg = e instanceof Error ? e.message : '对话失败';
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
content: firstChunkArrived ? m.content + `\n\n⚠️ ${errMsg}` : `⚠️ ${errMsg}`,
isError: !firstChunkArrived,
}
: m,
),
);
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
abortRef.current = null;
} }
}; };
@@ -485,7 +510,10 @@ export function AIOpsAssistant() {
<span className="inline-block w-2 h-4 ml-0.5 bg-purple-400 animate-pulse align-middle" /> <span className="inline-block w-2 h-4 ml-0.5 bg-purple-400 animate-pulse align-middle" />
)} )}
</div> </div>
<div className="text-xs text-slate-500 mt-1">{fmtTime(message.timestamp)}</div> <div className="text-xs text-slate-500 mt-1">
{fmtTime(message.timestamp)}
{message.isStopped && <span className="ml-2 text-slate-600">(已停止)</span>}
</div>
</div> </div>
</div> </div>
); );
@@ -508,6 +536,17 @@ export function AIOpsAssistant() {
<Button className="bg-purple-600 hover:bg-purple-700" onClick={handleSend} disabled={isLoading || !input.trim()}> <Button className="bg-purple-600 hover:bg-purple-700" onClick={handleSend} disabled={isLoading || !input.trim()}>
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
</Button> </Button>
{/* 流式生成中显示\"停止生成\"按钮,中止当前请求 // Logan.Li */}
{isLoading && (
<Button
variant="outline"
className="border-red-500/40 text-red-400 hover:bg-red-500/10"
onClick={() => abortRef.current?.abort()}
title="停止生成"
>
<Square className="h-4 w-4" />
</Button>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>