Files
story/web/src/pages/OpsConfigWorkspace.tsx
T

353 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type FormEvent, useEffect, useMemo, useState } from "react";
import {
APIRequestError, platformApi, type CurrentUser, type OpsConfigEvaluation, type OpsConfigRollout,
type OpsConfigTransitionRequest, type OpsConfigVersion, type OpsDeviceGroup,
type OpsResourceReference
} from "../api/client";
type Kind = "PERSONA" | "VOICE" | "MODEL" | "ROUTING" | "AGENT_BUNDLE";
type Scope = "TENANT" | "SUBJECT" | "DEVICE_GROUP";
type BundleField = "persona_version_id" | "voice_version_id" | "model_version_id" | "routing_version_id";
type EvaluationDraft = { item: OpsConfigVersion; suiteVersion: string; reportRef: string; result: "" | "PASSED" | "FAILED"; acknowledged: boolean };
type ConfigControlDraft =
| { kind: "resource_revoke"; item: OpsResourceReference; reason: string; acknowledged: boolean }
| { kind: "rollout_pause" | "rollout_resume"; item: OpsConfigRollout; reason: string; acknowledged: boolean };
type VersionTransitionDraft =
| { kind: "submit"; item: OpsConfigVersion; acknowledged: boolean }
| { kind: "publish" | "rollback"; item: OpsConfigVersion; scopeType: Scope; scopeID: string; rolloutPercent: number; maxConcurrentCalls: number; reason: string; acknowledged: boolean };
const bundleFields: Array<{ field: BundleField; kind: Exclude<Kind, "AGENT_BUNDLE">; label: string }> = [
{ field: "persona_version_id", kind: "PERSONA", label: "人设版本" },
{ field: "voice_version_id", kind: "VOICE", label: "音色版本" },
{ field: "model_version_id", kind: "MODEL", label: "模型版本" },
{ field: "routing_version_id", kind: "ROUTING", label: "路由版本" }
];
const elderCompanionSystemPrompt = `你是一位温和、耐心、尊重长者的陪伴型语音助手。
你的目标不是快速获得答案,而是让长者愿意表达,并感到自己被认真倾听。
聊天规则:
1. 使用简单、自然、口语化的中文,每次尽量只说一到两句话。
2. 一次只问一个问题,不连续盘问;每连续提问两次后,加入一次共情、总结或简短回应。
3. 先回应长者的情绪,再继续聊天,优先围绕长者刚刚提到的人、事、物展开。
4. 长者回答很短时,可以给出两个简单选项帮助继续,但不要替长者作决定。
5. 长者沉默时不要催促,可以说“没关系,我们慢慢聊”。
6. 长者重复讲同一件事时,不要指出“已经讲过”,应接住其中的情绪或细节。
7. 不要编造没有提供过的家庭信息、经历、记忆或事实;不确定时明确表示不确定。
8. 不主动提起背景资料中标记为避免的话题;长者主动提及时先共情,再谨慎回应。
9. 涉及疾病、用药和症状时,只做一般性安全提醒,不代替医生诊断或擅自建议停药换药。
10. 长者想结束聊天时自然收尾,不强行挽留,也不为了延长对话制造话题。
11. 不纠正长者的口音、表达方式或生活选择,不使用网络流行语和复杂术语。
12. 没有合适话题时,可从天气、饮食、睡眠、花草、电视节目或家人近况中选择一个开场。
推荐流程:问候近况 -> 找到兴趣或记忆关键词 -> 温和追问一个细节 -> 回应情绪 -> 自然收尾。
表达风格应像熟悉的晚辈或朋友,不像客服、医生或采访者。`;
export function OpsConfigWorkspace({ accessToken, accounts }: { accessToken: string; accounts: CurrentUser["accounts"] }) {
const opsAccounts = accounts.filter((item) => item.platform_role !== "customer");
const [tenantID, setTenantID] = useState(opsAccounts[0]?.tenant_id ?? "");
const account = opsAccounts.find((item) => item.tenant_id === tenantID) ?? opsAccounts[0];
const [items, setItems] = useState<OpsConfigVersion[]>([]);
const [resourceRefs, setResourceRefs] = useState<OpsResourceReference[]>([]);
const [deviceGroups, setDeviceGroups] = useState<OpsDeviceGroup[]>([]);
const [rollouts, setRollouts] = useState<OpsConfigRollout[]>([]);
const [evaluations, setEvaluations] = useState<Record<string, OpsConfigEvaluation[]>>({});
const [evaluationDraft, setEvaluationDraft] = useState<EvaluationDraft>();
const [configControlDraft, setConfigControlDraft] = useState<ConfigControlDraft>();
const [versionTransitionDraft, setVersionTransitionDraft] = useState<VersionTransitionDraft>();
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const [message, setMessage] = useState<string>();
const [kind, setKind] = useState<Kind>("ROUTING");
const [configKey, setConfigKey] = useState("default-agent-routing");
const [displayName, setDisplayName] = useState("默认语音路由");
const [payloadText, setPayloadText] = useState(defaultPayload("ROUTING"));
const [secretRefsText, setSecretRefsText] = useState("{}");
const [summary, setSummary] = useState("");
const [resourceType, setResourceType] = useState<"PROVIDER_CREDENTIAL" | "VOICE_RESOURCE" | "MODEL_RESOURCE">("PROVIDER_CREDENTIAL");
const [referenceName, setReferenceName] = useState("");
const [referenceDisplayName, setReferenceDisplayName] = useState("");
const [groupName, setGroupName] = useState("");
const [groupDeviceIDs, setGroupDeviceIDs] = useState("");
const canDraft = account && ["ops_operator", "ops_approver", "ops_admin"].includes(account.platform_role);
const canApprove = account && ["ops_approver", "ops_admin"].includes(account.platform_role);
async function load(signal?: AbortSignal) {
if (!tenantID) return;
setEvaluationDraft(undefined);
setConfigControlDraft(undefined);
setVersionTransitionDraft(undefined);
setLoading(true); setError(undefined);
try {
const [versions, refs, deviceGroupResponse, rolloutResponse] = await Promise.all([
platformApi.opsConfigVersions(tenantID, accessToken, signal),
platformApi.opsResourceReferences(tenantID, accessToken, signal),
platformApi.opsDeviceGroups(tenantID, accessToken, signal),
platformApi.opsConfigRollouts(tenantID, accessToken, signal)
]);
setItems(versions.data.items); setResourceRefs(refs.data.items); setDeviceGroups(deviceGroupResponse.data.items); setRollouts(rolloutResponse.data.items);
} catch (cause) {
if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(errorText(cause));
} finally { if (!signal?.aborted) setLoading(false); }
}
useEffect(() => {
const controller = new AbortController(); void load(controller.signal);
return () => controller.abort();
}, [tenantID, accessToken]);
useEffect(() => {
setKind("ROUTING"); setConfigKey("default-agent-routing"); setDisplayName("默认语音路由");
setPayloadText(defaultPayload("ROUTING")); setSecretRefsText("{}"); setSummary("");
}, [tenantID]);
const versionGroups = useMemo(() => {
const result = new Map<string, OpsConfigVersion[]>();
for (const item of items) result.set(item.config_key, [...(result.get(item.config_key) ?? []), item]);
return [...result.entries()];
}, [items]);
const bundleCandidates = useMemo(() => {
const result = new Map<Exclude<Kind, "AGENT_BUNDLE">, OpsConfigVersion[]>();
for (const definition of bundleFields) {
result.set(definition.kind, componentCandidates(items, definition.kind));
}
return result;
}, [items]);
const bundlePayload = useMemo(() => readBundlePayload(payloadText), [payloadText]);
const unavailableBundleKinds = bundleFields.filter((definition) => !(bundleCandidates.get(definition.kind)?.length));
const bundleReady = bundleFields.every((definition) => {
const selected = bundlePayload[definition.field];
return Boolean(selected && bundleCandidates.get(definition.kind)?.some((candidate) => candidate.id === selected));
});
const controllableRollouts = rollouts.filter((item) => item.status === "ACTIVE" || item.status === "PAUSED");
function changeKind(next: Kind) {
setKind(next); setPayloadText(next === "AGENT_BUNDLE" ? defaultAgentBundlePayload(items) : defaultPayload(next));
setSecretRefsText(next === "VOICE" || next === "MODEL" ? JSON.stringify({ credential_ref: "kms://voice-platform/provider/local-dev" }, null, 2) : "{}");
const defaults: Record<Kind, [string, string]> = {
PERSONA: ["default-persona", "默认长者陪伴人设"], VOICE: ["default-voice", "默认语音音色"],
MODEL: ["default-model", "默认对话模型"], ROUTING: ["default-agent-routing", "默认语音路由"],
AGENT_BUNDLE: ["default-agent-bundle", "默认Agent配置组合"]
};
setConfigKey(defaults[next][0]); setDisplayName(defaults[next][1]);
}
async function create(event: FormEvent) {
event.preventDefault();
let payload: Record<string, unknown>; let secret_refs: Record<string, string>;
try { payload = JSON.parse(payloadText); secret_refs = JSON.parse(secretRefsText); }
catch { setError("配置正文和密钥引用必须是有效JSON对象。"); return; }
if (kind === "AGENT_BUNDLE" && !bundleReady) {
setError(unavailableBundleKinds.length ? `请先发布可用的${unavailableBundleKinds.map((item) => item.label).join("、")},再创建Agent配置组合。` : "请选择四个仍可用的已发布或已退役组件版本。");
return;
}
await run(async () => {
await platformApi.createOpsConfigVersion(tenantID, { config_key: configKey.trim(), display_name: displayName.trim(), config_kind: kind, payload, secret_refs, change_summary: summary.trim() }, accessToken);
setSummary(""); setMessage("新的不可变草稿已创建;提交后还需离线评测通过和另一名审批人员发布。"); await load();
});
}
function selectBundleComponent(field: BundleField, versionID: string) {
setPayloadText(JSON.stringify({ ...bundlePayload, [field]: versionID }, null, 2));
}
function openVersionTransition(kind: VersionTransitionDraft["kind"], item: OpsConfigVersion) {
setEvaluationDraft(undefined); setConfigControlDraft(undefined); setError(undefined); setMessage(undefined);
setVersionTransitionDraft(kind === "submit"
? { kind, item, acknowledged: false }
: { kind, item, scopeType: "TENANT", scopeID: "", rolloutPercent: 100, maxConcurrentCalls: 100, reason: "", acknowledged: false });
}
async function submitVersionTransition(event: FormEvent) {
event.preventDefault(); const draft = versionTransitionDraft;
if (!draft || !versionTransitionDraftReady(draft)) return;
await run(async () => {
if (draft.kind === "submit") {
await platformApi.submitOpsConfigVersion(tenantID, draft.item, accessToken);
} else {
const input: OpsConfigTransitionRequest = { reason: draft.reason.trim(), rollout: { scope_type: draft.scopeType, ...(draft.scopeType === "TENANT" ? {} : { scope_id: draft.scopeID }), rollout_percent: draft.scopeType === "SUBJECT" ? 100 : draft.rolloutPercent, max_concurrent_calls: draft.maxConcurrentCalls } };
if (draft.kind === "publish") await platformApi.publishOpsConfigVersion(tenantID, draft.item, input, accessToken);
else await platformApi.rollbackOpsConfigVersion(tenantID, draft.item, input, accessToken);
}
setMessage(draft.kind === "submit" ? "配置已通过结构检查并提交审批。" : draft.kind === "publish" ? "配置已按确认范围发布,只影响后续新建通话。" : "已按确认的历史内容和范围创建新的已发布回滚版本。"); await load();
});
}
function openEvaluation(item: OpsConfigVersion) {
if (item.config_kind === "AGENT_BUNDLE") { setError("Agent配置组合必须使用标准评测工具生成并登记AGENT_QUALITY_V1证据,运营页面不能手工填写通过结论。"); return; }
setConfigControlDraft(undefined); setVersionTransitionDraft(undefined); setEvaluationDraft({ item, suiteVersion: "", reportRef: "", result: "", acknowledged: false }); setError(undefined); setMessage(undefined);
}
async function addEvaluation(event: FormEvent) {
event.preventDefault(); const draft = evaluationDraft;
if (!draft || !evaluationDraftReady(draft)) return;
await run(async () => {
await platformApi.createOpsConfigEvaluation(tenantID, draft.item.id, { evidence_type: "GENERIC", suite_version: draft.suiteVersion.trim(), result: draft.result as "PASSED" | "FAILED", report_ref: draft.reportRef.trim(), metrics: { registered_from: "operations-workspace", gate: "runtime-config-publish" } }, accessToken);
const response = await platformApi.opsConfigEvaluations(tenantID, draft.item.id, accessToken);
setEvaluations((current) => ({ ...current, [draft.item.id]: response.data.items })); setEvaluationDraft(undefined);
setMessage(`已登记${draft.result === "PASSED" ? "通过" : "失败"}的离线评测证据。`);
});
}
async function loadEvaluations(configID: string) {
await run(async () => { const response = await platformApi.opsConfigEvaluations(tenantID, configID, accessToken); setEvaluations((current) => ({ ...current, [configID]: response.data.items })); }, false);
}
async function addResourceReference(event: FormEvent) {
event.preventDefault();
await run(async () => {
await platformApi.createOpsResourceReference(tenantID, { resource_type: resourceType, reference_name: referenceName.trim(), display_name: referenceDisplayName.trim(), verified_at: new Date().toISOString() }, accessToken);
setReferenceName(""); setReferenceDisplayName(""); setMessage("受控资源引用已登记;系统未接收或保存资源密钥明文。"); await load();
});
}
function openResourceRevoke(item: OpsResourceReference) {
setEvaluationDraft(undefined); setVersionTransitionDraft(undefined); setConfigControlDraft({ kind: "resource_revoke", item, reason: "", acknowledged: false }); setError(undefined); setMessage(undefined);
}
function openRolloutTransition(action: "pause" | "resume", item: OpsConfigRollout) {
setEvaluationDraft(undefined); setVersionTransitionDraft(undefined); setConfigControlDraft({ kind: action === "pause" ? "rollout_pause" : "rollout_resume", item, reason: "", acknowledged: false }); setError(undefined); setMessage(undefined);
}
async function submitConfigControl(event: FormEvent) {
event.preventDefault(); const draft = configControlDraft;
if (!draft || !configControlDraftReady(draft)) return;
await run(async () => {
if (draft.kind === "resource_revoke") {
await platformApi.revokeOpsResourceReference(tenantID, draft.item, draft.reason.trim(), accessToken);
setMessage("资源引用已撤销;后续依赖该引用的配置发布将被门禁阻断。");
} else if (draft.kind === "rollout_pause") {
await platformApi.pauseOpsConfigRollout(tenantID, draft.item, draft.reason.trim(), accessToken);
setMessage("配置投放已暂停,后续新建通话不再命中该范围。");
} else {
await platformApi.resumeOpsConfigRollout(tenantID, draft.item, draft.reason.trim(), accessToken);
setMessage("配置投放已恢复,后续新建通话可再次命中该范围。");
}
await load();
});
}
async function addDeviceGroup(event: FormEvent) {
event.preventDefault();
const device_ids = groupDeviceIDs.split(/[\s,,;;]+/).map((value) => value.trim()).filter(Boolean);
await run(async () => {
await platformApi.createOpsDeviceGroup(tenantID, { name: groupName.trim(), selector: { source: "manual" }, device_ids }, accessToken);
setGroupName(""); setGroupDeviceIDs(""); setMessage("设备灰度分组已创建,可在发布范围中选择。"); await load();
});
}
async function run(operation: () => Promise<void>, clearMessage = true) {
setBusy(true); setError(undefined); if (clearMessage) setMessage(undefined);
try { await operation(); } catch (cause) { setError(errorText(cause)); } finally { setBusy(false); }
}
if (!opsAccounts.length) return null;
return <section className="ops-workspace" aria-label="内部运营配置工作台">
<header className="ops-workspace__head"><div><p className="eyebrow">OPERATIONS CONTROL</p><h2>配置发布与版本控制</h2><p>发布前必须具备通过的离线评测;密钥、模型和音色仅保存经核验的受控引用。</p></div><label><span>运营客户组织</span><select value={tenantID} onChange={(event) => setTenantID(event.target.value)}>{opsAccounts.map((item) => <option key={item.user_id} value={item.tenant_id}>{item.tenant_name} · {opsRoleLabel(item.platform_role)}</option>)}</select></label></header>
{error ? <div className="notice notice--error" role="alert"><strong>操作未完成</strong><p>{error}</p></div> : null}
{message ? <div className="notice" role="status"><strong>操作成功</strong><p>{message}</p></div> : null}
{canDraft ? <div className="ops-gate-grid">
<form className="ops-gate-card" onSubmit={(event) => void addResourceReference(event)}><strong>受控资源引用</strong><p>引用须在180天内核验;不接收密钥正文。</p><label><span>资源类型</span><select value={resourceType} onChange={(event) => setResourceType(event.target.value as typeof resourceType)}><option value="PROVIDER_CREDENTIAL">供应商凭据</option><option value="VOICE_RESOURCE">音色资源</option><option value="MODEL_RESOURCE">模型资源</option></select></label><label><span>引用标识</span><input value={referenceName} onChange={(event) => setReferenceName(event.target.value)} placeholder="kms://provider/prod" required /></label><label><span>显示名称</span><input value={referenceDisplayName} onChange={(event) => setReferenceDisplayName(event.target.value)} required /></label><button type="submit" disabled={busy}>登记引用</button><small>当前有效引用:{resourceRefs.filter((item) => item.status === "ACTIVE").length}</small></form>
<form className="ops-gate-card" onSubmit={(event) => void addDeviceGroup(event)}><strong>设备灰度分组</strong><p>成员必须属于当前客户组织,跨租户编号会被拒绝。</p><label><span>分组名称</span><input value={groupName} onChange={(event) => setGroupName(event.target.value)} required /></label><label><span>设备公开编号</span><textarea rows={3} value={groupDeviceIDs} onChange={(event) => setGroupDeviceIDs(event.target.value)} placeholder="每行一个UUID,也可用逗号分隔" required /></label><button type="submit" disabled={busy}>创建设备组</button><small>当前有效设备组:{deviceGroups.filter((item) => item.status === "ACTIVE").length}</small></form>
</div> : null}
<div className="ops-ledger-grid">
<section className="ops-ledger"><div><strong>配置投放控制</strong><span>{controllableRollouts.filter((item) => item.status === "ACTIVE").length} 个活动 · {controllableRollouts.filter((item) => item.status === "PAUSED").length} 个暂停</span></div>{controllableRollouts.length ? <ul>{controllableRollouts.map((item) => <li key={item.id}><span><strong>{item.config_key}</strong><small>{scopeLabel(item.scope_type)}{item.scope_id ? ` · ${item.scope_id}` : ""} · {item.status === "ACTIVE" ? "投放中" : "已暂停"} · 行版本 v{item.row_version}</small></span><span>{item.rollout_percent}% · 并发≤{item.max_concurrent_calls}</span>{canApprove ? <button type="button" className={`button-small ${item.status === "ACTIVE" ? "button-danger" : ""}`} disabled={busy} onClick={() => openRolloutTransition(item.status === "ACTIVE" ? "pause" : "resume", item)}>{item.status === "ACTIVE" ? "暂停" : "恢复"}</button> : null}</li>)}</ul> : <p>尚无活动或暂停的配置投放。</p>}</section>
<section className="ops-ledger"><div><strong>资源引用状态</strong><span>{resourceRefs.length} 条</span></div>{resourceRefs.length ? <ul>{resourceRefs.map((item) => <li key={item.id}><span><strong>{item.display_name}</strong><small>{item.reference_name} · {item.status === "ACTIVE" ? "有效" : "已撤销"} · 行版本 v{item.row_version}</small></span>{item.status === "ACTIVE" && canDraft ? <button type="button" className="button-small button-danger" disabled={busy} onClick={() => openResourceRevoke(item)}>撤销</button> : null}</li>)}</ul> : <p>尚未登记资源引用。</p>}</section>
</div>
{configControlDraft ? <form className={`sensitive-access-request controlled-operation ops-config-control ${configControlDraft.kind === "rollout_resume" ? "" : "controlled-operation--danger"}`} aria-label="运营配置受控操作" onSubmit={(event) => void submitConfigControl(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">CONTROLLED CONFIG OPERATION</p><h3>{configControlTitle(configControlDraft.kind)}</h3></div><span className={`status ${configControlDraft.kind === "rollout_resume" ? "warning" : "danger"}`}>{configControlDraft.kind === "rollout_resume" ? "恢复前复核" : "高影响操作"}</span></div>{configControlDraft.kind === "resource_revoke" ? <p>资源 <strong>{configControlDraft.item.id}</strong> · {configControlDraft.item.display_name} · {configControlDraft.item.resource_type} · {configControlDraft.item.reference_name} · 当前有效 · 行版本 v{configControlDraft.item.row_version}。撤销只改变受控引用状态,不删除外部密钥,也不改写已冻结的历史会话。</p> : <p>投放 <strong>{configControlDraft.item.id}</strong> · {configControlDraft.item.config_key} · {scopeLabel(configControlDraft.item.scope_type)}{configControlDraft.item.scope_id ? ` ${configControlDraft.item.scope_id}` : ""} · {configControlDraft.item.status === "ACTIVE" ? "投放中" : "已暂停"} · {configControlDraft.item.rollout_percent}% · 并发≤{configControlDraft.item.max_concurrent_calls} · 行版本 v{configControlDraft.item.row_version}。已建立通话继续使用创建时冻结的配置。</p>}<label><span>{configControlDraft.kind === "resource_revoke" ? "撤销原因" : configControlDraft.kind === "rollout_pause" ? "暂停原因和观察依据" : "恢复原因和确认依据"}</span><textarea autoFocus required maxLength={500} value={configControlDraft.reason} onChange={(event) => setConfigControlDraft({ ...configControlDraft, reason: event.target.value })} placeholder={configControlReasonPlaceholder(configControlDraft.kind)} /></label><label className="controlled-operation__confirmation"><input type="checkbox" checked={configControlDraft.acknowledged} onChange={(event) => setConfigControlDraft({ ...configControlDraft, acknowledged: event.target.checked })} />{configControlAcknowledgement(configControlDraft.kind)}</label><div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={() => setConfigControlDraft(undefined)}>取消</button><button type="submit" className={configControlDraft.kind === "rollout_resume" ? "" : "button-danger"} disabled={busy || !configControlDraftReady(configControlDraft)}>{busy ? "正在处理…" : configControlSubmitLabel(configControlDraft.kind)}</button></div></form> : null}
{canDraft ? <form className="ops-config-form" onSubmit={(event) => void create(event)}>
<div className="ops-config-form__intro"><strong>新建不可变草稿版本</strong><span>提交时执行结构、必填字段、敏感键和密钥引用检查。</span></div>
<label><span>配置类型</span><select value={kind} onChange={(event) => changeKind(event.target.value as Kind)}><option value="PERSONA">人设</option><option value="VOICE">音色</option><option value="MODEL">模型</option><option value="ROUTING">语音路由</option><option value="AGENT_BUNDLE">配置组合</option></select></label>
<label><span>版本族配置键</span><input value={configKey} pattern="[a-z][a-z0-9_.-]{2,99}" onChange={(event) => setConfigKey(event.target.value)} required /><small>{kind === "AGENT_BUNDLE" ? "可使用业务自定义键;发布后按灰度范围自动参与新通话选择。" : "同一键下的版本形成连续、可回滚的发布历史。"}</small></label>
<label><span>显示名称</span><input value={displayName} maxLength={200} onChange={(event) => setDisplayName(event.target.value)} required /></label>
{kind === "AGENT_BUNDLE" ? <fieldset className="ops-bundle-composer ops-config-form__wide"><legend>组合组件版本</legend>{bundleFields.map((definition) => {
const candidates = bundleCandidates.get(definition.kind) ?? [];
return <label key={definition.field}><span>{definition.label}</span><select value={bundlePayload[definition.field]} disabled={!candidates.length} required onChange={(event) => selectBundleComponent(definition.field, event.target.value)}><option value="">{candidates.length ? "请选择已发布版本" : "暂无可用版本"}</option>{candidates.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.display_name} · {candidate.config_key} v{candidate.version_no}{candidate.status === "RETIRED" ? " · 已退役(可追溯复用)" : " · 已发布"}</option>)}</select></label>;
})}<small className={unavailableBundleKinds.length ? "ops-bundle-composer__warning" : ""}>{unavailableBundleKinds.length ? `缺少可用组件:${unavailableBundleKinds.map((item) => item.label).join("、")}。请先完成对应配置的审批发布。` : "仅列出当前客户组织内已经发布过的不可变版本;提交和发布时服务端仍会复核租户、类型与状态。"}</small></fieldset> : <label className="ops-config-form__wide"><span>配置正文(JSON)</span><textarea rows={kind === "PERSONA" ? 18 : 8} value={payloadText} onChange={(event) => setPayloadText(event.target.value)} spellCheck={false} />{kind === "PERSONA" ? <small>默认模板包含倾听、单问题追问、沉默处理、事实边界、敏感话题和医疗安全规则;创建后仍需完成评测、审批和灰度发布。</small> : null}</label>}
{kind === "AGENT_BUNDLE" ? <p className="ops-config-form__wide ops-config-form__note">组合不直接保存密钥引用,模型和音色凭据由各自已发布组件管理。</p> : <label className="ops-config-form__wide"><span>受控密钥引用(JSON,不得填写密钥明文)</span><textarea rows={3} value={secretRefsText} onChange={(event) => setSecretRefsText(event.target.value)} spellCheck={false} /></label>}
<label className="ops-config-form__wide"><span>变更说明</span><input value={summary} maxLength={500} onChange={(event) => setSummary(event.target.value)} placeholder="说明变更目的、预期影响和验证依据" required /></label>
<button type="submit" disabled={busy || !summary.trim() || !configKey.trim() || !displayName.trim() || (kind === "AGENT_BUNDLE" && !bundleReady)}>创建草稿</button>
</form> : <p className="workspace__readonly-note">当前运营角色只能查看版本历史,不能创建配置。</p>}
<div className="ops-version-list"><div className="section-title"><h3>配置版本历史</h3><span>{items.length}</span></div>
{loading ? <p className="empty-copy">正在加载配置版本…</p> : versionGroups.length ? versionGroups.map(([key, versions]) => <section className="ops-version-group" key={key}><div><strong>{versions[0].display_name}</strong><span>{key} · {configKindLabel(versions[0].config_kind)}</span></div>{versions.map((item, index) => <article className="ops-version" key={item.id}>
<div className="ops-version__summary"><strong>v{item.version_no} · {configStatusLabel(item.status)}</strong><span>{item.change_summary}</span><small>行版本 v{item.row_version} · {formatDate(item.updated_at)}</small></div>
<details><summary>查看配置与版本差异</summary><div className="ops-diff"><pre>{JSON.stringify(index < versions.length - 1 ? versions[index + 1].payload : {}, null, 2)}</pre><pre>{JSON.stringify(item.payload, null, 2)}</pre></div><p>密钥引用:{Object.values(item.secret_refs).join("、") || "无"}</p></details>
{evaluations[item.id] ? <ul className="ops-evidence-list">{evaluations[item.id].map((evaluation) => <li key={evaluation.id}><strong>{evaluation.result === "PASSED" ? "通过" : "失败"}</strong><span>{evaluation.evidence_type === "AGENT_QUALITY_V1" ? "Agent标准质量" : "通用离线"} · {evaluation.suite_version} · {evaluation.report_ref}</span></li>)}</ul> : null}
<div className="ops-version__actions"><button type="button" className="button-small" disabled={busy} onClick={() => void loadEvaluations(item.id)}>评测证据</button>{(item.status === "DRAFT" || item.status === "PENDING_APPROVAL") && canDraft && item.config_kind !== "AGENT_BUNDLE" ? <button type="button" className="button-small" disabled={busy} onClick={() => openEvaluation(item)}>登记通用离线评测</button> : null}{item.status === "DRAFT" && canDraft ? <button type="button" className="button-small" disabled={busy} onClick={() => openVersionTransition("submit", item)}>提交审批</button> : null}{item.status === "PENDING_APPROVAL" && canApprove ? <button type="button" className="button-small" disabled={busy} onClick={() => openVersionTransition("publish", item)}>审批并发布</button> : null}{item.status === "RETIRED" && canApprove ? <button type="button" className="button-small button-danger" disabled={busy} onClick={() => openVersionTransition("rollback", item)}>回滚到此内容</button> : null}</div>
{versionTransitionDraft?.item.id === item.id ? <form className={`sensitive-access-request controlled-operation ops-version-transition ${versionTransitionDraft.kind === "rollback" ? "controlled-operation--danger" : ""}`} aria-label="配置版本受控流转" onSubmit={(event) => void submitVersionTransition(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">CONTROLLED VERSION TRANSITION</p><h4>{versionTransitionTitle(versionTransitionDraft.kind)}</h4></div><span className={`status ${versionTransitionDraft.kind === "rollback" ? "danger" : "warning"}`}>{versionTransitionDraft.kind === "submit" ? "进入审批" : versionTransitionDraft.kind === "publish" ? "双人审批" : "历史内容回滚"}</span></div><p>配置 <strong>{item.id}</strong> · {configKindLabel(item.config_kind)} · {item.config_key} v{item.version_no} · {configStatusLabel(item.status)} · 行版本 v{item.row_version} · 创建人 {item.created_by}。变更说明:{item.change_summary}</p>{versionTransitionDraft.kind === "submit" ? <p className="recording-notice">本操作只把不可变草稿送入审批,不会发布配置或改变现有投放;提交后必须由另一名具备审批权限的人员完成发布。</p> : <div className="ops-rollout-panel ops-version-transition__scope"><div><strong>本次确认的发布范围</strong><p>范围参数只属于当前固定版本;切换目标或取消后不会保留为其他版本的隐式参数。</p></div><label><span>范围类型</span><select value={versionTransitionDraft.scopeType} onChange={(event) => { const scopeType = event.target.value as Scope; setVersionTransitionDraft({ ...versionTransitionDraft, scopeType, scopeID: "", rolloutPercent: scopeType === "SUBJECT" ? 100 : versionTransitionDraft.rolloutPercent, acknowledged: false }); }}><option value="TENANT">整个客户组织</option><option value="DEVICE_GROUP">设备组</option><option value="SUBJECT">单个试验对象</option></select></label>{versionTransitionDraft.scopeType === "DEVICE_GROUP" ? <label><span>设备组</span><select required value={versionTransitionDraft.scopeID} onChange={(event) => setVersionTransitionDraft({ ...versionTransitionDraft, scopeID: event.target.value, acknowledged: false })}><option value="">请选择</option>{deviceGroups.filter((group) => group.status === "ACTIVE").map((group) => <option key={group.id} value={group.id}>{group.name} · {group.device_ids.length}台</option>)}</select></label> : versionTransitionDraft.scopeType === "SUBJECT" ? <label><span>试验对象公开编号</span><input required value={versionTransitionDraft.scopeID} onChange={(event) => setVersionTransitionDraft({ ...versionTransitionDraft, scopeID: event.target.value.trim(), acknowledged: false })} placeholder="UUID" /></label> : null}<label><span>灰度比例(%)</span><input type="number" min={1} max={100} value={versionTransitionDraft.scopeType === "SUBJECT" ? 100 : versionTransitionDraft.rolloutPercent} disabled={versionTransitionDraft.scopeType === "SUBJECT"} onChange={(event) => setVersionTransitionDraft({ ...versionTransitionDraft, rolloutPercent: Number(event.target.value), acknowledged: false })} /></label><label><span>并发影响上限</span><input type="number" min={1} max={100000} value={versionTransitionDraft.maxConcurrentCalls} onChange={(event) => setVersionTransitionDraft({ ...versionTransitionDraft, maxConcurrentCalls: Number(event.target.value), acknowledged: false })} /></label><label className="ops-rollout-panel__reason"><span>{versionTransitionDraft.kind === "publish" ? "审批发布依据" : "回滚依据和故障说明"}</span><textarea autoFocus required maxLength={500} value={versionTransitionDraft.reason} onChange={(event) => setVersionTransitionDraft({ ...versionTransitionDraft, reason: event.target.value, acknowledged: false })} placeholder={versionTransitionDraft.kind === "publish" ? "填写评测复核、审批依据、影响范围和观察计划" : "填写故障现象、历史版本选择依据、影响范围和回退观察计划"} /></label></div>}<label className="controlled-operation__confirmation"><input autoFocus={versionTransitionDraft.kind === "submit"} type="checkbox" checked={versionTransitionDraft.acknowledged} onChange={(event) => setVersionTransitionDraft({ ...versionTransitionDraft, acknowledged: event.target.checked })} />{versionTransitionAcknowledgement(versionTransitionDraft)}</label><div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={() => setVersionTransitionDraft(undefined)}>取消</button><button type="submit" className={versionTransitionDraft.kind === "rollback" ? "button-danger" : ""} disabled={busy || !versionTransitionDraftReady(versionTransitionDraft)}>{busy ? "正在处理…" : versionTransitionSubmitLabel(versionTransitionDraft.kind)}</button></div></form> : null}
{evaluationDraft?.item.id === item.id ? <form className="sensitive-access-request controlled-operation ops-evaluation-editor" aria-label="通用离线评测登记" onSubmit={(event) => void addEvaluation(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">GENERIC OFFLINE EVALUATION</p><h4>登记通用离线评测证据</h4></div><span className="status warning">不会替代Agent标准评测</span></div><p>配置 <strong>{item.id}</strong> · {configKindLabel(item.config_kind)} · {item.config_key} v{item.version_no} · 行版本 v{item.row_version}。本表单只登记通用证据,Agent配置组合和标准质量报告必须使用受控评测工具。</p><label><span>评测套件版本</span><input autoFocus required maxLength={100} value={evaluationDraft.suiteVersion} onChange={(event) => setEvaluationDraft({ ...evaluationDraft, suiteVersion: event.target.value })} placeholder="如 voice-routing-regression-v3" /></label><label><span>评测报告引用</span><input required maxLength={300} value={evaluationDraft.reportRef} onChange={(event) => setEvaluationDraft({ ...evaluationDraft, reportRef: event.target.value })} placeholder="填写可追溯的报告编号或受控存储引用" /></label><fieldset><legend>评测结论</legend><label><input type="radio" name={`evaluation-result-${item.id}`} checked={evaluationDraft.result === "PASSED"} onChange={() => setEvaluationDraft({ ...evaluationDraft, result: "PASSED", acknowledged: false })} />通过</label><label><input type="radio" name={`evaluation-result-${item.id}`} checked={evaluationDraft.result === "FAILED"} onChange={() => setEvaluationDraft({ ...evaluationDraft, result: "FAILED", acknowledged: false })} />失败</label></fieldset>{evaluationDraft.result === "PASSED" ? <label className="controlled-operation__confirmation"><input type="checkbox" checked={evaluationDraft.acknowledged} onChange={(event) => setEvaluationDraft({ ...evaluationDraft, acknowledged: event.target.checked })} />我确认报告内容和指标支持“通过”结论,并理解该记录可能参与当前配置的发布门禁</label> : null}<div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={() => setEvaluationDraft(undefined)}>取消</button><button type="submit" disabled={busy || !evaluationDraftReady(evaluationDraft)}>{busy ? "正在登记…" : "登记评测证据"}</button></div></form> : null}
{item.config_kind === "AGENT_BUNDLE" && (item.status === "DRAFT" || item.status === "PENDING_APPROVAL") ? <small>Agent配置组合只接受由固定评测集、观测文件和报告共同校验后登记的标准质量证据。</small> : null}
</article>)}</section>) : <p className="empty-copy">当前客户组织尚未创建运行配置版本。</p>}
</div>
</section>;
}
function defaultPayload(kind: Kind) {
const values: Record<Kind, unknown> = {
PERSONA: { system_prompt: elderCompanionSystemPrompt, tone: "warm" },
VOICE: { provider: "volcengine", voice_resource_ref: "voice://approved/default" }, MODEL: { provider: "volcengine", model_id: "model://approved/default" },
ROUTING: { default_engine_mode: "s2s_hybrid", routes: [{ goal: "open_conversation", engine_mode: "s2s_hybrid" }] },
AGENT_BUNDLE: { persona_version_id: "", voice_version_id: "", model_version_id: "", routing_version_id: "" }
};
return JSON.stringify(values[kind], null, 2);
}
function readBundlePayload(value: string): Record<BundleField, string> {
const empty: Record<BundleField, string> = { persona_version_id: "", voice_version_id: "", model_version_id: "", routing_version_id: "" };
try {
const parsed = JSON.parse(value) as Record<string, unknown>;
for (const definition of bundleFields) if (typeof parsed[definition.field] === "string") empty[definition.field] = String(parsed[definition.field]);
} catch { /* The create handler owns user-facing JSON errors for non-structured payloads. */ }
return empty;
}
function defaultAgentBundlePayload(items: OpsConfigVersion[]) {
const selected = readBundlePayload(defaultPayload("AGENT_BUNDLE"));
for (const definition of bundleFields) {
const candidate = componentCandidates(items, definition.kind)[0];
selected[definition.field] = candidate?.id ?? "";
}
return JSON.stringify(selected, null, 2);
}
function componentCandidates(items: OpsConfigVersion[], kind: Exclude<Kind, "AGENT_BUNDLE">) {
return items.filter((item) => item.config_kind === kind && (item.status === "PUBLISHED" || item.status === "RETIRED")).sort((left, right) => {
if (left.status !== right.status) return left.status === "PUBLISHED" ? -1 : 1;
const publishedOrder = new Date(right.published_at ?? right.updated_at).getTime() - new Date(left.published_at ?? left.updated_at).getTime();
return publishedOrder || right.version_no - left.version_no || left.config_key.localeCompare(right.config_key);
});
}
function evaluationDraftReady(draft: EvaluationDraft) { return Boolean(draft.suiteVersion.trim() && draft.reportRef.trim() && draft.result && (draft.result !== "PASSED" || draft.acknowledged)); }
function configControlDraftReady(draft: ConfigControlDraft) { return Boolean(draft.reason.trim() && draft.acknowledged); }
function versionTransitionDraftReady(draft: VersionTransitionDraft) {
if (!draft.acknowledged) return false;
if (draft.kind === "submit") return true;
return Boolean(draft.reason.trim() && (draft.scopeType === "TENANT" || draft.scopeID.trim()) && draft.rolloutPercent >= 1 && draft.rolloutPercent <= 100 && draft.maxConcurrentCalls >= 1 && draft.maxConcurrentCalls <= 100000);
}
function versionTransitionTitle(kind: VersionTransitionDraft["kind"]) { return ({ submit: "提交配置版本审批", publish: "审批并发布配置版本", rollback: "回滚到历史配置内容" } as const)[kind]; }
function versionTransitionSubmitLabel(kind: VersionTransitionDraft["kind"]) { return ({ submit: "确认提交审批", publish: "确认审批并发布", rollback: "确认创建回滚版本" } as const)[kind]; }
function versionTransitionAcknowledgement(draft: VersionTransitionDraft) {
if (draft.kind === "submit") return "我确认目标版本、变更说明和配置内容已经复核;提交只进入审批,不会直接影响现有或后续通话";
const range = `${scopeLabel(draft.scopeType)}${draft.scopeID ? ` ${draft.scopeID}` : ""}、${draft.scopeType === "SUBJECT" ? 100 : draft.rolloutPercent}%灰度、并发上限${draft.maxConcurrentCalls}`;
return draft.kind === "publish"
? `我确认离线评测、审批职责和发布范围(${range})均已复核;发布只影响后续新建通话,既有通话继续使用冻结配置`
: `我确认已核对目标历史内容和回滚范围(${range}),理解系统会创建新的不可变版本并只影响后续新建通话,且已准备观察和再次回退方案`;
}
function configControlTitle(kind: ConfigControlDraft["kind"]) { return ({ resource_revoke: "撤销受控资源引用", rollout_pause: "暂停配置投放", rollout_resume: "恢复配置投放" } as const)[kind]; }
function configControlSubmitLabel(kind: ConfigControlDraft["kind"]) { return ({ resource_revoke: "确认撤销引用", rollout_pause: "确认暂停投放", rollout_resume: "确认恢复投放" } as const)[kind]; }
function configControlReasonPlaceholder(kind: ConfigControlDraft["kind"]) { return ({ resource_revoke: "说明撤销依据、替代资源和影响范围", rollout_pause: "说明异常现象、暂停依据和观察计划", rollout_resume: "说明问题已消除的证据、恢复范围和观察计划" } as const)[kind]; }
function configControlAcknowledgement(kind: ConfigControlDraft["kind"]) { return ({ resource_revoke: "我确认后续依赖该引用的配置发布将被阻断,并已核对替代资源;本操作不会删除外部密钥或改写历史会话", rollout_pause: "我确认该投放将不再被后续新建通话选中;已建立通话仍继续使用冻结配置", rollout_resume: "我确认目标配置仍可发布、同范围没有其他活动投放,并已准备恢复后的观察和回退方案" } as const)[kind]; }
function errorText(value: unknown) { return value instanceof APIRequestError || value instanceof Error ? value.message : "未知错误"; }
function opsRoleLabel(value: string) { return ({ ops_viewer: "运营只读", ops_operator: "运营操作", ops_approver: "配置审批", ops_admin: "平台管理员" } as Record<string, string>)[value] ?? value; }
function configKindLabel(value: string) { return ({ PERSONA: "人设", VOICE: "音色", MODEL: "模型", ROUTING: "路由", AGENT_BUNDLE: "配置组合" } as Record<string, string>)[value] ?? value; }
function configStatusLabel(value: string) { return ({ DRAFT: "草稿", PENDING_APPROVAL: "待审批", PUBLISHED: "已发布", RETIRED: "已退役" } as Record<string, string>)[value] ?? value; }
function scopeLabel(value: string) { return ({ TENANT: "客户组织", SUBJECT: "试验对象", DEVICE_GROUP: "设备组" } as Record<string, string>)[value] ?? value; }
function formatDate(value: string) { return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(value)); }