修复云端角色版 mini-openclaw 访问

This commit is contained in:
NathanAlex7
2026-09-21 12:31:35 +08:00
parent aec52ecdf6
commit 4732cee85e
7 changed files with 76 additions and 19 deletions
+1 -1
View File
@@ -313,7 +313,7 @@ def business_command(payload: CommandInput, request: Request):
message = payload.message.strip()
current_role = request.state.identity["role"]
key = request.headers.get("idempotency-key", "")
if any(word in message for word in ("预审", "评审", "检查", "复核")) and not any(word in message for word in ("查看", "还缺", "多少", "状态", "生成", "报告", "提交")):
if any(word in message for word in ("预审", "评审", "检查", "复核", "研判")) and not any(word in message for word in ("查看", "还缺", "多少", "状态", "生成", "报告", "提交")):
plan_id = "corporate.independent-review" if current_role == "reviewer" else "corporate.pre-review"
run = translate(lambda: svc.enqueue(payload.project_id, request.state.identity, message, plan_id, key, project_body["revision"]))
return {"intent": "start_run", "message": ("独立评审" if current_role == "reviewer" else "AI预审") + "已进入队列。", "object_id": run["id"], "url": f"/xingbang-workbench?project={payload.project_id}&run={run['id']}"}
+12
View File
@@ -351,6 +351,18 @@ class ProjectAssistantTests(unittest.TestCase):
self.assertEqual(returned["next_action"], "查看退回要求并补充材料")
self.assertTrue(returned["returned_at"])
def test_role_scoped_miniclaw_understands_risk_assessment_phrase(self):
env = {"XINGBANG_DEMO_ROLE_LOGIN": "1", "XINGBANG_DEMO_PROJECTS": "DEMO-A,DEMO-B,DEMO-C"}
with patch.dict(os.environ, env):
response = self.post(
"/commands",
{"project_id": "DEMO-A", "message": "帮我做一次项目风险研判"},
headers={"X-Xingbang-Demo-Role": "manager", "Idempotency-Key": "cloud-mini-openclaw-risk"},
)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["intent"], "start_run")
self.assertEqual(self.service.resource("run", response.json()["object_id"])["run_kind"], "pre_review")
def test_conversation_read_questions_receive_distinct_answers(self):
env = {"XINGBANG_DEMO_ROLE_LOGIN": "1", "XINGBANG_DEMO_PROJECTS": "DEMO-A,DEMO-B,DEMO-C"}
headers = {"X-Xingbang-Demo-Role": "manager"}
+38
View File
@@ -0,0 +1,38 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const assistant = read("src/components/xingbang/BusinessAssistant.tsx");
const message = read("src/components/chat/ChatMessage.tsx");
const coreProxy = read("src/app/api/core/[...path]/route.ts");
assert.match(
assistant,
/setNativeAccess\(body\.identity\?\.role==="admin"&&!!sessionStorage\.getItem\("xingbang-access-token"\)\)/,
"只有带正式令牌的管理员才能进入原版通用工具工作台",
);
assert.match(
assistant,
/nativeAccess\?<AppProvider><ChatPanel\/><\/AppProvider>:conversation/,
"客户经理和评审员应回落到受角色约束的项目会话,而不是请求管理员接口",
);
assert.match(
assistant,
/当前身份只会调用本项目授权的 Skills、规则和业务工具/,
"受限工作台应明确说明权限边界",
);
assert.match(
message,
/authentication\.\?fail[\s\S]*return "provider";[\s\S]*\/401\|unauthorized[\s\S]*return "identity";/,
"401 身份错误不能继续误报为模型 API Key 失败",
);
assert.match(
coreProxy,
/user\.role !== "admin"/,
"修复不得放开原版终端、文件和网页工具的管理员边界",
);
console.log("Cloud mini-openclaw role access checks passed");
+1
View File
@@ -210,6 +210,7 @@ def main() -> int:
("backend-unit", [project_python(), "-m", "unittest", "discover", "-s", ".", "-p", "test_*.py", "-v"], BACKEND),
("reviewer-workspace-ui", [executable("node"), "scripts/test_reviewer_workspace.cjs"], ROOT),
("identity-recovery-ui", [executable("node"), "scripts/test_identity_recovery.cjs"], ROOT),
("cloud-assistant-access-ui", [executable("node"), "scripts/test_cloud_assistant_access.cjs"], ROOT),
("ui-readability", [executable("node"), "scripts/test_ui_readability.cjs"], ROOT),
("frontend-build", [npm, "run", "build"], ROOT),
("compose-local-config", [docker, "compose", "-f", "docker-compose.yml", "config", "--quiet"], ROOT),
+15 -12
View File
@@ -18,14 +18,16 @@ function formatTime(ts: number): string {
return d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
}
/** Detect 401 / API key errors */
function isAuthError(content: string): boolean {
return /401|authentication.?fail|invalid.*api.?key|api.?key.*invalid/i.test(content);
/** Keep provider-key failures separate from workbench identity failures. */
function authErrorKind(content: string): "provider" | "identity" | null {
if (/authentication.?fail|invalid.*api.?key|api.?key.*invalid|incorrect.*api.?key/i.test(content)) return "provider";
if (/401|unauthorized|访问身份|管理员访问身份|访问令牌/i.test(content)) return "identity";
return null;
}
export default function ChatMessage({ message }: Props) {
const isUser = message.role === "user";
const hasAuthError = !isUser && isAuthError(message.content);
const errorKind = !isUser ? authErrorKind(message.content) : null;
return (
<div className="animate-fade-in px-4 py-1.5">
@@ -62,8 +64,8 @@ export default function ChatMessage({ message }: Props) {
)}
{/* Auth error alert */}
{hasAuthError ? (
<AuthErrorAlert content={message.content} />
{errorKind ? (
<AuthErrorAlert content={message.content} kind={errorKind} />
) : message.content ? (
<div>
<div className="bg-white/60 px-4 py-2.5 rounded-2xl rounded-tl-md text-[14px] leading-relaxed shadow-[0_1px_3px_rgba(0,0,0,0.04)] border border-black/[0.04]">
@@ -102,28 +104,29 @@ export default function ChatMessage({ message }: Props) {
);
}
/** Prominent auth error alert with setup guidance */
function AuthErrorAlert({ content }: { content: string }) {
/** Prominent auth error alert with guidance for the failing layer. */
function AuthErrorAlert({ content, kind }: { content: string; kind: "provider" | "identity" }) {
const provider = kind === "provider";
return (
<div className="animate-fade-in-scale rounded-xl border border-red-200 bg-red-50/80 px-4 py-3 space-y-2">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-red-500 shrink-0" />
<span className="text-[13px] font-semibold text-red-700">
API Key 认证失败
{provider ? "模型 API Key 认证失败" : "当前访问身份未通过"}
</span>
</div>
<p className="text-[12px] text-red-600/80 leading-relaxed">
你的 API Key 无效或未配置。请检查 <code className="bg-red-100 px-1 rounded text-red-700">backend/.env</code> 文件中的配置。
{provider ? <>模型服务拒绝了当前 Key,请由配置管理员检查模型配置。</> : <>当前身份不能使用原版通用工具。请返回业务助手使用当前角色的项目工具,或由管理员连接正式访问身份。</>}
</p>
<div className="flex items-center gap-3 pt-1">
<a
href="/operations-center?domain=current&view=health"
href={provider ? "/operations-center?domain=current&view=health" : "/data-access"}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] font-medium text-red-600 hover:text-red-800 transition-colors"
>
<Key className="w-3 h-3" />
检查后端状态
{provider ? "检查模型状态" : "检查访问身份"}
</a>
<span className="text-[10px] text-red-400">|</span>
<span className="text-[10px] text-red-500 font-mono">{content.slice(0, 120)}...</span>
@@ -21,13 +21,14 @@ export default function BusinessAssistant(){
const [projects,setProjects]=useState<any[]>([]),[identity,setIdentity]=useState<any>(null),[projectId,setProjectId]=useState("");
const [input,setInput]=useState(""),[busy,setBusy]=useState(false),[error,setError]=useState("");
const [mode,setMode]=useState<"business"|"openclaw">("business");
const [nativeAccess,setNativeAccess]=useState(false);
const [messages,setMessages]=useState<Message[]>([{id:"hello",side:"assistant",text:"你好,我是兴邦对公业务助手。选择项目后,你可以直接告诉我查状态、发起AI预审、生成报告或补充材料。"}]);
const role=identity?.role||"manager",project=projects.find(p=>p.id===projectId);
const quick=useMemo(()=>role==="reviewer"?["查看项目状态","发起独立评审","生成评审报告"]:role==="admin"?["查看项目状态"]:["查看项目状态","发起AI预审","生成预审报告","提交正式评审"],[role]);
async function load(){
setError("");
try{const response=await fetch(`${ROOT}/projects`,{headers:headers()});const body=await response.json();if(!response.ok)throw Error(body.detail||"身份或项目读取失败");setProjects(body.projects);setIdentity(body.identity);const requested=new URLSearchParams(location.search).get("project");setProjectId(current=>current||(requested&&body.projects.some((p:any)=>p.id===requested)?requested:body.projects[0]?.id||""));}
try{const response=await fetch(`${ROOT}/projects`,{headers:headers()});const body=await response.json();if(!response.ok)throw Error(body.detail||"身份或项目读取失败");setProjects(body.projects);setIdentity(body.identity);setNativeAccess(body.identity?.role==="admin"&&!!sessionStorage.getItem("xingbang-access-token"));const requested=new URLSearchParams(location.search).get("project");setProjectId(current=>current||(requested&&body.projects.some((p:any)=>p.id===requested)?requested:body.projects[0]?.id||""));}
catch(e:any){setError(e.message)}
}
useEffect(()=>{load()},[]);
@@ -47,16 +48,17 @@ export default function BusinessAssistant(){
finally{setBusy(false)}
}
function submit(event:FormEvent){event.preventDefault();send(input)}
const conversation=<div className="ba-chat"><div className="ba-messages">{messages.map(message=><article key={message.id} className={message.side}><div>{message.side==="assistant"&&<Bot size={16}/>}<p>{message.text}</p>{message.side==="user"&&<span className="ba-user-icon" aria-label="本人"><UserRound size={17}/></span>}</div>{message.objectId&&<small>操作凭证 · {message.objectId}</small>}{message.url&&<a href={message.url}>查看项目中的结果<ArrowUpRight size={14}/></a>}</article>)}{busy&&<article className="assistant pending"><div><Bot size={16}/><p>正在通过 mini-openclaw 执行并记录…</p></div></article>}</div>
{error&&<div role="alert" className="ba-error">{error}</div>}
<form onSubmit={submit}><label className="ba-attach" title="补充项目材料"><Paperclip size={18}/><input aria-label="补充项目材料" type="file" accept=".txt,.csv,.docx,.xlsx,.pdf,.png,.jpg,.jpeg" onChange={attach}/></label><input aria-label="业务指令" value={input} onChange={e=>setInput(e.target.value)} placeholder={`例如:帮我做一次 ${projectId||"当前项目"} 风险研判`}/><button aria-label="发送业务指令" disabled={busy||!input.trim()||!projectId}><Send size={18}/></button></form><footer><FilePlus2 size={13}/>当前身份只会调用本项目授权的 Skills、规则和业务工具,并保留操作凭证。</footer>
</div>;
return <div className="ba-shell"><AgentNav active="assistant" project={projectId} identity={identity}/><main className="ba-main">
<header className="ba-heading"><div><p>MINI-OPENCLAW BUSINESS ENTRY</p><h1>一句话,推进对公业务</h1><span>对话和页面按钮共用同一套 Skills、规则、权限与留痕。</span></div><IdentitySelector identity={identity}/></header>
<nav className="ba-mode-tabs" aria-label="助手模式"><button className={mode==="business"?"active":""} onClick={()=>setMode("business")}><Sparkles size={15}/>业务快捷助手</button><button className={mode==="openclaw"?"active":""} onClick={()=>setMode("openclaw")}><MessagesSquare size={15}/>mini-openclaw</button></nav>
<section className="ba-context"><div><span>正在处理</span><select aria-label="选择业务项目" value={projectId} onChange={e=>setProjectId(e.target.value)}>{projects.map(p=><option key={p.id} value={p.id}>{p.id} · {p.customer}</option>)}</select></div>{project&&<><div><span>当前阶段</span><b>{project.stage}</b></div><div><span>下一步</span><b>{project.next_action}</b></div><a href={`/xingbang-workbench?project=${encodeURIComponent(projectId)}`}>进入项目<ArrowUpRight size={15}/></a></>}</section>
{mode==="business"?<section className="ba-workspace"><aside><Sparkles size={18}/><h2>{ROLE_LABEL[role]}快捷操作</h2><p>{role==="reviewer"?"独立查看材料和历史,再形成评审意见。":role==="admin"?"配置能力与数据边界,不替业务人员作判断。":"补证、预审、成稿、提交,一条主线完成。"}</p>{quick.map(item=><button key={item} disabled={busy||!projectId} onClick={()=>send(item)}>{item}<ArrowUpRight size={14}/></button>)}<small><Bot size={13}/>每次执行都会生成对象编号,可回到项目中追溯。</small></aside>
<div className="ba-chat"><div className="ba-messages">{messages.map(message=><article key={message.id} className={message.side}><div>{message.side==="assistant"&&<Bot size={16}/>}<p>{message.text}</p>{message.side==="user"&&<span className="ba-user-icon" aria-label="本人"><UserRound size={17}/></span>}</div>{message.objectId&&<small>操作凭证 · {message.objectId}</small>}{message.url&&<a href={message.url}>查看项目中的结果<ArrowUpRight size={14}/></a>}</article>)}{busy&&<article className="assistant pending"><div><Bot size={16}/><p>正在通过 mini-openclaw 执行并记录…</p></div></article>}</div>
{error&&<div role="alert" className="ba-error">{error}</div>}
<form onSubmit={submit}><label className="ba-attach" title="补充项目材料"><Paperclip size={18}/><input aria-label="补充项目材料" type="file" accept=".txt,.csv,.docx,.xlsx,.pdf,.png,.jpg,.jpeg" onChange={attach}/></label><input aria-label="业务指令" value={input} onChange={e=>setInput(e.target.value)} placeholder={`例如:查看 ${projectId||"当前项目"} 还缺什么材料`}/><button aria-label="发送业务指令" disabled={busy||!input.trim()||!projectId}><Send size={18}/></button></form><footer><FilePlus2 size={13}/>上传材料只形成待确认候选,不会自动覆盖项目事实。</footer>
</div>
</section>:<section className="ba-native"><header><div><Bot size={18}/><div><b>mini-openclaw 原生工作台</b><span>已绑定 {projectId} · {ROLE_LABEL[role]};适合复杂分析和自由探索</span></div></div><a href={`/xingbang-workbench?project=${encodeURIComponent(projectId)}`}>查看项目事实<ArrowUpRight size={14}/></a></header><AppProvider><ChatPanel/></AppProvider></section>}
{conversation}
</section>:<section className="ba-native"><header><div><Bot size={18}/><div><b>{nativeAccess?"mini-openclaw 原生管理工作台":`${ROLE_LABEL[role]} mini-openclaw 工作台`}</b><span>{nativeAccess?"管理员已认证,可使用原版通用工具":`已绑定 ${projectId};按当前身份调用受限 Skills、规则和项目工具`}</span></div></div><a href={`/xingbang-workbench?project=${encodeURIComponent(projectId)}`}>查看项目事实<ArrowUpRight size={14}/></a></header>{nativeAccess?<AppProvider><ChatPanel/></AppProvider>:conversation}</section>}
</main></div>;
}
@@ -11,6 +11,7 @@
.ba-workspace aside small{font-size:12px}.ba-messages article p{font-size:15px}.ba-messages article small{font-size:12px}.ba-messages article a{font-size:13px;font-weight:700}
.ba-error{font-size:14px}.ba-chat form{border-color:#bdd2c6;box-shadow:0 8px 28px #244a3715}.ba-chat form>input{font-size:15px}.ba-chat form>button,.ba-attach{width:44px;height:44px}.ba-chat footer{font-size:12px}
.ba-native>header b{font-size:14px}.ba-native>header span{font-size:12px}.ba-native>header>a{font-size:13px;font-weight:700}
.ba-native>.ba-chat{min-height:0}.ba-native>.ba-chat .ba-messages{max-height:none}
/* Give operator messages a full row so the bubble cannot drift into the middle. */
.ba-messages article.user{width:100%;max-width:100%;margin-left:0}