feat(ai): 新会话/清屏 + 工作中转圈 + 可编辑单槽排队
对标 opsxshell/aiops 契约 14 与附图 UX:顶栏新会话与清屏; 忙时「工作中 N 秒」;排队卡支持立即/编辑/删除;Ask 亦单槽排队; run 世代防停止后旧 finally 清 busy。
This commit is contained in:
1 parent
d543f3b924
commit
9c2b623ce4
4 files changed
+318
-77
No files matched your search
@@ -267,7 +267,19 @@ confirm_bind = sha256(connection_id + "\n" + normalize(sql_or_command))
|
|||||||
| ✅ | 会话落盘 + HTTP 续跑(`GET/DELETE /api/ai/sessions`、`POST …/continue`)+ UI「加预算继续」 |
|
| ✅ | 会话落盘 + HTTP 续跑(`GET/DELETE /api/ai/sessions`、`POST …/continue`)+ UI「加预算继续」 |
|
||||||
| ✅ | NeedConfirm 结束 run 为 `awaiting_confirm` outcome |
|
| ✅ | NeedConfirm 结束 run 为 `awaiting_confirm` outcome |
|
||||||
| ✅ | 同参循环拒 + 中段检查点(第 4 / 6 轮)接入主循环 |
|
| ✅ | 同参循环拒 + 中段检查点(第 4 / 6 轮)接入主循环 |
|
||||||
| ✅ | 忙时单槽排队 UI(覆盖 / 撤销 / 立即 / 结束后自动续发) |
|
| ✅ | 忙时单槽排队 UI(覆盖 / 编辑 / 撤销 / 立即 / 工作中计时 / 结束后自动续发;Ask+Agent) |
|
||||||
|
| ✅ | 新会话 / 清屏(侧栏顶栏;清屏丢 sessionId 防隐式续跑) |
|
||||||
|
|
||||||
|
### 侧栏 UX(对标 opsxshell / 附图)
|
||||||
|
|
||||||
|
| 控件 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| **新会话** | 停止当前 → 清空消息 / sessionId / 排队 / 输入 |
|
||||||
|
| **清屏** | 停止当前 → 清空消息与排队(草稿保留);丢掉 sessionId |
|
||||||
|
| **工作中 N 秒** | `aiBusy` 时列表底转圈计时 |
|
||||||
|
| **排队卡** | 单槽:`立即`(中断开跑)/ `编辑`(拉回输入)/ `删除`(撤销) |
|
||||||
|
| **忙时输入** | placeholder「继续输入以排队…」;发送=入队(再提交覆盖) |
|
||||||
|
|
||||||
| ⬜ | Ask 真 token 流式(非假打字)— 成本高:需 openai-chat / anthropic / responses 三套 SSE;本轮记缺口,Ask 仍一次性 Complete |
|
| ⬜ | Ask 真 token 流式(非假打字)— 成本高:需 openai-chat / anthropic / responses 三套 SSE;本轮记缺口,Ask 仍一次性 Complete |
|
||||||
| ⬜ | MCP「断开」结束外部 Cursor 会话(当前仅 UI 提示) |
|
| ⬜ | MCP「断开」结束外部 Cursor 会话(当前仅 UI 提示) |
|
||||||
| ⬜ | 设置页一键 `npm install -g`(现为复制命令;避免桌面代跑需授权) |
|
| ⬜ | 设置页一键 `npm install -g`(现为复制命令;避免桌面代跑需授权) |
|
||||||
|
|||||||
+238
-64
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ArrowUp, Eraser, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
callAi,
|
callAi,
|
||||||
callAiAgentStream,
|
callAiAgentStream,
|
||||||
@@ -424,7 +425,13 @@ export function AiPanel() {
|
|||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [mcp, setMcp] = useState<McpStatus | null>(null);
|
const [mcp, setMcp] = useState<McpStatus | null>(null);
|
||||||
const [mcpOpen, setMcpOpen] = useState(false);
|
const [mcpOpen, setMcpOpen] = useState(false);
|
||||||
|
const [elapsedSec, setElapsedSec] = useState(0);
|
||||||
|
const [editingQueue, setEditingQueue] = useState(false);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
/** 世代号:停止/立即发送时 bump,避免旧 finally 清 busy 或误续发排队 */
|
||||||
|
const runGenRef = useRef(0);
|
||||||
|
const skipAutoQueueRef = useRef(false);
|
||||||
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -461,6 +468,27 @@ export function AiPanel() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 工作中计时(对标 shell / 附图「工作中 N 秒」)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!busy) {
|
||||||
|
setElapsedSec(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const started = Date.now();
|
||||||
|
setElapsedSec(0);
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
setElapsedSec(Math.max(0, Math.floor((Date.now() - started) / 1000)));
|
||||||
|
}, 500);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [busy]);
|
||||||
|
|
||||||
|
// 新消息滚到底
|
||||||
|
useEffect(() => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}, [messages.length, busy, aiQueuedPrompt]);
|
||||||
|
|
||||||
const conn = connections.find((c) => c.id === activeConnectionId);
|
const conn = connections.find((c) => c.id === activeConnectionId);
|
||||||
const tab = tabs.find((t) => t.id === activeTabId);
|
const tab = tabs.find((t) => t.id === activeTabId);
|
||||||
const sql = tab?.sql ?? "";
|
const sql = tab?.sql ?? "";
|
||||||
@@ -470,39 +498,76 @@ export function AiPanel() {
|
|||||||
const setMode = (mode: AiChatMode) => set("aiMode", mode);
|
const setMode = (mode: AiChatMode) => set("aiMode", mode);
|
||||||
|
|
||||||
const stopAgent = () => {
|
const stopAgent = () => {
|
||||||
|
runGenRef.current += 1;
|
||||||
|
skipAutoQueueRef.current = true;
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
set("aiBusy", false);
|
set("aiBusy", false);
|
||||||
|
// 下一 tick 恢复自动续发开关(给「立即」microtask 用)
|
||||||
|
queueMicrotask(() => {
|
||||||
|
skipAutoQueueRef.current = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushQueuedAfterIdle = () => {
|
||||||
|
if (skipAutoQueueRef.current) return;
|
||||||
|
const queued = useWorkspaceStore.getState().aiQueuedPrompt;
|
||||||
|
if (!queued?.trim()) return;
|
||||||
|
set("aiQueuedPrompt", null);
|
||||||
|
const mode = useWorkspaceStore.getState().aiMode;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (mode === "agent") void runAgent({ prompt: queued.trim() });
|
||||||
|
else void runAsk(action, queued.trim());
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const runAsk = async (action: AiAction, prompt?: string) => {
|
const runAsk = async (action: AiAction, prompt?: string) => {
|
||||||
const text = (prompt ?? ask).trim();
|
const text = (prompt ?? ask).trim();
|
||||||
if (busy || !conn || ((action === "ask" || action === "generate") && !text)) return;
|
if (!conn || ((action === "ask" || action === "generate") && !text)) return;
|
||||||
|
if (useWorkspaceStore.getState().aiBusy) return;
|
||||||
|
const gen = ++runGenRef.current;
|
||||||
set("aiBusy", true);
|
set("aiBusy", true);
|
||||||
if (action === "ask" || action === "generate") {
|
if (action === "ask" || action === "generate") {
|
||||||
pushAi("user", text || ACTIONS.find((a) => a.id === action)?.label || action);
|
pushAi("user", text || ACTIONS.find((a) => a.id === action)?.label || action);
|
||||||
} else {
|
} else {
|
||||||
pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`);
|
pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`);
|
||||||
}
|
}
|
||||||
|
if (prompt !== undefined || action === "ask" || action === "generate") {
|
||||||
|
if (!prompt) set("aiAsk", "");
|
||||||
|
}
|
||||||
|
const ac = new AbortController();
|
||||||
|
abortRef.current = ac;
|
||||||
try {
|
try {
|
||||||
const schema = schemaContextFor(conn?.id, database);
|
const schema = schemaContextFor(conn?.id, database);
|
||||||
const res = await callAi({
|
const res = await callAi(
|
||||||
action,
|
{
|
||||||
prompt: text,
|
action,
|
||||||
sql,
|
prompt: text,
|
||||||
driver: conn?.driver,
|
sql,
|
||||||
database: database,
|
driver: conn?.driver,
|
||||||
error: result?.error,
|
database: database,
|
||||||
schema: schema || undefined,
|
error: result?.error,
|
||||||
model: model && model !== "opsxdb-stub" ? model : undefined,
|
schema: schema || undefined,
|
||||||
});
|
model: model && model !== "opsxdb-stub" ? model : undefined,
|
||||||
|
},
|
||||||
|
ac.signal,
|
||||||
|
);
|
||||||
|
if (runGenRef.current !== gen) return;
|
||||||
pushAi("assistant", res.content);
|
pushAi("assistant", res.content);
|
||||||
if (res.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, res.sql);
|
if (res.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, res.sql);
|
||||||
if (action === "ask" || action === "generate") set("aiAsk", "");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pushAi("assistant", `失败:${e instanceof Error ? e.message : String(e)}`);
|
if (runGenRef.current !== gen) return;
|
||||||
|
if ((e as Error)?.name === "AbortError") {
|
||||||
|
pushAi("assistant", "已停止");
|
||||||
|
} else {
|
||||||
|
pushAi("assistant", `失败:${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
set("aiBusy", false);
|
if (runGenRef.current === gen) {
|
||||||
|
abortRef.current = null;
|
||||||
|
set("aiBusy", false);
|
||||||
|
flushQueuedAfterIdle();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -514,8 +579,9 @@ export function AiPanel() {
|
|||||||
}) => {
|
}) => {
|
||||||
const text = (opts?.prompt ?? ask).trim();
|
const text = (opts?.prompt ?? ask).trim();
|
||||||
const isContinue = !!opts?.continue;
|
const isContinue = !!opts?.continue;
|
||||||
if (busy) return;
|
if (useWorkspaceStore.getState().aiBusy) return;
|
||||||
if (!isContinue && !text) return;
|
if (!isContinue && !text) return;
|
||||||
|
const gen = ++runGenRef.current;
|
||||||
set("aiBusy", true);
|
set("aiBusy", true);
|
||||||
if (!opts?.skipUserBubble) {
|
if (!opts?.skipUserBubble) {
|
||||||
pushAi("user", isContinue && !text ? "加预算继续" : text);
|
pushAi("user", isContinue && !text ? "加预算继续" : text);
|
||||||
@@ -539,21 +605,25 @@ export function AiPanel() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onStepStart: (step) => {
|
onStepStart: (step) => {
|
||||||
|
if (runGenRef.current !== gen) return;
|
||||||
patchAiMessage(asstId, (m) => {
|
patchAiMessage(asstId, (m) => {
|
||||||
const withoutPending = (m.steps || []).filter((s) => !s.pending);
|
const withoutPending = (m.steps || []).filter((s) => !s.pending);
|
||||||
return { ...m, steps: [...withoutPending, { ...step, pending: true, ok: false, summary: "执行中…", ms: 0 }] };
|
return { ...m, steps: [...withoutPending, { ...step, pending: true, ok: false, summary: "执行中…", ms: 0 }] };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onStep: (step) => {
|
onStep: (step) => {
|
||||||
|
if (runGenRef.current !== gen) return;
|
||||||
patchAiMessage(asstId, (m) => {
|
patchAiMessage(asstId, (m) => {
|
||||||
const withoutPending = (m.steps || []).filter((s) => !s.pending);
|
const withoutPending = (m.steps || []).filter((s) => !s.pending);
|
||||||
return { ...m, steps: [...withoutPending, step] };
|
return { ...m, steps: [...withoutPending, step] };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDelta: (delta) => {
|
onDelta: (delta) => {
|
||||||
|
if (runGenRef.current !== gen) return;
|
||||||
patchAiMessage(asstId, (m) => ({ ...m, content: (m.content || "") + delta, streaming: true }));
|
patchAiMessage(asstId, (m) => ({ ...m, content: (m.content || "") + delta, streaming: true }));
|
||||||
},
|
},
|
||||||
onDone: (final) => {
|
onDone: (final) => {
|
||||||
|
if (runGenRef.current !== gen) return;
|
||||||
patchAiMessage(asstId, {
|
patchAiMessage(asstId, {
|
||||||
content: final.content,
|
content: final.content,
|
||||||
steps: final.steps || [],
|
steps: final.steps || [],
|
||||||
@@ -569,6 +639,7 @@ export function AiPanel() {
|
|||||||
if (final.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, final.sql);
|
if (final.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, final.sql);
|
||||||
},
|
},
|
||||||
onIncomplete: (final) => {
|
onIncomplete: (final) => {
|
||||||
|
if (runGenRef.current !== gen) return;
|
||||||
patchAiMessage(asstId, {
|
patchAiMessage(asstId, {
|
||||||
content: final.content,
|
content: final.content,
|
||||||
steps: final.steps || [],
|
steps: final.steps || [],
|
||||||
@@ -583,6 +654,7 @@ export function AiPanel() {
|
|||||||
if (final.sessionId) set("aiSessionId", final.sessionId);
|
if (final.sessionId) set("aiSessionId", final.sessionId);
|
||||||
},
|
},
|
||||||
onSession: (meta) => {
|
onSession: (meta) => {
|
||||||
|
if (runGenRef.current !== gen) return;
|
||||||
if (meta.sessionId) set("aiSessionId", meta.sessionId);
|
if (meta.sessionId) set("aiSessionId", meta.sessionId);
|
||||||
patchAiMessage(asstId, (m) => ({
|
patchAiMessage(asstId, (m) => ({
|
||||||
...m,
|
...m,
|
||||||
@@ -593,6 +665,7 @@ export function AiPanel() {
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
onError: (err, partial) => {
|
onError: (err, partial) => {
|
||||||
|
if (runGenRef.current !== gen) return;
|
||||||
patchAiMessage(asstId, {
|
patchAiMessage(asstId, {
|
||||||
content: partial?.content || `失败:${err}`,
|
content: partial?.content || `失败:${err}`,
|
||||||
steps: (partial?.steps as AiAgentStepView[]) || [],
|
steps: (partial?.steps as AiAgentStepView[]) || [],
|
||||||
@@ -605,6 +678,7 @@ export function AiPanel() {
|
|||||||
);
|
);
|
||||||
void res;
|
void res;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (runGenRef.current !== gen) return;
|
||||||
if ((e as Error)?.name === "AbortError") {
|
if ((e as Error)?.name === "AbortError") {
|
||||||
patchAiMessage(asstId, (m) => ({ ...m, content: m.content || "已停止", streaming: false, runOutcome: "cancelled" }));
|
patchAiMessage(asstId, (m) => ({ ...m, content: m.content || "已停止", streaming: false, runOutcome: "cancelled" }));
|
||||||
} else {
|
} else {
|
||||||
@@ -615,31 +689,37 @@ export function AiPanel() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
abortRef.current = null;
|
if (runGenRef.current === gen) {
|
||||||
set("aiBusy", false);
|
abortRef.current = null;
|
||||||
patchAiMessage(asstId, (m) => ({ ...m, streaming: false }));
|
set("aiBusy", false);
|
||||||
// 忙时单槽:结束后自动续发排队
|
patchAiMessage(asstId, (m) => ({ ...m, streaming: false }));
|
||||||
const queued = useWorkspaceStore.getState().aiQueuedPrompt;
|
flushQueuedAfterIdle();
|
||||||
if (queued && queued.trim()) {
|
|
||||||
set("aiQueuedPrompt", null);
|
|
||||||
void runAgent({ prompt: queued.trim() });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 发送或入队(对标 opsxshell / aiops 契约 14) */
|
||||||
const send = () => {
|
const send = () => {
|
||||||
|
const text = ask.trim();
|
||||||
if (aiMode === "agent") {
|
if (aiMode === "agent") {
|
||||||
const text = ask.trim();
|
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (busy) {
|
if (busy) {
|
||||||
// 单槽排队:再提交覆盖
|
|
||||||
set("aiQueuedPrompt", text);
|
set("aiQueuedPrompt", text);
|
||||||
set("aiAsk", "");
|
set("aiAsk", "");
|
||||||
|
setEditingQueue(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void runAgent();
|
void runAgent();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Ask:忙时同样单槽排队
|
||||||
|
if (busy) {
|
||||||
|
if (!text) return;
|
||||||
|
set("aiQueuedPrompt", text);
|
||||||
|
set("aiAsk", "");
|
||||||
|
setEditingQueue(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void runAsk(action);
|
void runAsk(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -655,18 +735,53 @@ export function AiPanel() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 立即:中断当前 → 立刻跑排队内容 */
|
||||||
const flushQueueNow = () => {
|
const flushQueueNow = () => {
|
||||||
const q = useWorkspaceStore.getState().aiQueuedPrompt;
|
const q = useWorkspaceStore.getState().aiQueuedPrompt;
|
||||||
if (!q?.trim()) return;
|
if (!q?.trim()) return;
|
||||||
set("aiQueuedPrompt", null);
|
set("aiQueuedPrompt", null);
|
||||||
abortRef.current?.abort();
|
setEditingQueue(false);
|
||||||
// 下一 tick 跑排队(等 abort finally 清 busy)
|
stopAgent();
|
||||||
window.setTimeout(() => {
|
const mode = useWorkspaceStore.getState().aiMode;
|
||||||
void runAgent({ prompt: q.trim() });
|
queueMicrotask(() => {
|
||||||
}, 50);
|
if (mode === "agent") void runAgent({ prompt: q.trim() });
|
||||||
|
else void runAsk(action, q.trim());
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const discardQueue = () => set("aiQueuedPrompt", null);
|
/** 编辑:拉回输入框,清空排队槽 */
|
||||||
|
const editQueue = () => {
|
||||||
|
const q = useWorkspaceStore.getState().aiQueuedPrompt;
|
||||||
|
if (!q) return;
|
||||||
|
set("aiAsk", q);
|
||||||
|
set("aiQueuedPrompt", null);
|
||||||
|
setEditingQueue(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 撤销 / 删除排队 */
|
||||||
|
const discardQueue = () => {
|
||||||
|
set("aiQueuedPrompt", null);
|
||||||
|
setEditingQueue(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 新会话:丢弃轨迹与 sessionId,可重新开跑 */
|
||||||
|
const newSession = () => {
|
||||||
|
stopAgent();
|
||||||
|
set("aiMessages", []);
|
||||||
|
set("aiSessionId", null);
|
||||||
|
set("aiQueuedPrompt", null);
|
||||||
|
set("aiAsk", "");
|
||||||
|
setEditingQueue(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 清屏:只清可见消息与排队,草稿保留;sessionId 一并丢掉以免隐式续跑旧轨迹 */
|
||||||
|
const clearScreen = () => {
|
||||||
|
if (busy) stopAgent();
|
||||||
|
set("aiMessages", []);
|
||||||
|
set("aiSessionId", null);
|
||||||
|
set("aiQueuedPrompt", null);
|
||||||
|
setEditingQueue(false);
|
||||||
|
};
|
||||||
|
|
||||||
const pendingConfirm = (() => {
|
const pendingConfirm = (() => {
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
@@ -678,11 +793,19 @@ export function AiPanel() {
|
|||||||
return null;
|
return null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const placeholder = busy
|
||||||
|
? aiQueuedPrompt
|
||||||
|
? "继续输入以覆盖排队内容…"
|
||||||
|
: "继续输入以排队后续修改"
|
||||||
|
: aiMode === "agent"
|
||||||
|
? "让 Agent 查库…例如:demo 有哪些表?users 前 5 行"
|
||||||
|
: "描述你想查询什么…";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="ops-panel flex h-full w-full min-w-0 flex-col border-l">
|
<aside className="ops-panel flex h-full w-full min-w-0 flex-col border-l">
|
||||||
<div className="ops-section-label justify-between border-b gap-1">
|
<div className="ops-section-label justify-between border-b gap-1">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5 min-w-0">
|
||||||
新对话
|
<span className="truncate">AI 会话</span>
|
||||||
{mcp?.active ? (
|
{mcp?.active ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -696,7 +819,28 @@ export function AiPanel() {
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ops-btn-ghost inline-flex h-5 items-center gap-0.5 px-1.5 text-[10px]"
|
||||||
|
title="新会话"
|
||||||
|
aria-label="新会话"
|
||||||
|
onClick={newSession}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" aria-hidden />
|
||||||
|
新会话
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ops-btn-ghost inline-flex h-5 items-center gap-0.5 px-1.5 text-[10px]"
|
||||||
|
title="清屏"
|
||||||
|
aria-label="清屏"
|
||||||
|
disabled={messages.length === 0 && !aiQueuedPrompt}
|
||||||
|
onClick={clearScreen}
|
||||||
|
>
|
||||||
|
<Eraser className="h-3 w-3" aria-hidden />
|
||||||
|
清屏
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]"
|
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]"
|
||||||
@@ -744,8 +888,8 @@ export function AiPanel() {
|
|||||||
|
|
||||||
{showSettings && <AiSettings onClose={() => setShowSettings(false)} />}
|
{showSettings && <AiSettings onClose={() => setShowSettings(false)} />}
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-auto p-1.5">
|
<div ref={listRef} className="min-h-0 flex-1 space-y-1.5 overflow-auto p-1.5">
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && !busy && (
|
||||||
<div className="grid h-full place-items-center px-2 text-center text-[12px]" style={{ color: "var(--ops-muted)" }}>
|
<div className="grid h-full place-items-center px-2 text-center text-[12px]" style={{ color: "var(--ops-muted)" }}>
|
||||||
{aiMode === "agent"
|
{aiMode === "agent"
|
||||||
? "Agent 会真查白名单里的库。试试:列出 demo 有哪些表"
|
? "Agent 会真查白名单里的库。试试:列出 demo 有哪些表"
|
||||||
@@ -826,45 +970,58 @@ export function AiPanel() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
|
|
||||||
{aiQueuedPrompt ? (
|
{busy ? (
|
||||||
<div
|
<div className="ops-ai-working" role="status" aria-live="polite">
|
||||||
className="flex items-center gap-1.5 border-t px-2 py-1 text-[11px]"
|
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" style={{ color: "var(--ops-accent)" }} aria-hidden />
|
||||||
style={{ borderColor: "var(--ops-border-soft)", color: "var(--ops-muted)", background: "var(--ops-surface-2)" }}
|
<span>工作中 {elapsedSec} 秒</span>
|
||||||
>
|
</div>
|
||||||
<span className="min-w-0 flex-1 truncate" title={aiQueuedPrompt}>
|
) : null}
|
||||||
已排队:{aiQueuedPrompt}
|
|
||||||
</span>
|
{aiQueuedPrompt ? (
|
||||||
<button type="button" className="ops-btn-ghost h-5 px-1.5 text-[10px]" onClick={discardQueue}>
|
<div className="ops-ai-queue-card" role="status">
|
||||||
撤销
|
<div className="ops-ai-queue-card-body" title={aiQueuedPrompt}>
|
||||||
</button>
|
<span className="ops-ai-queue-grip" aria-hidden>
|
||||||
<button type="button" className="ops-btn-secondary h-5 px-1.5 text-[10px]" onClick={flushQueueNow} disabled={!busy}>
|
⠿
|
||||||
立即
|
</span>
|
||||||
</button>
|
<span className="ops-ai-queue-text">{aiQueuedPrompt}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div className="ops-ai-queue-actions">
|
||||||
|
<button type="button" className="ops-btn-secondary h-6 px-2 text-[10px] inline-flex items-center gap-1" onClick={flushQueueNow} title="中断当前并立即发送">
|
||||||
|
<ArrowUp className="h-3 w-3" aria-hidden />
|
||||||
|
立即
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ops-btn-ghost h-6 w-6 grid place-items-center p-0" onClick={editQueue} title="编辑排队内容" aria-label="编辑">
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ops-btn-ghost h-6 w-6 grid place-items-center p-0" onClick={discardQueue} title="撤销排队" aria-label="删除">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="ops-ai-composer">
|
<div className="ops-ai-composer">
|
||||||
<div className="ops-ai-mode" role="group" aria-label="Ask 或 Agent">
|
<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}>
|
<button type="button" aria-pressed={aiMode === "ask"} className={aiMode === "ask" ? "ops-ai-mode-active" : undefined} onClick={() => setMode("ask")}>
|
||||||
Ask
|
Ask
|
||||||
</button>
|
</button>
|
||||||
<button type="button" aria-pressed={aiMode === "agent"} className={aiMode === "agent" ? "ops-ai-mode-active" : undefined} onClick={() => setMode("agent")} disabled={busy}>
|
<button type="button" aria-pressed={aiMode === "agent"} className={aiMode === "agent" ? "ops-ai-mode-active" : undefined} onClick={() => setMode("agent")}>
|
||||||
Agent
|
Agent
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="ops-ai-context" title="跟随 SQL 编辑器当前数据库">
|
<div className="ops-ai-context" title="跟随 SQL 编辑器当前数据库">
|
||||||
{conn?.name || "未选择连接"} › {conn?.driver.toLowerCase() === "redis" ? `DB${database}` : database || "未选择数据库"}
|
{conn?.name || "未选择连接"} › {conn?.driver.toLowerCase() === "redis" ? `DB${database}` : database || "未选择数据库"}
|
||||||
{aiMode === "agent" ? (mcp?.writesEnabled ? " · 可写需确认" : " · 只读工具") : ""}
|
{aiMode === "agent" ? (mcp?.writesEnabled ? " · 可写需确认" : " · 只读工具") : ""}
|
||||||
|
{aiSessionId ? ` · ${aiSessionId.slice(0, 12)}…` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<textarea
|
<textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
className="ops-input min-w-0 flex-1 resize-none"
|
className="ops-input min-w-0 flex-1 resize-none"
|
||||||
placeholder={aiMode === "agent" ? "让 Agent 查库…例如:demo 有哪些表?users 前 5 行" : "描述你想查询什么…"}
|
placeholder={placeholder}
|
||||||
value={ask}
|
value={ask}
|
||||||
disabled={busy && aiMode !== "agent"}
|
|
||||||
onChange={(e) => set("aiAsk", e.target.value)}
|
onChange={(e) => set("aiAsk", e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
@@ -873,13 +1030,25 @@ export function AiPanel() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{busy && aiMode === "agent" ? (
|
{busy ? (
|
||||||
<button type="button" className="ops-btn-secondary" onClick={stopAgent}>
|
<div className="flex flex-col gap-1">
|
||||||
停止
|
<button type="button" className="ops-btn-secondary inline-flex items-center justify-center gap-1" onClick={stopAgent} title="停止当前运行">
|
||||||
</button>
|
停止
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ops-btn-primary inline-flex items-center justify-center gap-1"
|
||||||
|
disabled={!ask.trim()}
|
||||||
|
onClick={send}
|
||||||
|
title={ask.trim() ? "加入排队(单槽,覆盖)" : "输入后可排队"}
|
||||||
|
>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||||
|
排队
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className="ops-btn-primary" disabled={busy || (aiMode === "agent" && !ask.trim())} onClick={send}>
|
<button type="button" className="ops-btn-primary" disabled={aiMode === "agent" && !ask.trim()} onClick={send}>
|
||||||
{busy ? "…" : "发送"}
|
发送
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -892,6 +1061,7 @@ export function AiPanel() {
|
|||||||
aria-pressed={action === a.id}
|
aria-pressed={action === a.id}
|
||||||
className={action === a.id ? "ops-ai-action-active" : undefined}
|
className={action === a.id ? "ops-ai-action-active" : undefined}
|
||||||
onClick={() => setAction(a.id)}
|
onClick={() => setAction(a.id)}
|
||||||
|
disabled={busy}
|
||||||
>
|
>
|
||||||
{a.id === "generate" ? "生成 SQL" : a.label}
|
{a.id === "generate" ? "生成 SQL" : a.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -899,7 +1069,11 @@ export function AiPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-1.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
<div className="mt-1.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||||
Agent 与 MCP 同源工具;默认只读。写操作需确认。忙时可再输入排队(单槽);触顶可「加预算继续」。
|
{busy
|
||||||
|
? editingQueue
|
||||||
|
? "编辑中:改完回车或点「排队」覆盖单槽。"
|
||||||
|
: "工作中可继续输入排队(单槽);点排队卡「立即」可改方向。"
|
||||||
|
: "Agent 与 MCP 同源工具;默认只读。写操作需确认。触顶可「加预算继续」。"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+15
-12
@@ -511,18 +511,21 @@ export type AiAction =
|
|||||||
| "dialect"
|
| "dialect"
|
||||||
| "sample";
|
| "sample";
|
||||||
|
|
||||||
export async function callAi(payload: {
|
export async function callAi(
|
||||||
action: AiAction;
|
payload: {
|
||||||
prompt?: string;
|
action: AiAction;
|
||||||
sql?: string;
|
prompt?: string;
|
||||||
driver?: string;
|
sql?: string;
|
||||||
database?: string;
|
driver?: string;
|
||||||
error?: string;
|
database?: string;
|
||||||
/** C4/C8:前端注入的库表列上下文(截断 4000 字符) */
|
error?: string;
|
||||||
schema?: string;
|
/** C4/C8:前端注入的库表列上下文(截断 4000 字符) */
|
||||||
model?: string;
|
schema?: string;
|
||||||
}) {
|
model?: string;
|
||||||
return post<{ content: string; sql?: string }>("/api/ai/chat", payload);
|
},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
return post<{ content: string; sql?: string }>("/api/ai/chat", payload, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 阶段 2:Agent 模式 — 服务端多轮调同一 aitools Runtime(Ask 仍用 callAi) */
|
/** 阶段 2:Agent 模式 — 服务端多轮调同一 aitools Runtime(Ask 仍用 callAi) */
|
||||||
|
|||||||
@@ -708,3 +708,55 @@
|
|||||||
@keyframes ops-ai-blink { 50% { opacity: 0; } }
|
@keyframes ops-ai-blink { 50% { opacity: 0; } }
|
||||||
.ops-ai-step-spin { animation: ops-ai-pulse 0.9s ease-in-out infinite; }
|
.ops-ai-step-spin { animation: ops-ai-pulse 0.9s ease-in-out infinite; }
|
||||||
@keyframes ops-ai-pulse { 50% { opacity: 0.35; transform: scale(0.85); } }
|
@keyframes ops-ai-pulse { 50% { opacity: 0.35; transform: scale(0.85); } }
|
||||||
|
|
||||||
|
/* AI 会话:工作中 / 排队卡(对标 opsxshell · aiops 契约 14) */
|
||||||
|
.ops-ai-working {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ops-muted);
|
||||||
|
}
|
||||||
|
.ops-ai-queue-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 2px 0 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--ops-accent) 28%, var(--ops-border));
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--ops-accent) 8%, var(--ops-surface-2));
|
||||||
|
}
|
||||||
|
.ops-ai-queue-card-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.ops-ai-queue-grip {
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.ops-ai-queue-text {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ops-text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ops-ai-queue-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user