feat(ai): session persist + continue HTTP/UI, checkpoints, queue
落地 docs/16 P1:会话落盘与真续跑(prior tool 轨迹)、incomplete「加预算继续」、 awaiting_confirm outcome、同参拒+第4/6轮检查点、侧栏忙时单槽排队。 Ask 真 token 流式记缺口(三协议 SSE 成本高)。
This commit is contained in:
1 parent
1513f4bc2d
commit
d543f3b924
10 files changed
+783
-104
No files matched your search
+140
-17
@@ -12,7 +12,8 @@ import {
|
||||
type McpStatus,
|
||||
} from "./api";
|
||||
import { AI_PRESETS, findAiPreset, type AiAuthStyle, type AiProtocol } from "./aiPresets";
|
||||
import { useWorkspaceStore, type AiAgentStepView, type AiChatMode, type QueryResult } from "./store";
|
||||
import { useWorkspaceStore, type AiAgentStepView, type AiChatMode,
|
||||
type AiMessage, type QueryResult } from "./store";
|
||||
import { editorDatabase } from "./redisContext";
|
||||
|
||||
const ACTIONS: { id: AiAction; label: string }[] = [
|
||||
@@ -409,6 +410,8 @@ export function AiPanel() {
|
||||
const model = useWorkspaceStore((s) => s.aiModel);
|
||||
const busy = useWorkspaceStore((s) => s.aiBusy);
|
||||
const aiMode = useWorkspaceStore((s) => s.aiMode);
|
||||
const aiSessionId = useWorkspaceStore((s) => s.aiSessionId);
|
||||
const aiQueuedPrompt = useWorkspaceStore((s) => s.aiQueuedPrompt);
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const tabs = useWorkspaceStore((s) => s.tabs);
|
||||
@@ -503,15 +506,25 @@ export function AiPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const runAgent = async () => {
|
||||
const text = ask.trim();
|
||||
if (busy || !text) return;
|
||||
const runAgent = async (opts?: {
|
||||
prompt?: string;
|
||||
continue?: boolean;
|
||||
maxRounds?: number;
|
||||
skipUserBubble?: boolean;
|
||||
}) => {
|
||||
const text = (opts?.prompt ?? ask).trim();
|
||||
const isContinue = !!opts?.continue;
|
||||
if (busy) return;
|
||||
if (!isContinue && !text) return;
|
||||
set("aiBusy", true);
|
||||
pushAi("user", text);
|
||||
set("aiAsk", "");
|
||||
if (!opts?.skipUserBubble) {
|
||||
pushAi("user", isContinue && !text ? "加预算继续" : text);
|
||||
}
|
||||
if (!opts?.prompt) set("aiAsk", "");
|
||||
const asstId = pushAi("assistant", "", [], { streaming: true });
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
const sessionId = useWorkspaceStore.getState().aiSessionId || undefined;
|
||||
try {
|
||||
const res = await callAiAgentStream(
|
||||
{
|
||||
@@ -520,15 +533,18 @@ export function AiPanel() {
|
||||
driver: conn?.driver,
|
||||
database: database || undefined,
|
||||
model: model && model !== "opsxdb-stub" ? model : undefined,
|
||||
sessionId,
|
||||
continue: isContinue || undefined,
|
||||
maxRounds: opts?.maxRounds,
|
||||
},
|
||||
{
|
||||
onStepStart: (step) => {
|
||||
patchAiMessage(asstId, (m) => ({
|
||||
...m,
|
||||
steps: [...(m.steps || []).filter((s) => !s.pending), { ...step, pending: true, ok: false, summary: "执行中…", ms: 0 }],
|
||||
}));
|
||||
patchAiMessage(asstId, (m) => {
|
||||
const withoutPending = (m.steps || []).filter((s) => !s.pending);
|
||||
return { ...m, steps: [...withoutPending, { ...step, pending: true, ok: false, summary: "执行中…", ms: 0 }] };
|
||||
});
|
||||
},
|
||||
onStep: (step: AiAgentStepExt) => {
|
||||
onStep: (step) => {
|
||||
patchAiMessage(asstId, (m) => {
|
||||
const withoutPending = (m.steps || []).filter((s) => !s.pending);
|
||||
return { ...m, steps: [...withoutPending, step] };
|
||||
@@ -543,14 +559,45 @@ export function AiPanel() {
|
||||
steps: final.steps || [],
|
||||
streaming: false,
|
||||
runId: final.runId,
|
||||
sessionId: final.sessionId,
|
||||
runOutcome: final.runOutcome || "completed",
|
||||
resumable: final.resumable,
|
||||
incompleteReason: final.incompleteReason,
|
||||
stepsBudget: final.stepsBudget,
|
||||
});
|
||||
if (final.sessionId) set("aiSessionId", final.sessionId);
|
||||
if (final.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, final.sql);
|
||||
},
|
||||
onIncomplete: (final) => {
|
||||
patchAiMessage(asstId, {
|
||||
content: final.content,
|
||||
steps: final.steps || [],
|
||||
streaming: false,
|
||||
runId: final.runId,
|
||||
sessionId: final.sessionId,
|
||||
runOutcome: "incomplete",
|
||||
resumable: true,
|
||||
incompleteReason: final.incompleteReason || "max_steps",
|
||||
stepsBudget: final.stepsBudget,
|
||||
});
|
||||
if (final.sessionId) set("aiSessionId", final.sessionId);
|
||||
},
|
||||
onSession: (meta) => {
|
||||
if (meta.sessionId) set("aiSessionId", meta.sessionId);
|
||||
patchAiMessage(asstId, (m) => ({
|
||||
...m,
|
||||
sessionId: meta.sessionId || m.sessionId,
|
||||
runId: meta.runId || m.runId,
|
||||
runOutcome: meta.runOutcome || m.runOutcome,
|
||||
resumable: meta.resumable ?? m.resumable,
|
||||
}));
|
||||
},
|
||||
onError: (err, partial) => {
|
||||
patchAiMessage(asstId, {
|
||||
content: partial?.content || `失败:${err}`,
|
||||
steps: (partial?.steps as AiAgentStepView[]) || [],
|
||||
streaming: false,
|
||||
runOutcome: "failed",
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -559,25 +606,68 @@ export function AiPanel() {
|
||||
void res;
|
||||
} catch (e) {
|
||||
if ((e as Error)?.name === "AbortError") {
|
||||
patchAiMessage(asstId, (m) => ({ ...m, content: m.content || "已停止", streaming: false }));
|
||||
patchAiMessage(asstId, (m) => ({ ...m, content: m.content || "已停止", streaming: false, runOutcome: "cancelled" }));
|
||||
} else {
|
||||
patchAiMessage(asstId, {
|
||||
content: `失败:${e instanceof Error ? e.message : String(e)}`,
|
||||
streaming: false,
|
||||
runOutcome: "failed",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
set("aiBusy", false);
|
||||
patchAiMessage(asstId, (m) => ({ ...m, streaming: false }));
|
||||
// 忙时单槽:结束后自动续发排队
|
||||
const queued = useWorkspaceStore.getState().aiQueuedPrompt;
|
||||
if (queued && queued.trim()) {
|
||||
set("aiQueuedPrompt", null);
|
||||
void runAgent({ prompt: queued.trim() });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (aiMode === "agent") void runAgent();
|
||||
else void runAsk(action);
|
||||
if (aiMode === "agent") {
|
||||
const text = ask.trim();
|
||||
if (!text) return;
|
||||
if (busy) {
|
||||
// 单槽排队:再提交覆盖
|
||||
set("aiQueuedPrompt", text);
|
||||
set("aiAsk", "");
|
||||
return;
|
||||
}
|
||||
void runAgent();
|
||||
return;
|
||||
}
|
||||
void runAsk(action);
|
||||
};
|
||||
|
||||
const continueIncomplete = (m: AiMessage) => {
|
||||
if (busy) return;
|
||||
const sid = m.sessionId || aiSessionId;
|
||||
if (sid) set("aiSessionId", sid);
|
||||
void runAgent({
|
||||
prompt: "",
|
||||
continue: true,
|
||||
maxRounds: Math.min(16, (m.stepsBudget || 8) + 4),
|
||||
skipUserBubble: false,
|
||||
});
|
||||
};
|
||||
|
||||
const flushQueueNow = () => {
|
||||
const q = useWorkspaceStore.getState().aiQueuedPrompt;
|
||||
if (!q?.trim()) return;
|
||||
set("aiQueuedPrompt", null);
|
||||
abortRef.current?.abort();
|
||||
// 下一 tick 跑排队(等 abort finally 清 busy)
|
||||
window.setTimeout(() => {
|
||||
void runAgent({ prompt: q.trim() });
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const discardQueue = () => set("aiQueuedPrompt", null);
|
||||
|
||||
const pendingConfirm = (() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
@@ -683,6 +773,22 @@ export function AiPanel() {
|
||||
</div>
|
||||
<MessageContent content={m.content} streaming={m.streaming} />
|
||||
{m.role === "assistant" && m.steps && m.steps.length > 0 ? <AgentSteps steps={m.steps} /> : null}
|
||||
{m.role === "assistant" && m.runOutcome === "incomplete" && m.resumable && !m.streaming ? (
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary h-6 px-2 text-[11px]"
|
||||
disabled={busy}
|
||||
onClick={() => continueIncomplete(m)}
|
||||
>
|
||||
加预算继续
|
||||
</button>
|
||||
<span className="text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{m.incompleteReason === "max_steps" ? "已达轮次上限" : m.incompleteReason || "未完成"}
|
||||
{m.stepsBudget ? ` · 预算 ${m.stepsBudget}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{pendingConfirm && pendingConfirm.messageId === m.id && pendingConfirm.step.confirmSql ? (
|
||||
<DangerConfirmBar
|
||||
sql={pendingConfirm.step.confirmSql}
|
||||
@@ -722,6 +828,23 @@ export function AiPanel() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{aiQueuedPrompt ? (
|
||||
<div
|
||||
className="flex items-center gap-1.5 border-t px-2 py-1 text-[11px]"
|
||||
style={{ borderColor: "var(--ops-border-soft)", color: "var(--ops-muted)", background: "var(--ops-surface-2)" }}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate" title={aiQueuedPrompt}>
|
||||
已排队:{aiQueuedPrompt}
|
||||
</span>
|
||||
<button type="button" className="ops-btn-ghost h-5 px-1.5 text-[10px]" onClick={discardQueue}>
|
||||
撤销
|
||||
</button>
|
||||
<button type="button" className="ops-btn-secondary h-5 px-1.5 text-[10px]" onClick={flushQueueNow} disabled={!busy}>
|
||||
立即
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="ops-ai-composer">
|
||||
<div className="ops-ai-mode" role="group" aria-label="Ask 或 Agent">
|
||||
<button type="button" aria-pressed={aiMode === "ask"} className={aiMode === "ask" ? "ops-ai-mode-active" : undefined} onClick={() => setMode("ask")} disabled={busy}>
|
||||
@@ -741,12 +864,12 @@ export function AiPanel() {
|
||||
className="ops-input min-w-0 flex-1 resize-none"
|
||||
placeholder={aiMode === "agent" ? "让 Agent 查库…例如:demo 有哪些表?users 前 5 行" : "描述你想查询什么…"}
|
||||
value={ask}
|
||||
disabled={busy}
|
||||
disabled={busy && aiMode !== "agent"}
|
||||
onChange={(e) => set("aiAsk", e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
if (!busy) send();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -776,7 +899,7 @@ export function AiPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
Agent 与 MCP 同源工具;默认只读。写操作需 mcp.json mode=read_write 且在气泡内确认。
|
||||
Agent 与 MCP 同源工具;默认只读。写操作需确认。忙时可再输入排队(单槽);触顶可「加预算继续」。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+80
-16
@@ -548,16 +548,49 @@ export type AiAgentRes = {
|
||||
stepsBudget?: number;
|
||||
};
|
||||
|
||||
export async function callAiAgent(payload: {
|
||||
prompt: string;
|
||||
connectionId?: string;
|
||||
driver?: string;
|
||||
database?: string;
|
||||
model?: string;
|
||||
}) {
|
||||
export async function callAiAgent(payload: AiAgentStreamPayload) {
|
||||
return post<AiAgentRes>("/api/ai/agent", payload);
|
||||
}
|
||||
|
||||
export type AiSessionSummary = {
|
||||
id: string;
|
||||
title?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
msgCount: number;
|
||||
lastRunId?: string;
|
||||
};
|
||||
|
||||
export async function listAiSessions() {
|
||||
return get<{ sessions: AiSessionSummary[] }>("/api/ai/sessions");
|
||||
}
|
||||
|
||||
export async function getAiSession(id: string) {
|
||||
return get<{
|
||||
id: string;
|
||||
title?: string;
|
||||
status: string;
|
||||
messages: unknown[];
|
||||
runs?: unknown[];
|
||||
}>(`/api/ai/sessions/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function continueAiSession(
|
||||
id: string,
|
||||
payload: {
|
||||
prompt?: string;
|
||||
connectionId?: string;
|
||||
driver?: string;
|
||||
database?: string;
|
||||
model?: string;
|
||||
maxRounds?: number;
|
||||
stream?: boolean;
|
||||
},
|
||||
) {
|
||||
return post<AiAgentRes>(`/api/ai/sessions/${encodeURIComponent(id)}/continue`, payload);
|
||||
}
|
||||
|
||||
export type AiAgentQueryResult = {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
@@ -579,18 +612,26 @@ export type AiAgentStreamHandlers = {
|
||||
onStep?: (step: AiAgentStepExt) => void;
|
||||
onDelta?: (delta: string) => void;
|
||||
onDone?: (res: AiAgentRes) => void;
|
||||
onIncomplete?: (res: AiAgentRes) => void;
|
||||
onSession?: (meta: { sessionId?: string; runId?: string; runOutcome?: string; resumable?: boolean }) => void;
|
||||
onError?: (error: string, partial?: Partial<AiAgentRes>) => void;
|
||||
};
|
||||
|
||||
/** Agent SSE(stream=true);不可用时回落非流式。signal 供「停止」。 */
|
||||
export type AiAgentStreamPayload = {
|
||||
prompt: string;
|
||||
connectionId?: string;
|
||||
driver?: string;
|
||||
database?: string;
|
||||
model?: string;
|
||||
sessionId?: string;
|
||||
continue?: boolean;
|
||||
maxRounds?: number;
|
||||
prior?: unknown[];
|
||||
};
|
||||
|
||||
export async function callAiAgentStream(
|
||||
payload: {
|
||||
prompt: string;
|
||||
connectionId?: string;
|
||||
driver?: string;
|
||||
database?: string;
|
||||
model?: string;
|
||||
},
|
||||
payload: AiAgentStreamPayload,
|
||||
handlers: AiAgentStreamHandlers,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AiAgentRes> {
|
||||
@@ -636,15 +677,38 @@ export async function callAiAgentStream(
|
||||
const d = String(ev.delta || "");
|
||||
content += d;
|
||||
handlers.onDelta?.(d);
|
||||
} else if (typ === "done") {
|
||||
} else if (typ === "done" || typ === "incomplete") {
|
||||
finalRes = {
|
||||
content: String(ev.content ?? content),
|
||||
sql: ev.sql ? String(ev.sql) : undefined,
|
||||
steps: (ev.steps as AiAgentStep[]) || steps,
|
||||
rounds: Number(ev.rounds ?? rounds) || 0,
|
||||
runId: ev.runId ? String(ev.runId) : undefined,
|
||||
sessionId: ev.sessionId ? String(ev.sessionId) : undefined,
|
||||
runOutcome: ev.runOutcome ? String(ev.runOutcome) : typ === "incomplete" ? "incomplete" : undefined,
|
||||
incompleteReason: ev.incompleteReason ? String(ev.incompleteReason) : undefined,
|
||||
resumable: !!ev.resumable,
|
||||
stepsBudget: ev.stepsBudget != null ? Number(ev.stepsBudget) : undefined,
|
||||
};
|
||||
rounds = finalRes.rounds;
|
||||
handlers.onDone?.(finalRes);
|
||||
if (typ === "incomplete") handlers.onIncomplete?.(finalRes);
|
||||
else handlers.onDone?.(finalRes);
|
||||
} else if (typ === "session") {
|
||||
handlers.onSession?.({
|
||||
sessionId: ev.content ? String(ev.content) : ev.sessionId ? String(ev.sessionId) : undefined,
|
||||
runId: ev.runId ? String(ev.runId) : undefined,
|
||||
runOutcome: ev.runOutcome ? String(ev.runOutcome) : undefined,
|
||||
resumable: !!ev.resumable,
|
||||
});
|
||||
if (finalRes) {
|
||||
finalRes = {
|
||||
...finalRes,
|
||||
sessionId: (ev.content ? String(ev.content) : finalRes.sessionId) || finalRes.sessionId,
|
||||
runId: ev.runId ? String(ev.runId) : finalRes.runId,
|
||||
runOutcome: ev.runOutcome ? String(ev.runOutcome) : finalRes.runOutcome,
|
||||
resumable: ev.resumable != null ? !!ev.resumable : finalRes.resumable,
|
||||
};
|
||||
}
|
||||
} else if (typ === "error") {
|
||||
handlers.onError?.(String(ev.error || "agent error"), { content, steps, rounds: Number(ev.rounds) || 0 });
|
||||
}
|
||||
|
||||
@@ -217,6 +217,11 @@ export type AiMessage = {
|
||||
content: string;
|
||||
at: string;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
runOutcome?: string;
|
||||
incompleteReason?: string;
|
||||
resumable?: boolean;
|
||||
stepsBudget?: number;
|
||||
/** Agent 工具步骤时间线(仅 assistant) */
|
||||
steps?: AiAgentStepView[];
|
||||
streaming?: boolean;
|
||||
@@ -266,6 +271,10 @@ type State = {
|
||||
aiBusy: boolean;
|
||||
/** Ask=不调工具;Agent=多轮 aitools */
|
||||
aiMode: AiChatMode;
|
||||
/** 当前 Agent 会话 id(落盘续跑) */
|
||||
aiSessionId: string | null;
|
||||
/** 忙时单槽排队文案;再提交覆盖 */
|
||||
aiQueuedPrompt: string | null;
|
||||
status: string;
|
||||
notifyModal: NotifyPayload | null;
|
||||
notifyToasts: NotifyPayload[];
|
||||
@@ -501,6 +510,8 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
aiModel: "opsxdb-stub",
|
||||
aiBusy: false,
|
||||
aiMode: "ask",
|
||||
aiSessionId: null,
|
||||
aiQueuedPrompt: null,
|
||||
status: "就绪",
|
||||
notifyModal: null,
|
||||
notifyToasts: [],
|
||||
|
||||
Reference in new issue
Block a user