feat(control-plane): add web AI message processing
Build web service image / build (push) Successful in 48s
Build web service image / build (push) Successful in 48s
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user