feat(control-plane): add web AI message processing
Build web service image / build (push) Successful in 48s

This commit is contained in:
2026-09-12 15:57:49 +08:00
parent 97cfba7f01
commit e321d38fa3
14 changed files with 12974 additions and 73 deletions
+497 -7
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";
@@ -7,6 +7,7 @@ const navItems = [
{ id: "nodes", label: "节点", icon: "◈" },
{ id: "tasks", label: "任务", icon: "↗" },
{ id: "events", label: "白名单事件", icon: "◉" },
{ id: "ai", label: "AI 处理", icon: "✦" },
{ id: "audit", label: "操作审计", icon: "≡" },
];
@@ -674,6 +675,475 @@ function TaskModal({ nodes, token, onClose, onCreated }) {
);
}
const defaultAISchema = `{
"type": "object",
"properties": {
"label": { "type": "string" },
"reply": { "type": "string" }
},
"required": ["label"],
"additionalProperties": false
}`;
function aiFlowPayload(flow, enabled = flow.enabled) {
return {
name: flow.name,
targets: flow.targets,
trigger: flow.trigger,
instruction: flow.instruction,
output_schema: flow.output_schema,
tools: flow.tools || [],
enabled,
};
}
function aiTargetSummary(target) {
if (!target) return "—";
const type = target.chat_type === "Group" ? "群聊" : "私聊";
return `${type} ${shortId(target.chat_id, 18)}`;
}
function AIView({ nodes, flows, runs, provider, token, onChanged }) {
const [showCreate, setShowCreate] = useState(false);
const [busyId, setBusyId] = useState("");
const flowNames = Object.fromEntries(
flows.map((flow) => [flow.flow_id, flow.name]),
);
const toggle = async (flow) => {
setBusyId(flow.flow_id);
try {
await api(`/v1/ai/flows/${encodeURIComponent(flow.flow_id)}`, {
token,
method: "PUT",
body: aiFlowPayload(flow, !flow.enabled),
});
onChanged();
} catch (error) {
window.alert(error.message);
} finally {
setBusyId("");
}
};
const runNow = async (flow) => {
setBusyId(flow.flow_id);
try {
await api(`/v1/ai/flows/${encodeURIComponent(flow.flow_id)}/run`, {
token,
method: "POST",
});
onChanged();
} catch (error) {
window.alert(error.message);
} finally {
setBusyId("");
}
};
return (
<>
<section className="panel full-panel">
<div className="panel-heading">
<div>
<h3>AI 消息处理流程</h3>
<p>按账号和稳定会话 ID处理实时消息或定时拉取消息。</p>
</div>
<div className="heading-actions">
<span
className={
provider?.provider_configured ? "provider-ok" : "provider-off"
}
>
{provider?.provider_configured
? `Provider · ${provider.model}`
: "未配置 Provider"}
</span>
<button
className="primary-button"
onClick={() => setShowCreate(true)}
>
+ 新建流程
</button>
</div>
</div>
{flows.length === 0 ? (
<Empty text="暂无 AI 流程,请先创建一个处理流程。" />
) : (
<div className="table-wrap">
<table className="ai-table">
<thead>
<tr>
<th>流程</th>
<th>目标</th>
<th>触发方式</th>
<th>Tools</th>
<th>状态</th>
<th>最近运行</th>
<th />
</tr>
</thead>
<tbody>
{flows.map((flow) => (
<tr key={flow.flow_id}>
<td>
<div className="cell-primary">
<div className="ai-symbol">✦</div>
<div>
<strong>{flow.name}</strong>
<small>{shortId(flow.flow_id, 16)}</small>
</div>
</div>
</td>
<td>
<strong>{aiTargetSummary(flow.targets?.[0])}</strong>
<small className="cell-note">
{flow.targets?.[0]?.account_id || "—"}
</small>
</td>
<td>
<span className="trigger-pill">
{flow.trigger?.type === "realtime"
? "实时消息"
: `每 ${flow.trigger?.interval_seconds || 60} 秒`}
</span>
{flow.trigger?.type === "interval" &&
flow.next_run_at && (
<small className="cell-note">
下次 {formatTime(flow.next_run_at)}
</small>
)}
</td>
<td>
<span className="tool-list">
{flow.tools?.includes("reply_text")
? "上下文 + 自动回复"
: "上下文"}
</span>
</td>
<td>
<span
className={
flow.enabled
? "flow-state enabled"
: "flow-state disabled"
}
>
{flow.enabled ? "运行中" : "已暂停"}
</span>
{flow.last_error && (
<small className="cell-note error-code">
{flow.last_error}
</small>
)}
</td>
<td>{formatTime(flow.last_run_at)}</td>
<td>
<div className="row-actions">
{flow.trigger?.type === "interval" && (
<button
className="text-button"
disabled={busyId === flow.flow_id || !flow.enabled}
onClick={() => runNow(flow)}
>
立即拉取
</button>
)}
<button
className="secondary-button compact-button"
disabled={busyId === flow.flow_id}
onClick={() => toggle(flow)}
>
{flow.enabled ? "暂停" : "启用"}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="panel full-panel ai-runs-panel">
<div className="panel-heading">
<div>
<h3>AI 执行记录</h3>
<p>结构化输出、工具调用和失败原因。</p>
</div>
<span className="table-count">最近 {runs.length} 条</span>
</div>
{runs.length === 0 ? (
<Empty text="暂无 AI 执行记录" />
) : (
<div className="ai-run-list">
{runs.map((run) => (
<article className="ai-run-card" key={run.run_id}>
<div className="ai-run-head">
<div>
<strong>
{flowNames[run.flow_id] || shortId(run.flow_id, 16)}
</strong>
<span>
{aiTargetSummary(run.target)} ·{" "}
{formatTime(run.created_at)}
</span>
</div>
<StatusBadge value={run.status} />
</div>
<div className="ai-run-content">
<div>
<small>输入</small>
<p>{run.messages?.[0]?.content || "无正文"}</p>
</div>
<div>
<small>结构化输出</small>
<pre>
{run.output
? JSON.stringify(run.output, null, 2)
: run.error || run.raw_output || "等待处理…"}
</pre>
</div>
</div>
{run.tool_calls?.length > 0 && (
<div className="ai-tool-trace">
Tools:
{run.tool_calls
.map((call) => `${call.name} (${call.status})`)
.join(" · ")}
</div>
)}
</article>
))}
</div>
)}
</section>
{showCreate && (
<AIFlowModal
nodes={nodes}
token={token}
onClose={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false);
onChanged();
}}
/>
)}
</>
);
}
function AIFlowModal({ nodes, token, onClose, onCreated }) {
const [nodeId, setNodeId] = useState(nodes[0]?.node_id || "");
const [accountId, setAccountId] = useState(nodes[0]?.active_account_id || "");
const [chatId, setChatId] = useState("");
const [chatType, setChatType] = useState("Private");
const [triggerType, setTriggerType] = useState("realtime");
const [intervalSeconds, setIntervalSeconds] = useState("60");
const [name, setName] = useState("消息分类与回复");
const [instruction, setInstruction] = useState(
"判断消息类别,并在需要时使用 reply_text 回复原会话。",
);
const [schemaText, setSchemaText] = useState(defaultAISchema);
const [autoReply, setAutoReply] = useState(true);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (event) => {
event.preventDefault();
setError("");
let schema;
try {
schema = JSON.parse(schemaText);
} catch {
setError("返回 Schema 不是有效 JSON。");
return;
}
if (!nodeId || !accountId || !chatId || !name || !instruction) {
setError("请填写流程名称、节点、账号和目标会话 ID。");
return;
}
setBusy(true);
try {
await api("/v1/ai/flows", {
token,
method: "POST",
body: {
name,
targets: [
{
node_id: nodeId,
account_id: accountId,
chat_id: chatId,
chat_type: chatType,
},
],
trigger:
triggerType === "realtime"
? { type: "realtime" }
: {
type: "interval",
interval_seconds: Number(intervalSeconds),
batch_limit: 20,
},
instruction,
output_schema: schema,
tools: autoReply
? ["read_message_context", "reply_text"]
: ["read_message_context"],
enabled: true,
},
});
onCreated();
} catch (reason) {
setError(reason.message);
} finally {
setBusy(false);
}
};
return (
<div
className="modal-backdrop"
onMouseDown={(event) => event.target === event.currentTarget && onClose()}
>
<div className="modal ai-modal">
<div className="modal-heading">
<div>
<div className="eyebrow">NEW AI PROCESSOR</div>
<h3>新建 AI 消息处理流程</h3>
<p>消息内容只会进入已授权流程;自动回复目标固定为来源会话。</p>
</div>
<button className="close-button" onClick={onClose}>
×
</button>
</div>
<form onSubmit={submit} className="task-form">
<label>
流程名称
<input
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={120}
/>
</label>
<div className="form-grid two">
<label>
节点
<select
value={nodeId}
onChange={(event) => {
setNodeId(event.target.value);
const selected = nodes.find(
(node) => node.node_id === event.target.value,
);
if (selected?.active_account_id)
setAccountId(selected.active_account_id);
}}
>
{nodes.map((node) => (
<option key={node.node_id} value={node.node_id}>
{node.node_id}
</option>
))}
</select>
</label>
<label>
微信账号 ID
<input
value={accountId}
onChange={(event) => setAccountId(event.target.value)}
placeholder="稳定 account_id"
/>
</label>
</div>
<div className="form-grid two">
<label>
会话类型
<select
value={chatType}
onChange={(event) => setChatType(event.target.value)}
>
<option value="Private">私聊</option>
<option value="Group">群聊</option>
</select>
</label>
<label>
稳定会话 ID
<input
value={chatId}
onChange={(event) => setChatId(event.target.value)}
placeholder="来自白名单会话"
/>
</label>
</div>
<div className="form-grid two">
<label>
触发方式
<select
value={triggerType}
onChange={(event) => setTriggerType(event.target.value)}
>
<option value="realtime">实时消息</option>
<option value="interval">定时拉取</option>
</select>
</label>
{triggerType === "interval" ? (
<label>
间隔(秒)
<input
type="number"
min="10"
max="86400"
value={intervalSeconds}
onChange={(event) => setIntervalSeconds(event.target.value)}
/>
</label>
) : (
<div />
)}
</div>
<label>
AI 处理指令
<textarea
value={instruction}
onChange={(event) => setInstruction(event.target.value)}
rows={4}
maxLength={8000}
/>
</label>
<label>
返回 JSON Schema(根节点必须是 object)
<textarea
className="json-editor"
value={schemaText}
onChange={(event) => setSchemaText(event.target.value)}
rows={10}
spellCheck="false"
/>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={autoReply}
onChange={(event) => setAutoReply(event.target.checked)}
/>
允许 AI 自动回复原群/私聊
</label>
{error && <div className="form-error">{error}</div>}
<div className="modal-actions">
<button
type="button"
className="secondary-button"
onClick={onClose}
>
返回
</button>
<button className="primary-button" disabled={busy || !nodes.length}>
{busy ? "创建中…" : "创建流程"}
</button>
</div>
</form>
</div>
</div>
);
}
function EventsView({ events }) {
const [query, setQuery] = useState("");
const visible = events.filter(
@@ -808,6 +1278,9 @@ function App() {
const [tasks, setTasks] = useState([]);
const [events, setEvents] = useState([]);
const [audit, setAudit] = useState([]);
const [aiFlows, setAiFlows] = useState([]);
const [aiRuns, setAiRuns] = useState([]);
const [aiProvider, setAiProvider] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const logout = useCallback(() => {
@@ -821,16 +1294,22 @@ function App() {
setLoading(true);
setError("");
try {
const [nodeData, taskData, eventData, auditData] = await Promise.all([
api("/v1/nodes", { token }),
api("/v1/tasks?limit=200", { token }),
api("/v1/events?limit=200", { token }),
api("/v1/audit?limit=200", { token }),
]);
const [nodeData, taskData, eventData, auditData, aiFlowData, aiRunData] =
await Promise.all([
api("/v1/nodes", { token }),
api("/v1/tasks?limit=200", { token }),
api("/v1/events?limit=200", { token }),
api("/v1/audit?limit=200", { token }),
api("/v1/ai/flows?limit=200", { token }),
api("/v1/ai/runs?limit=200", { token }),
]);
setNodes(nodeData.nodes || []);
setTasks(taskData.tasks || []);
setEvents(eventData.events || []);
setAudit(auditData.audit || []);
setAiFlows(aiFlowData.flows || []);
setAiRuns(aiRunData.runs || []);
setAiProvider(aiFlowData.provider || null);
} catch (reason) {
if (reason.status === 401) logout();
else setError(reason.message);
@@ -856,6 +1335,7 @@ function App() {
nodes: "查看节点心跳、微信登录状态与能力摘要",
tasks: "管理远程任务,所有执行均由节点本地能力边界约束",
events: "按账号与会话作用域查看已授权上报内容",
ai: "配置消息触发、结构化输出和受控自动回复",
audit: "追踪控制面与节点之间的关键操作",
}[view];
if (!token) return <Login onLogin={handleLogin} />;
@@ -900,6 +1380,16 @@ function App() {
/>
)}
{view === "events" && <EventsView events={events} />}
{view === "ai" && (
<AIView
nodes={nodes}
flows={aiFlows}
runs={aiRuns}
provider={aiProvider}
token={token}
onChanged={refresh}
/>
)}
{view === "audit" && <AuditView audit={audit} />}
</div>
<footer className="footer">
+171 -1
View File
@@ -861,6 +861,172 @@ nav {
10px "DM Mono",
monospace;
}
.provider-ok,
.provider-off,
.flow-state,
.trigger-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.provider-ok,
.flow-state.enabled {
color: #168a68;
background: #e7f8f2;
}
.provider-off,
.flow-state.disabled {
color: #9b6b27;
background: #fff4e3;
}
.provider-ok,
.provider-off {
min-height: 30px;
padding: 0 10px;
}
.ai-symbol {
display: grid;
place-items: center;
width: 29px;
height: 29px;
border-radius: 8px;
color: #7454d6;
background: #eeeaff;
font-size: 16px;
}
.trigger-pill,
.flow-state {
padding: 5px 8px;
}
.trigger-pill {
color: #5574b6;
background: #edf2ff;
}
.tool-list {
color: #68778d;
font-size: 10px;
}
.row-actions {
display: flex;
gap: 9px;
align-items: center;
justify-content: flex-end;
}
.compact-button {
height: 30px;
padding: 0 9px;
font-size: 10px;
}
.ai-table {
min-width: 980px;
}
.ai-runs-panel {
margin-top: 16px;
}
.ai-run-list {
display: grid;
gap: 10px;
}
.ai-run-card {
padding: 15px;
border: 1px solid #edf0f4;
border-radius: 10px;
background: #fbfcfe;
}
.ai-run-head {
display: flex;
gap: 15px;
align-items: center;
justify-content: space-between;
}
.ai-run-head strong,
.ai-run-head span {
display: block;
}
.ai-run-head strong {
color: #344156;
font-size: 12px;
}
.ai-run-head span {
margin-top: 5px;
color: #9aa5b4;
font-size: 10px;
}
.ai-run-content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 14px;
margin-top: 14px;
}
.ai-run-content > div {
min-width: 0;
padding: 11px;
border-radius: 8px;
background: #fff;
}
.ai-run-content small {
color: #9aa5b4;
font-size: 10px;
font-weight: 700;
}
.ai-run-content p {
margin: 8px 0 0;
color: #55647a;
font-size: 11px;
line-height: 1.6;
}
.ai-run-content pre {
max-height: 170px;
margin: 8px 0 0;
overflow: auto;
color: #52617a;
font:
10px / 1.55 "DM Mono",
monospace;
white-space: pre-wrap;
word-break: break-word;
}
.ai-tool-trace {
margin-top: 10px;
color: #8068c7;
font-size: 10px;
}
.ai-modal {
width: min(700px, 100%);
max-height: calc(100vh - 40px);
overflow: auto;
}
.form-grid {
display: grid;
gap: 14px;
}
.form-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.json-editor {
min-height: 180px;
color: #52617a;
background: #fbfcfe;
font:
11px / 1.55 "DM Mono",
monospace;
}
.checkbox-label {
display: flex;
grid-template-columns: none;
gap: 8px;
align-items: center;
color: #55647a;
font-size: 11px;
}
.checkbox-label input {
width: 15px;
height: 15px;
margin: 0;
}
@media (max-width: 1100px) {
.metric-grid {
@@ -898,7 +1064,7 @@ nav {
display: none;
}
nav {
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(6, 1fr);
}
.nav-item {
justify-content: center;
@@ -942,6 +1108,10 @@ nav {
margin-top: 12px;
font-size: 23px;
}
.ai-run-content,
.form-grid.two {
grid-template-columns: 1fr;
}
.panel,
.full-panel {
padding: 16px;