diff --git a/backend/api/project_assistant.py b/backend/api/project_assistant.py index f1ea1e0..f4ecf8d 100644 --- a/backend/api/project_assistant.py +++ b/backend/api/project_assistant.py @@ -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']}"} diff --git a/backend/test_project_assistant.py b/backend/test_project_assistant.py index 01e6044..7dffcc0 100644 --- a/backend/test_project_assistant.py +++ b/backend/test_project_assistant.py @@ -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"} diff --git a/scripts/test_cloud_assistant_access.cjs b/scripts/test_cloud_assistant_access.cjs new file mode 100644 index 0000000..e1d00d4 --- /dev/null +++ b/scripts/test_cloud_assistant_access.cjs @@ -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>: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"); diff --git a/scripts/verify.py b/scripts/verify.py index ea7e292..4f3770f 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -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), diff --git a/src/components/chat/ChatMessage.tsx b/src/components/chat/ChatMessage.tsx index 3ac7a43..19d960b 100644 --- a/src/components/chat/ChatMessage.tsx +++ b/src/components/chat/ChatMessage.tsx @@ -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 (
@@ -62,8 +64,8 @@ export default function ChatMessage({ message }: Props) { )} {/* Auth error alert */} - {hasAuthError ? ( - + {errorKind ? ( + ) : message.content ? (
@@ -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 (
- API Key 认证失败 + {provider ? "模型 API Key 认证失败" : "当前访问身份未通过"}

- 你的 API Key 无效或未配置。请检查 backend/.env 文件中的配置。 + {provider ? <>模型服务拒绝了当前 Key,请由配置管理员检查模型配置。 : <>当前身份不能使用原版通用工具。请返回业务助手使用当前角色的项目工具,或由管理员连接正式访问身份。}

- 检查后端状态 + {provider ? "检查模型状态" : "检查访问身份"} | {content.slice(0, 120)}... diff --git a/src/components/xingbang/BusinessAssistant.tsx b/src/components/xingbang/BusinessAssistant.tsx index 4c3c365..395a0ca 100644 --- a/src/components/xingbang/BusinessAssistant.tsx +++ b/src/components/xingbang/BusinessAssistant.tsx @@ -21,13 +21,14 @@ export default function BusinessAssistant(){ const [projects,setProjects]=useState([]),[identity,setIdentity]=useState(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([{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=
{messages.map(message=>
{message.side==="assistant"&&}

{message.text}

{message.side==="user"&&}
{message.objectId&&操作凭证 · {message.objectId}}{message.url&&查看项目中的结果}
)}{busy&&

正在通过 mini-openclaw 执行并记录…

}
+ {error&&
{error}
} +
setInput(e.target.value)} placeholder={`例如:帮我做一次 ${projectId||"当前项目"} 风险研判`}/>
当前身份只会调用本项目授权的 Skills、规则和业务工具,并保留操作凭证。
+
; return

MINI-OPENCLAW BUSINESS ENTRY

一句话,推进对公业务

对话和页面按钮共用同一套 Skills、规则、权限与留痕。
正在处理
{project&&<>
当前阶段{project.stage}
下一步{project.next_action}
进入项目}
{mode==="business"?
-
{messages.map(message=>
{message.side==="assistant"&&}

{message.text}

{message.side==="user"&&}
{message.objectId&&操作凭证 · {message.objectId}}{message.url&&查看项目中的结果}
)}{busy&&

正在通过 mini-openclaw 执行并记录…

}
- {error&&
{error}
} -
setInput(e.target.value)} placeholder={`例如:查看 ${projectId||"当前项目"} 还缺什么材料`}/>
上传材料只形成待确认候选,不会自动覆盖项目事实。
-
-
:
mini-openclaw 原生工作台已绑定 {projectId} · {ROLE_LABEL[role]};适合复杂分析和自由探索
查看项目事实
} + {conversation} + :
{nativeAccess?"mini-openclaw 原生管理工作台":`${ROLE_LABEL[role]} mini-openclaw 工作台`}{nativeAccess?"管理员已认证,可使用原版通用工具":`已绑定 ${projectId};按当前身份调用受限 Skills、规则和项目工具`}
查看项目事实
{nativeAccess?:conversation}
}
; } diff --git a/src/components/xingbang/business-assistant.css b/src/components/xingbang/business-assistant.css index 2455356..e5334b4 100644 --- a/src/components/xingbang/business-assistant.css +++ b/src/components/xingbang/business-assistant.css @@ -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}