diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index c2c12ef1..c94fa96d 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -84,7 +84,6 @@ interface AgentDraft { /** 会商席位「技能识别强化」开关。 */ seatSkillDirective: boolean; knowledgeBaseIds: string[]; - jobResponsibilities: string; } function draftFromAgent(agent: Agent): AgentDraft { @@ -99,7 +98,6 @@ function draftFromAgent(agent: Agent): AgentDraft { templateName: agent.template_name ?? "", seatSkillDirective: agent.seat_skill_directive ?? false, knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [], - jobResponsibilities: agent.job_responsibilities ?? "", }; } @@ -153,6 +151,7 @@ export function AgentDetailPanel({ skillsIcon, skillsTitle, showCommonQuestions = false, + jobResponsibilities, panelTitle, embedded, hideStatusBadges = false, @@ -173,6 +172,11 @@ export function AgentDetailPanel({ skillsIcon?: ReactNode; skillsTitle?: string; showCommonQuestions?: boolean; + /** + * 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示; + * 不传则隐藏该区块。此字段来自业务链,不随智能体本身编辑。 + */ + jobResponsibilities?: string | null; /** Overrides the standard panel heading for an embedded consumer. */ panelTitle?: string; /** Renders one fixed panel instead of the responsive collapsible rail. */ @@ -341,8 +345,6 @@ export function AgentDetailPanel({ request.template_name = draft.templateName.trim(); if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false)) request.seat_skill_directive = draft.seatSkillDirective; - if (draft.jobResponsibilities.trim() !== (agent.job_responsibilities ?? "")) - request.job_responsibilities = draft.jobResponsibilities.trim(); if ( JSON.stringify(draft.knowledgeBaseIds) !== JSON.stringify(agent.llmwiki_knowledge_base_ids ?? []) @@ -725,37 +727,21 @@ export function AgentDetailPanel({ - {/* ── 岗位职责 ───────────────────────────────────────────────────── */} - } - title="岗位职责" - > - {editing && draft ? ( - - setDraft({ ...draft, jobResponsibilities: e.currentTarget.value }) - } - placeholder="描述该智能体对应岗位的职责…" - className="min-h-20 text-sm" - /> - ) : agent.job_responsibilities?.trim() ? ( - - {desensitize(agent.job_responsibilities)} - - ) : canEdit ? ( - - - 添加岗位职责 - - ) : ( - 暂无岗位职责 - )} - + {/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */} + {jobResponsibilities !== undefined && ( + } + title="岗位职责" + > + {jobResponsibilities?.trim() ? ( + + {desensitize(jobResponsibilities)} + + ) : ( + 暂无岗位职责 + )} + + )} {/* ── 常用问题 ───────────────────────────────────────────────────── */} diff --git a/frontend-web/src/core/agents/types.ts b/frontend-web/src/core/agents/types.ts index 06718d76..70d82a8e 100644 --- a/frontend-web/src/core/agents/types.ts +++ b/frontend-web/src/core/agents/types.ts @@ -62,8 +62,6 @@ export interface Agent { * (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。 * 与业务链条同名开关取 OR。null/false = 关 (默认)。 */ seat_skill_directive?: boolean | null; - /** 岗位职责。会商智能体配置面板展示在常用问题上方。空/缺省 = 未填写。 */ - job_responsibilities?: string | null; /** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */ llmwiki_knowledge_base_ids?: string[] | null; tags?: Tag[]; @@ -101,8 +99,6 @@ export interface CreateAgentRequest { template_name?: string | null; /** 会商席位「技能识别强化」开关;缺省/false = 关。 */ seat_skill_directive?: boolean | null; - /** 岗位职责;缺省/空 = 未填写。 */ - job_responsibilities?: string | null; llmwiki_knowledge_base_ids?: string[] | null; } @@ -121,7 +117,5 @@ export interface UpdateAgentRequest { template_name?: string | null; /** 会商席位「技能识别强化」开关;缺省/false = 关。 */ seat_skill_directive?: boolean | null; - /** 岗位职责;传空字符串表示清除。 */ - job_responsibilities?: string | null; llmwiki_knowledge_base_ids?: string[] | null; } diff --git a/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx b/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx index 5d4423a0..62d06b13 100644 --- a/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx +++ b/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx @@ -417,6 +417,7 @@ function EightBfAgentWorkspace({ showRecentChats={false} avatarSrc={agentConfigLogo} positionName={positionNameById(selectedSeat?.position_id)} + jobResponsibilities={selectedSeat?.job_responsibilities ?? ""} skillsIcon={} skillsTitle="技能列表" showCommonQuestions @@ -502,10 +503,12 @@ function EightBfAgentRow({ }) { const name = agent?.name || seat.name || "未命名智能体"; const description = agent?.description || seat.description || "智能体助手"; - const avatarText = Array.from(name.trim() || "?")[0]?.toUpperCase() || "?"; + const avatarText = firstAgentAvatarChar(name); return ( - {avatarText} + + {avatarText ? avatarText : } + {name} {description} @@ -515,6 +518,13 @@ function EightBfAgentRow({ ); } +/** 名称首字若是【、( 这类标点,列表卡片改用智能体 logo,不把标点当头像。 */ +function firstAgentAvatarChar(name: string): string | null { + const first = Array.from(name.trim())[0]; + if (!first || /^[\p{P}\p{S}]$/u.test(first)) return null; + return first.toUpperCase(); +} + function intentSections(intentReady: object | null): Array<{ label: string; items: string[] }> { const source = intentReady && typeof intentReady === "object" ? intentReady as IntentRecord : null; if (!source) return []; diff --git a/frontend-web/src/eightbf-roundtable/styles/step1.css b/frontend-web/src/eightbf-roundtable/styles/step1.css index d3bab280..9dc22caf 100644 --- a/frontend-web/src/eightbf-roundtable/styles/step1.css +++ b/frontend-web/src/eightbf-roundtable/styles/step1.css @@ -300,6 +300,11 @@ font-size: 16px; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__agent-selector-avatar img { + width: 22px; + height: 22px; + object-fit: contain; +} .eightbf-roundtable .eightbf-roundtable__agent-selector-main { display: flex; min-width: 0; diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index 544664e7..18032890 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -5706,6 +5706,10 @@ function RightWorkspace({ } skillsTitle={specialAgentConfig ? "技能列表" : undefined} showCommonQuestions={specialAgentConfig} + jobResponsibilities={ + chain?.seats.find((seat) => seat.agent_id === selectedAgentId) + ?.job_responsibilities ?? "" + } /> diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index 3f29f2d0..03651596 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -55,6 +55,11 @@ export interface ChainSeat { * 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。 */ position_id?: string | null; + /** + * 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示; + * 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。 + */ + job_responsibilities?: string | null; } /** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */ diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index bb1cbf88..20aa42fe 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -476,6 +476,19 @@ export default function BusinessChainEditorPage() { ); }, []); + const setSeatJobResponsibilitiesCb = useCallback((agentId: string, jobResponsibilities: string) => { + setStageGroups((prev) => + prev.map((group) => ({ + ...group, + seats: group.seats.map((seat) => + seat.agent_id === agentId + ? { ...seat, job_responsibilities: jobResponsibilities } + : seat, + ), + })), + ); + }, []); + // ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)──────────────────────── const [optimizingCoordinator, setOptimizingCoordinator] = useState(false); const [goalOptimizingIdx, setGoalOptimizingIdx] = useState(null); @@ -681,6 +694,8 @@ export default function BusinessChainEditorPage() { seat_mode: s.seat_mode ?? null, // 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。 position_id: s.position_id ?? null, + // 业务链席位岗位职责:会商智能体配置面板只读展示。 + job_responsibilities: s.job_responsibilities?.trim() || null, }; }); // 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。 @@ -1284,6 +1299,7 @@ export default function BusinessChainEditorPage() { positionConfigMode={positionConfigMode} positionOptions={positionOptions} onSeatPositionChange={setSeatPositionCb} + onSeatJobResponsibilitiesChange={setSeatJobResponsibilitiesCb} onStageGoalChange={setGoal} goalOptimizingIdx={goalOptimizingIdx} onOptimizeGoal={handleOptimizeStageGoal} @@ -1468,6 +1484,7 @@ function StagesColumn({ positionConfigMode, positionOptions, onSeatPositionChange, + onSeatJobResponsibilitiesChange, onStageGoalChange, goalOptimizingIdx, onOptimizeGoal, @@ -1492,6 +1509,7 @@ function StagesColumn({ /** Live directory from the administrator's position-role configuration. */ positionOptions: readonly PositionOption[]; onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void; + onSeatJobResponsibilitiesChange: (agentId: string, jobResponsibilities: string) => void; onStageGoalChange: (stageIdx: number, goal: string) => void; /** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */ goalOptimizingIdx: number | null; @@ -1601,6 +1619,7 @@ function StagesColumn({ positionConfigMode={positionConfigMode} positionOptions={positionOptions} onSeatPositionChange={onSeatPositionChange} + onSeatJobResponsibilitiesChange={onSeatJobResponsibilitiesChange} onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)} goalOptimizing={goalOptimizingIdx === stageIdx} anyGoalOptimizing={goalOptimizingIdx !== null} @@ -1636,6 +1655,7 @@ function StageSection({ positionConfigMode, positionOptions, onSeatPositionChange, + onSeatJobResponsibilitiesChange, onGoalChange, goalOptimizing, anyGoalOptimizing, @@ -1660,6 +1680,7 @@ function StageSection({ positionConfigMode: boolean; positionOptions: readonly PositionOption[]; onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void; + onSeatJobResponsibilitiesChange: (agentId: string, jobResponsibilities: string) => void; onGoalChange: (goal: string) => void; /** 本 Stage 的阶段目标正在 AI 优化中。 */ goalOptimizing: boolean; @@ -1827,6 +1848,9 @@ function StageSection({ showPositionConfig={positionConfigMode} positionOptions={positionOptions} onPositionChange={(positionId) => onSeatPositionChange(seat.agent_id, positionId)} + onJobResponsibilitiesChange={(value) => + onSeatJobResponsibilitiesChange(seat.agent_id, value) + } onDetail={() => onSeatDetail(seat.agent_id, seat.name)} /> ))} @@ -1856,6 +1880,7 @@ function SeatStrip({ showPositionConfig, positionOptions, onPositionChange, + onJobResponsibilitiesChange, onDetail, }: { seat: ChainSeat; @@ -1880,6 +1905,7 @@ function SeatStrip({ /** Current position directory, injected by the business-chain editor. */ positionOptions: readonly PositionOption[]; onPositionChange: (positionId: PositionId | null) => void; + onJobResponsibilitiesChange: (jobResponsibilities: string) => void; onDetail: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = @@ -1955,6 +1981,17 @@ function SeatStrip({ {seat.description} )} + + 岗位职责 + onJobResponsibilitiesChange(event.currentTarget.value)} + onPointerDown={(event) => event.stopPropagation()} + placeholder="填写该席位在本业务链中的岗位职责" + rows={2} + className="w-full resize-none rounded border border-border bg-background px-2 py-1 text-[11px] leading-4 text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-primary/50" + /> + {/* 岗位协同从专用入口进来时才展示。使用 TDesign Select,未分配不会阻塞普通圆桌使用。 */} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index eb21ba3b..df09b074 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -994,6 +994,8 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me The embedded detail proxy hides WeKnora's outer `.main > .aside_box` application sidebar and expands the route outlet, but deliberately keeps the Wiki page's internal directory/index sidebar. +**Embedded native knowledge-base switching:** The original WeKnora breadcrumb picker remains available inside the detail iframe. A signed 15-minute embed cookie carries only the current DeerFlow user's visible mappings (their own plus published bases); the proxied `/api/v1/knowledge-bases` response is filtered to that allowlist. Choosing another entry performs a full iframe navigation, rechecks the DeerFlow mapping and read/write permission, then reissues the cookie for the selected base. Never expose the upstream service-admin list directly or permit an arbitrary remote knowledge-base ID. + ### Embedded Client (`packages/harness/deerflow/client.py`) `DeerFlowClient` provides direct in-process access to all DeerFlow capabilities without HTTP services. All return types align with the Gateway API response schemas, so consumer code works identically in HTTP and embedded modes. diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 0ddb89b3..eb5694b4 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -82,6 +82,7 @@ Per-thread isolated execution with virtual path translation: - **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths - **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md). - **Embedded WeKnora detail chrome**: the proxy hides WeKnora's application sidebar (`.main > .aside_box`) and expands the detail outlet to the full iframe width, while preserving the Wiki page's own index/navigation panel. +- **Embedded WeKnora knowledge-base switching**: the original breadcrumb picker works inside the iframe, but lists only the signed-in user's own and published knowledge bases. Every switch is reauthorized through DeerFlow and receives a fresh short-lived iframe session, so it never exposes the upstream service-admin knowledge-base list. - **Prefixed WeKnora deployment**: the frontend may be published under a separate prefix such as `/magentweb`, while embedded WeKnora detail traffic is served by the Gateway under `/deerflow`. The Gateway rewrites HTML root assets, WeKnora API URLs, redirects, and Vite dynamic dependency maps so page assets stay under `/deerflow`; the prefixed proxy routes must be registered before the unprefixed fallback. The signed embed cookie covers `/deerflow`, and auth/CSRF recognition includes all supported static directories. - **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match - **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access) diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index a60cfb1c..d40e534c 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -69,11 +69,6 @@ class AgentResponse(BaseModel): description="会商席位「技能识别强化」开关 (config.yaml). True = 本智能体作为会商席位时强化技能识别 (与业务链条同名开关取 OR). None/False = 关 (默认).", ) - job_responsibilities: str | None = Field( - default=None, - description="岗位职责 (config.yaml). Shown above 常用问题 in the agent configuration panel. None/empty = not set.", - ) - llmwiki_knowledge_base_ids: list[str] | None = Field( default=None, description="DeerFlow LLMWiki mapping ids this agent may offer for conversation retrieval.", @@ -131,7 +126,6 @@ class AgentCreateRequest(BaseModel): common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.") template_name: str | None = Field(default=None, description="入库模板名 persisted to config.yaml. Enables publishing markdown artifacts to the external knowledge-ingest API.") seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).") - job_responsibilities: str | None = Field(default=None, description="岗位职责 persisted to config.yaml. Empty/None = not set.") llmwiki_knowledge_base_ids: list[str] | None = Field( default=None, max_length=100, @@ -154,7 +148,6 @@ class AgentUpdateRequest(BaseModel): common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.") template_name: str | None = Field(default=None, description="Updated 入库模板名 (config.yaml). Empty string clears it.") seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).") - job_responsibilities: str | None = Field(default=None, description="Updated 岗位职责 (config.yaml). Empty string clears it.") llmwiki_knowledge_base_ids: list[str] | None = Field( default=None, max_length=100, @@ -294,7 +287,6 @@ def _write_agent_config( common_questions: list[CommonQuestion] | None = None, template_name: str | None = None, seat_skill_directive: bool | None = None, - job_responsibilities: str | None = None, llmwiki_knowledge_base_ids: list[str] | None = None, ) -> None: agent_dir = get_paths().agent_dir(agent_id) @@ -327,8 +319,6 @@ def _write_agent_config( # 会商席位技能识别强化开关:仅当显式设置时写入(None = 不写, 由 AgentConfig 默认为关)。 if seat_skill_directive is not None: config_data["seat_skill_directive"] = bool(seat_skill_directive) - if job_responsibilities is not None and job_responsibilities.strip(): - config_data["job_responsibilities"] = job_responsibilities.strip() if llmwiki_knowledge_base_ids is not None: config_data["llmwiki_knowledge_base_ids"] = list( dict.fromkeys(item.strip() for item in llmwiki_knowledge_base_ids if item.strip()) @@ -346,7 +336,6 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]: "common_questions": None, "template_name": None, "seat_skill_directive": None, - "job_responsibilities": None, "llmwiki_knowledge_base_ids": None, } try: @@ -362,7 +351,6 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]: "common_questions": cfg.common_questions, "template_name": cfg.template_name, "seat_skill_directive": cfg.seat_skill_directive, - "job_responsibilities": cfg.job_responsibilities, "llmwiki_knowledge_base_ids": cfg.llmwiki_knowledge_base_ids, } @@ -531,7 +519,6 @@ def _record_to_response( "common_questions": None, "template_name": None, "seat_skill_directive": None, - "job_responsibilities": None, "llmwiki_knowledge_base_ids": None, } if include_extras: @@ -558,7 +545,6 @@ def _record_to_response( common_questions=extras.get("common_questions"), template_name=extras.get("template_name"), seat_skill_directive=extras.get("seat_skill_directive"), - job_responsibilities=extras.get("job_responsibilities"), llmwiki_knowledge_base_ids=extras.get("llmwiki_knowledge_base_ids"), featured_order=record.get("featured_order"), ) @@ -891,7 +877,6 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque common_questions=request.common_questions, template_name=request.template_name, seat_skill_directive=request.seat_skill_directive, - job_responsibilities=request.job_responsibilities, llmwiki_knowledge_base_ids=llmwiki_ids, ) _write_agent_soul(agent_id, request.soul) @@ -974,14 +959,13 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request: next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"] next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"] next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"] - next_job_responsibilities = request.job_responsibilities if "job_responsibilities" in fields_set else existing_extras["job_responsibilities"] next_llmwiki_ids = ( await _validate_llmwiki_bindings(http_request, request.llmwiki_knowledge_base_ids) if "llmwiki_knowledge_base_ids" in fields_set else existing_extras["llmwiki_knowledge_base_ids"] ) - config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "job_responsibilities", "llmwiki_knowledge_base_ids")) + config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "llmwiki_knowledge_base_ids")) if config_dirty: _write_agent_config( agent_id, @@ -993,7 +977,6 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request: common_questions=next_common_questions, template_name=next_template_name, seat_skill_directive=next_seat_skill_directive, - job_responsibilities=next_job_responsibilities, llmwiki_knowledge_base_ids=next_llmwiki_ids, ) # Keep the listing cache in lock-step with config.yaml, but only diff --git a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py index 8bc4191f..1f7145ee 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py +++ b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py @@ -9,7 +9,7 @@ import re import time from dataclasses import replace from typing import Any, Literal -from urllib.parse import quote, urlencode, urlsplit +from urllib.parse import quote, unquote, urlencode, urlsplit import httpx from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile @@ -27,11 +27,21 @@ from app.gateway.llmwiki_deposit import ( ensure_conversation_deposit_mapping, ) from app.gateway.weknora_embed import ( - WEKNORA_EMBED_COOKIE as _WEKNORA_EMBED_COOKIE, WEKNORA_EMBED_API_PREFIX as _WEKNORA_EMBED_API_PREFIX, +) +from app.gateway.weknora_embed import ( + WEKNORA_EMBED_COOKIE as _WEKNORA_EMBED_COOKIE, +) +from app.gateway.weknora_embed import ( WEKNORA_EMBED_TTL_SECONDS as _WEKNORA_EMBED_TTL_SECONDS, +) +from app.gateway.weknora_embed import ( WeKnoraEmbedContext, +) +from app.gateway.weknora_embed import ( sign_weknora_embed_payload as _sign_embed_payload, +) +from app.gateway.weknora_embed import ( verify_weknora_embed_cookie as _verify_embed_cookie, ) from deerflow.config.agents_config import load_agent_config @@ -292,21 +302,9 @@ def _admin_session_cache_key(runtime: Any) -> str: def _extract_weknora_token(payload: dict[str, Any]) -> tuple[str, str, dict[str, Any] | None, dict[str, Any] | None]: source = payload.get("data") if isinstance(payload.get("data"), dict) else payload token = str(source.get("token") or source.get("access_token") or payload.get("token") or payload.get("access_token") or "") - refresh_token = str( - source.get("refresh_token") - or source.get("refreshToken") - or payload.get("refresh_token") - or payload.get("refreshToken") - or "" - ) + refresh_token = str(source.get("refresh_token") or source.get("refreshToken") or payload.get("refresh_token") or payload.get("refreshToken") or "") user = source.get("user") if isinstance(source.get("user"), dict) else payload.get("user") - tenant = ( - source.get("tenant") - if isinstance(source.get("tenant"), dict) - else source.get("active_tenant") - if isinstance(source.get("active_tenant"), dict) - else payload.get("tenant") - ) + tenant = source.get("tenant") if isinstance(source.get("tenant"), dict) else source.get("active_tenant") if isinstance(source.get("active_tenant"), dict) else payload.get("tenant") return token, refresh_token, user if isinstance(user, dict) else None, tenant if isinstance(tenant, dict) else None @@ -377,6 +375,79 @@ async def _read_embed_context(request: Request) -> WeKnoraEmbedContext: ctx, can_write=writable is not None, is_conversation_deposit=_is_conversation_deposit_mapping(readable), + allowed_weknora_ids=_embed_allowed_weknora_ids(ctx), + ) + + +def _embed_allowed_weknora_ids(ctx: WeKnoraEmbedContext) -> tuple[str, ...]: + """Return the signed knowledge-base allowlist, always retaining the active base.""" + + return tuple(dict.fromkeys(value for value in (ctx.weknora_id, *ctx.allowed_weknora_ids) if value)) + + +def _embed_context_payload(ctx: WeKnoraEmbedContext) -> dict[str, Any]: + return { + "mapping_id": ctx.mapping_id, + "weknora_id": ctx.weknora_id, + "user_id": ctx.user_id, + "is_admin": ctx.is_admin, + "can_write": ctx.can_write, + "is_conversation_deposit": ctx.is_conversation_deposit, + "allowed_weknora_ids": list(_embed_allowed_weknora_ids(ctx)), + "exp": ctx.exp, + } + + +def _weknora_id_from_platform_path(path: str) -> str | None: + prefix = "/platform/knowledge-bases/" + if not path.startswith(prefix): + return None + raw_id = path.removeprefix(prefix) + if not raw_id or "/" in raw_id: + return None + return unquote(raw_id) + + +async def _switch_embed_context( + request: Request, + ctx: WeKnoraEmbedContext, + weknora_id: str, +) -> WeKnoraEmbedContext: + """Resolve a permitted native-UI switch to the exact cmzs mapping. + + The embedded WeKnora UI uses a service-admin token, so every target remains + constrained to an ID signed into this short-lived session and is checked + again against the cmzs visibility rules before its page can be proxied. + """ + + if weknora_id not in _embed_allowed_weknora_ids(ctx): + raise HTTPException(status_code=403, detail="Knowledge base is outside the embedded session scope") + store = _store(request) + mapped = await store.get_by_weknora_id(weknora_id) + if mapped is None: + raise HTTPException(status_code=403, detail="Knowledge base is not managed by cmzs") + readable = await store.get_authorized( + str(mapped["id"]), + ctx.user_id, + write=False, + is_admin=ctx.is_admin, + ) + if readable is None or str(readable.get("weknora_id") or "") != weknora_id: + raise HTTPException(status_code=403, detail="Knowledge base is no longer authorized") + writable = None + if not _is_conversation_deposit_mapping(readable): + writable = await store.get_authorized( + str(readable["id"]), + ctx.user_id, + write=True, + is_admin=ctx.is_admin, + ) + return replace( + ctx, + mapping_id=str(readable["id"]), + weknora_id=weknora_id, + can_write=writable is not None, + is_conversation_deposit=_is_conversation_deposit_mapping(readable), ) @@ -431,12 +502,7 @@ def _body_kb_id(body: Any) -> str: def _request_kb_id(request: Request, body: Any) -> str: - return _body_kb_id(body) or str( - request.query_params.get("kb_id") - or request.query_params.get("knowledge_base_id") - or request.query_params.get("source_kb_id") - or "" - ) + return _body_kb_id(body) or str(request.query_params.get("kb_id") or request.query_params.get("knowledge_base_id") or request.query_params.get("source_kb_id") or "") async def _admin_json(runtime: Any, path: str, *, token: str, params: dict[str, Any] | None = None) -> dict[str, Any]: @@ -533,12 +599,7 @@ async def _authorize_weknora_api_proxy( raise HTTPException(status_code=403, detail="Global WeKnora knowledge collection is not allowed") action = tail[1] if read_method and action == "batch": - ids = [ - item.strip() - for raw in request.query_params.getlist("ids") - for item in raw.split(",") - if item.strip() - ] + ids = [item.strip() for raw in request.query_params.getlist("ids") for item in raw.split(",") if item.strip()] if not ids: raise HTTPException(status_code=403, detail="Batch operation must include document ids") for document_id in ids: @@ -574,14 +635,19 @@ async def _authorize_weknora_api_proxy( # A few read-only metadata endpoints are required by the original detail # page to render upload controls and current-tenant state. They do not # expose other knowledge-base contents. - if read_method and tail and tail[0] in { - "models", - "system", - "storage-backends", - "vector-stores", - "web-search-providers", - "mcp-services", - }: + if ( + read_method + and tail + and tail[0] + in { + "models", + "system", + "storage-backends", + "vector-stores", + "web-search-providers", + "mcp-services", + } + ): return raise HTTPException(status_code=403, detail="This WeKnora endpoint is blocked in embedded detail mode") @@ -598,27 +664,29 @@ def _filter_knowledge_base_list_response(response: httpx.Response, ctx: WeKnoraE if not isinstance(payload, dict): return None - def keep_current(value: Any) -> Any: + allowed_weknora_ids = set(_embed_allowed_weknora_ids(ctx)) + + def keep_allowed(value: Any) -> Any: if isinstance(value, list): - return [item for item in value if isinstance(item, dict) and str(item.get("id") or "") == ctx.weknora_id] + return [item for item in value if isinstance(item, dict) and str(item.get("id") or "") in allowed_weknora_ids] if isinstance(value, dict): result = dict(value) for key in ("items", "list", "knowledge_bases", "knowledge", "records", "data"): if isinstance(result.get(key), list): - result[key] = keep_current(result[key]) + result[key] = keep_allowed(result[key]) result["total"] = len(result[key]) return result return value if isinstance(payload.get("data"), list): - payload["data"] = keep_current(payload["data"]) + payload["data"] = keep_allowed(payload["data"]) payload["total"] = len(payload["data"]) elif isinstance(payload.get("data"), dict): - payload["data"] = keep_current(payload["data"]) + payload["data"] = keep_allowed(payload["data"]) else: for key in ("items", "list", "knowledge_bases", "records"): if isinstance(payload.get(key), list): - payload[key] = keep_current(payload[key]) + payload[key] = keep_allowed(payload[key]) payload["total"] = len(payload[key]) return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") @@ -654,7 +722,7 @@ def _with_public_prefix(prefix: str, path: str) -> str: def _without_public_prefix(prefix: str, path: str) -> str: if prefix and path.startswith(f"{prefix}/"): - return path[len(prefix):] + return path[len(prefix) :] if prefix and path == prefix: return "/" return path @@ -826,6 +894,7 @@ def _weknora_embed_injection( "user": user, "tenant": tenant, "weknoraId": ctx.weknora_id, + "allowedWeKnoraIds": list(_embed_allowed_weknora_ids(ctx)), "publicPrefix": public_prefix, "apiPrefix": _with_public_prefix(public_prefix, _WEKNORA_EMBED_API_PREFIX), } @@ -896,8 +965,7 @@ html.deerflow-weknora-embed .document-header {{ padding-right: 32px !important; background: var(--td-bg-color-page) !important; }} -html.deerflow-weknora-embed .breadcrumb-link:first-child, -html.deerflow-weknora-embed .breadcrumb-link.dropdown {{ +html.deerflow-weknora-embed .breadcrumb-link:first-child {{ pointer-events: none !important; cursor: default !important; }} @@ -1223,11 +1291,24 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla }} const allowedPath = cfg.allowedPath; + const allowedWeKnoraIds = new Set( + [cfg.weknoraId, ...(Array.isArray(cfg.allowedWeKnoraIds) ? cfg.allowedWeKnoraIds : [])] + .filter(Boolean) + ); + const knowledgeBasePathPrefix = (cfg.publicPrefix || "") + "/platform/knowledge-bases/"; + function isAllowedKnowledgeBasePath(pathname) {{ + if (!pathname.startsWith(knowledgeBasePathPrefix)) return false; + const encodedId = pathname.slice(knowledgeBasePathPrefix.length); + if (!encodedId || encodedId.includes("/")) return false; + try {{ return allowedWeKnoraIds.has(decodeURIComponent(encodedId)); }} catch (_) {{ return false; }} + }} function allowedUrl(url) {{ try {{ const next = new URL(url, location.origin); if (next.origin !== location.origin) return null; - if (next.pathname === allowedPath) return next.pathname + next.search + next.hash; + if (next.pathname === allowedPath || isAllowedKnowledgeBasePath(next.pathname)) {{ + return next.pathname + next.search + next.hash; + }} const query = new URLSearchParams(location.search); if (cfg.tab && !query.has("tab")) query.set("tab", cfg.tab); return allowedPath + (query.toString() ? "?" + query.toString() : "") + location.hash; @@ -1236,10 +1317,21 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla }} }} function enforceLocation() {{ - if (location.pathname !== allowedPath) {{ + if (location.pathname !== allowedPath && !isAllowedKnowledgeBasePath(location.pathname)) {{ history.replaceState(history.state, "", allowedPath + location.search + location.hash); }} }} + function reloadForKnowledgeBaseSwitch(target, replace) {{ + try {{ + const next = new URL(target, location.origin); + if (next.pathname === location.pathname || !isAllowedKnowledgeBasePath(next.pathname)) return false; + if (replace) location.replace(next.href); + else location.assign(next.href); + return true; + }} catch (_) {{ + return false; + }} + }} function purgeGuides() {{ document .querySelectorAll( @@ -1339,8 +1431,16 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla }} const rawPushState = history.pushState.bind(history); const rawReplaceState = history.replaceState.bind(history); - history.pushState = (state, title, url) => rawPushState(state, title, url ? allowedUrl(url) : location.href); - history.replaceState = (state, title, url) => rawReplaceState(state, title, url ? allowedUrl(url) : location.href); + history.pushState = (state, title, url) => {{ + const target = url ? allowedUrl(url) : location.href; + if (url && target && reloadForKnowledgeBaseSwitch(target, false)) return; + return rawPushState(state, title, target); + }}; + history.replaceState = (state, title, url) => {{ + const target = url ? allowedUrl(url) : location.href; + if (url && target && reloadForKnowledgeBaseSwitch(target, true)) return; + return rawReplaceState(state, title, target); + }}; window.addEventListener("popstate", enforceLocation); document.addEventListener("click", (event) => {{ const anchor = event.target && event.target.closest ? event.target.closest("a[href]") : null; @@ -1349,6 +1449,7 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla if (target && new URL(anchor.href, location.origin).pathname !== allowedPath) {{ event.preventDefault(); event.stopPropagation(); + if (reloadForKnowledgeBaseSwitch(target, false)) return; history.replaceState(history.state, "", target); }} }}, true); @@ -1365,7 +1466,7 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla hideWeKnoraShell(); tickSourceDetail(); if (ready) return; - if (!force && (location.pathname !== allowedPath || !hasDetailShell())) return; + if (!force && (!isAllowedKnowledgeBasePath(location.pathname) && location.pathname !== allowedPath || !hasDetailShell())) return; ready = true; root.classList.add("deerflow-weknora-ready"); }} @@ -1553,10 +1654,7 @@ async def _proxy_weknora_request( def _is_conversation_deposit_mapping(row: dict[str, Any]) -> bool: - return ( - str(row.get("owner_user_id") or "") == CONVERSATION_DEPOSIT_OWNER_USER_ID - and str(row.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME - ) + return str(row.get("owner_user_id") or "") == CONVERSATION_DEPOSIT_OWNER_USER_ID and str(row.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME _LEGACY_BRAND_PATTERN = re.compile(r"deer[\s_-]*flow", re.IGNORECASE) @@ -1612,13 +1710,7 @@ def _mapping_view(row: dict[str, Any], remote: dict[str, Any] | None, *, actor_u source = remote or {} is_conversation_deposit = _is_conversation_deposit_mapping(row) is_owner = row.get("owner_user_id") == actor_user_id - description = ( - CONVERSATION_DEPOSIT_KB_DESCRIPTION - if is_conversation_deposit - else source.get("description") - if source.get("description") is not None - else row.get("description") or "" - ) + description = CONVERSATION_DEPOSIT_KB_DESCRIPTION if is_conversation_deposit else source.get("description") if source.get("description") is not None else row.get("description") or "" return { "id": row["id"], "remote_status": "available" if remote is not None else "missing", @@ -1871,19 +1963,28 @@ async def _prepare_weknora_embed_session( ) -> tuple[str, dict[str, Any]]: _runtime_for_iframe_proxy(request) row, user_id, is_admin = await _authorized_mapping(request, mapping_id, write=False) + visible_rows = await _store(request).list_visible( + user_id, + scope="all", + # The iframe must mirror the knowledge-base picker, not grant an + # administrator an unlisted user's private knowledge bases. + is_admin=False, + ) writable = None if not _is_conversation_deposit_mapping(row): writable = await _store(request).get_authorized(mapping_id, user_id, write=True, is_admin=is_admin) weknora_id = str(row["weknora_id"]) - return weknora_id, { - "mapping_id": mapping_id, - "weknora_id": weknora_id, - "user_id": user_id, - "is_admin": is_admin, - "can_write": writable is not None, - "is_conversation_deposit": _is_conversation_deposit_mapping(row), - "exp": int(time.time()) + _WEKNORA_EMBED_TTL_SECONDS, - } + ctx = WeKnoraEmbedContext( + mapping_id=mapping_id, + weknora_id=weknora_id, + user_id=user_id, + is_admin=is_admin, + can_write=writable is not None, + is_conversation_deposit=_is_conversation_deposit_mapping(row), + allowed_weknora_ids=tuple(dict.fromkeys(value for value in (weknora_id, *(str(item.get("weknora_id") or "") for item in visible_rows)) if value)), + exp=int(time.time()) + _WEKNORA_EMBED_TTL_SECONDS, + ) + return weknora_id, _embed_context_payload(ctx) @router.post("/api/llmwiki/knowledge-bases/{mapping_id}/weknora/session") @@ -1932,8 +2033,21 @@ async def open_weknora_detail_frame( async def proxy_weknora_platform_page(request: Request, proxied_path: str = "") -> Response: ctx = await _read_embed_context(request) requested = f"/platform/{proxied_path}".rstrip("/") - allowed_path = f"/platform/knowledge-bases/{ctx.weknora_id}" + allowed_path = f"/platform/knowledge-bases/{quote(ctx.weknora_id, safe='')}" if requested != allowed_path: + requested_weknora_id = _weknora_id_from_platform_path(requested) + if requested_weknora_id is not None: + selected_ctx = await _switch_embed_context(request, ctx, requested_weknora_id) + response = await _proxy_weknora_request( + request, + target_base_url=_runtime_for_iframe_proxy(request).web_base_url, + upstream_path=f"/platform/knowledge-bases/{quote(requested_weknora_id, safe='')}", + ctx=selected_ctx, + is_api=False, + tab=request.query_params.get("tab") or "wiki", + ) + _set_weknora_embed_cookie(request, response, _embed_context_payload(selected_ctx)) + return response static_upstream_path = _weknora_static_fallback_upstream_path(request) if static_upstream_path is not None: runtime = _runtime_for_iframe_proxy(request) @@ -2151,11 +2265,7 @@ async def list_knowledge_bases( except WeKnoraError as exc: _raise_upstream(exc) deposit_remote = next( - ( - item - for item in remote_items - if str(item.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME - ), + (item for item in remote_items if str(item.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME), None, ) if deposit_remote is not None: @@ -2165,11 +2275,7 @@ async def list_knowledge_bases( client, remote_knowledge_bases=remote_items, ) - description_was_synchronized = ( - previous_mapping is None - or not _is_conversation_deposit_mapping(previous_mapping) - or str(previous_mapping.get("description") or "") != CONVERSATION_DEPOSIT_KB_DESCRIPTION - ) + description_was_synchronized = previous_mapping is None or not _is_conversation_deposit_mapping(previous_mapping) or str(previous_mapping.get("description") or "") != CONVERSATION_DEPOSIT_KB_DESCRIPTION if deposit_mapping is not None and description_was_synchronized: remote_id = str(deposit_mapping["weknora_id"]) remote[remote_id] = { @@ -2409,13 +2515,7 @@ async def list_wiki_pages( query=query.strip(), ) raw_pages = result.get("pages") if isinstance(result, dict) else [] - pages = [ - view - for page_item in (raw_pages if isinstance(raw_pages, list) else []) - if isinstance(page_item, dict) - for view in [_wiki_page_view(page_item)] - if view is not None - ] + pages = [view for page_item in (raw_pages if isinstance(raw_pages, list) else []) if isinstance(page_item, dict) for view in [_wiki_page_view(page_item)] if view is not None] return {**result, "pages": pages} except WeKnoraError as exc: _raise_upstream(exc) @@ -2557,11 +2657,7 @@ async def search_llmwiki(request: Request, body: SearchRequest) -> dict[str, Any mapping = local_by_remote.get(str(result.get("knowledge_base_id") or "")) if mapping is None: continue - visible_result = ( - _sanitize_conversation_deposit_value(result) - if _is_conversation_deposit_mapping(mapping) - else result - ) + visible_result = _sanitize_conversation_deposit_value(result) if _is_conversation_deposit_mapping(mapping) else result params = urlencode({"knowledge_base_id": mapping["id"], "chunk_id": visible_result.get("chunk_id") or ""}) visible.append( { @@ -2665,28 +2761,13 @@ async def get_source_context( if sanitize_deposit and wiki_page is not None: wiki_page = _sanitize_conversation_deposit_value(wiki_page) focus_chunk = next((chunk for chunk in chunks if str(chunk.get("id") or "") == chunk_id), {}) - document_id = str( - focus_chunk.get("knowledge_id") - or focus_chunk.get("document_id") - or focus_chunk.get("knowledgeId") - or "" - ) - document_title = str( - focus_chunk.get("knowledge_title") - or focus_chunk.get("title") - or focus_chunk.get("file_name") - or focus_chunk.get("filename") - or "" - ) + document_id = str(focus_chunk.get("knowledge_id") or focus_chunk.get("document_id") or focus_chunk.get("knowledgeId") or "") + document_title = str(focus_chunk.get("knowledge_title") or focus_chunk.get("title") or focus_chunk.get("file_name") or focus_chunk.get("filename") or "") normalized_chunks = [ { "id": str(chunk.get("id") or ""), "chunk_index": int(chunk.get("chunk_index") or 0), - "content": str( - _sanitize_conversation_deposit_value(chunk.get("content") or "") - if sanitize_deposit - else chunk.get("content") or "" - ), + "content": str(_sanitize_conversation_deposit_value(chunk.get("content") or "") if sanitize_deposit else chunk.get("content") or ""), "is_focus": str(chunk.get("id") or "") == chunk_id, } for chunk in chunks diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py index 86dfec4b..170b9536 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py @@ -71,6 +71,9 @@ class ChainSeatSchema(BaseModel): # 岗位协同专用的席位岗位归属。字段保存在既有 seats JSON 中,普通圆桌编排 # 忽略它;显式声明可确保 Pydantic 在 create/update 的 model_dump() 时不剥掉。 position_id: str | None = Field(default=None, max_length=64) + # 该席位在业务链中的岗位职责。会商智能体配置面板只读展示此字段; + # 保存在既有 seats JSON 中,无需迁移。空/None = 未填写。 + job_responsibilities: str | None = Field(default=None, max_length=4000) class ChainResponse(BaseModel): diff --git a/offline-backend-20260512/backend/app/gateway/weknora_embed.py b/offline-backend-20260512/backend/app/gateway/weknora_embed.py index a0f499f4..24c529d2 100644 --- a/offline-backend-20260512/backend/app/gateway/weknora_embed.py +++ b/offline-backend-20260512/backend/app/gateway/weknora_embed.py @@ -76,6 +76,7 @@ class WeKnoraEmbedContext: can_write: bool exp: int is_conversation_deposit: bool = False + allowed_weknora_ids: tuple[str, ...] = () def is_weknora_embed_proxy_path(path: str) -> bool: @@ -109,9 +110,7 @@ def verify_weknora_embed_cookie(raw: str | None) -> WeKnoraEmbedContext: if not raw or "." not in raw: raise HTTPException(status_code=401, detail="WeKnora iframe session is missing") body, sig = raw.rsplit(".", 1) - expected = _b64url_encode( - hmac.new(WEKNORA_PROXY_SECRET.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest() - ) + expected = _b64url_encode(hmac.new(WEKNORA_PROXY_SECRET.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest()) if not hmac.compare_digest(sig, expected): raise HTTPException(status_code=401, detail="WeKnora iframe session is invalid") try: @@ -120,6 +119,8 @@ def verify_weknora_embed_cookie(raw: str | None) -> WeKnoraEmbedContext: raise HTTPException(status_code=401, detail="WeKnora iframe session is invalid") from None if int(payload.get("exp") or 0) < int(time.time()): raise HTTPException(status_code=401, detail="WeKnora iframe session has expired") + raw_allowed_ids = payload.get("allowed_weknora_ids") + allowed_weknora_ids = tuple(dict.fromkeys(str(item).strip() for item in raw_allowed_ids if isinstance(item, (str, int)) and str(item).strip())) if isinstance(raw_allowed_ids, list) else () return WeKnoraEmbedContext( mapping_id=str(payload.get("mapping_id") or ""), weknora_id=str(payload.get("weknora_id") or ""), @@ -128,6 +129,7 @@ def verify_weknora_embed_cookie(raw: str | None) -> WeKnoraEmbedContext: can_write=bool(payload.get("can_write")), exp=int(payload.get("exp") or 0), is_conversation_deposit=bool(payload.get("is_conversation_deposit")), + allowed_weknora_ids=allowed_weknora_ids, ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py index 6fef1133..996b9dff 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py @@ -109,9 +109,6 @@ class AgentConfig(BaseModel): # 与「业务链条配置」里的同名开关取 OR:任一打开即对该席位生效。见 # ``app/gateway/roundtable_seat_skills.py``。 seat_skill_directive: bool | None = None - # 岗位职责:会商智能体配置面板展示用描述。Display/UX metadata only — - # the runtime never reads this. None/empty = not set. - job_responsibilities: str | None = None # DeerFlow mapping ids this agent may offer in its new-conversation # knowledge selector. WeKnora ids and credentials never enter agent files. llmwiki_knowledge_base_ids: list[str] | None = None diff --git a/offline-backend-20260512/backend/tests/test_agents_backend.py b/offline-backend-20260512/backend/tests/test_agents_backend.py index b9a033fc..6fa336ca 100644 --- a/offline-backend-20260512/backend/tests/test_agents_backend.py +++ b/offline-backend-20260512/backend/tests/test_agents_backend.py @@ -412,7 +412,6 @@ def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> No "common_questions": None, "template_name": None, "seat_skill_directive": None, - "job_responsibilities": None, "llmwiki_knowledge_base_ids": None, } @@ -426,7 +425,6 @@ def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths "common_questions": None, "template_name": None, "seat_skill_directive": None, - "job_responsibilities": None, "llmwiki_knowledge_base_ids": None, } @@ -499,31 +497,6 @@ def test_write_agent_config_omits_skills_when_none(isolated_paths) -> None: assert "tool_groups" not in raw -def test_write_agent_config_persists_job_responsibilities(isolated_paths) -> None: - agent_id = "agent-job-1" - _write_agent_config( - agent_id, - name="助手", - description="", - job_responsibilities=" 负责意图识别与业务链确认 ", - ) - - config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml" - raw = yaml.safe_load(config_file.read_text(encoding="utf-8")) - assert raw["job_responsibilities"] == "负责意图识别与业务链确认" - assert _load_config_extras(agent_id)["job_responsibilities"] == "负责意图识别与业务链确认" - - -def test_write_agent_config_omits_blank_job_responsibilities(isolated_paths) -> None: - agent_id = "agent-job-2" - _write_agent_config(agent_id, name="助手", description="", job_responsibilities=" ") - - config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml" - raw = yaml.safe_load(config_file.read_text(encoding="utf-8")) - assert "job_responsibilities" not in raw - assert _load_config_extras(agent_id)["job_responsibilities"] is None - - @pytest.mark.asyncio async def test_backfill_listing_cache_mirrors_config_values() -> None: store = MemoryAgentStore() diff --git a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py index b8950f2c..6fa74211 100644 --- a/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +++ b/offline-backend-20260512/backend/tests/test_llmwiki_weknora.py @@ -6,6 +6,7 @@ from types import SimpleNamespace import httpx import pytest from fastapi import FastAPI, HTTPException +from fastapi.responses import Response from starlette.datastructures import Headers, QueryParams from starlette.requests import Request @@ -19,6 +20,8 @@ from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError from deerflow.persistence.llmwiki import MemoryLlmWikiStore from deerflow.tools.builtins.llmwiki_search_tool import ( _sanitize_conversation_deposit_value as sanitize_tool_deposit_value, +) +from deerflow.tools.builtins.llmwiki_search_tool import ( selected_knowledge_base_ids, ) @@ -31,11 +34,9 @@ def _request( request_headers: dict[str, str] | None = None, root_path: str = "", ) -> Request: - headers: list[tuple[bytes, bytes]] = [ - (key.lower().encode(), value.encode()) for key, value in (request_headers or {}).items() - ] + headers: list[tuple[bytes, bytes]] = [(key.lower().encode(), value.encode()) for key, value in (request_headers or {}).items()] if cookies: - headers.append(("cookie".encode(), "; ".join(f"{key}={value}" for key, value in cookies.items()).encode())) + headers.append((b"cookie", "; ".join(f"{key}={value}" for key, value in cookies.items()).encode())) return Request( { "type": "http", @@ -173,9 +174,7 @@ def test_weknora_embed_cookie_bypasses_only_proxy_paths() -> None: cookies = {"deerflow_weknora_embed": raw_cookie} assert auth_middleware._is_weknora_embed_proxy_request(_request("/platform/knowledge-bases/kb", cookies=cookies)) - assert auth_middleware._is_weknora_embed_proxy_request( - _request("/deerflow/platform/knowledge-bases/kb", cookies=cookies) - ) + assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/platform/knowledge-bases/kb", cookies=cookies)) assert auth_middleware._is_weknora_embed_proxy_request(_request("/assets/index.js", cookies=cookies)) assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/assets/index.js", cookies=cookies)) for static_path in ( @@ -191,26 +190,14 @@ def test_weknora_embed_cookie_bypasses_only_proxy_paths() -> None: assert auth_middleware._is_weknora_embed_proxy_request(request) assert csrf_middleware.is_weknora_embed_proxy_request(request) assert auth_middleware._is_weknora_embed_proxy_request(_request("/config.js", cookies=cookies)) - assert auth_middleware._is_weknora_embed_proxy_request( - _request("/tdesign-icons/0.4.1/fonts/index.js", cookies=cookies) - ) + assert auth_middleware._is_weknora_embed_proxy_request(_request("/tdesign-icons/0.4.1/fonts/index.js", cookies=cookies)) assert auth_middleware._is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies=cookies)) - assert auth_middleware._is_weknora_embed_proxy_request( - _request("/deerflow/api/v1/knowledge-search", cookies=cookies) - ) - assert auth_middleware._is_weknora_embed_proxy_request( - _request("/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies) - ) - assert auth_middleware._is_weknora_embed_proxy_request( - _request("/deerflow/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies) - ) + assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/api/v1/knowledge-search", cookies=cookies)) + assert auth_middleware._is_weknora_embed_proxy_request(_request("/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies)) + assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies)) assert csrf_middleware.is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies=cookies)) - assert not auth_middleware._is_weknora_embed_proxy_request( - _request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"}) - ) - assert not csrf_middleware.is_weknora_embed_proxy_request( - _request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"}) - ) + assert not auth_middleware._is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"})) + assert not csrf_middleware.is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"})) assert not auth_middleware._is_weknora_embed_proxy_request(_request("/api/v1/auth/me", cookies=cookies)) assert not auth_middleware._is_weknora_embed_proxy_request(_request("/api/models", cookies=cookies)) assert not auth_middleware._is_weknora_embed_proxy_request(_request("/platform/knowledge-bases/kb")) @@ -227,6 +214,12 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo async def get_authorized(self, *_: object, **__: object) -> dict[str, str]: return {"id": "local-kb", "weknora_id": "remote-kb"} + async def list_visible(self, *_: object, **__: object) -> list[dict[str, str]]: + return [ + {"id": "local-kb", "weknora_id": "remote-kb"}, + {"id": "public-kb", "weknora_id": "other-public-kb"}, + ] + monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: object()) monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping) monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store()) @@ -246,11 +239,7 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo assert "deerflow_weknora_embed=" in cookie assert "Secure" in cookie assert "SameSite=none" in cookie - set_cookie_headers = [ - value.decode("latin-1") - for key, value in response.raw_headers - if key.lower() == b"set-cookie" - ] + set_cookie_headers = [value.decode("latin-1") for key, value in response.raw_headers if key.lower() == b"set-cookie"] assert any("Path=/" in value for value in set_cookie_headers) assert any("Path=/deerflow" in value for value in set_cookie_headers) raw = cookie.split("deerflow_weknora_embed=", 1)[1].split(";", 1)[0] @@ -258,6 +247,7 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo assert ctx.mapping_id == "local-kb" assert ctx.weknora_id == "remote-kb" assert ctx.can_write is True + assert ctx.allowed_weknora_ids == ("remote-kb", "other-public-kb") @pytest.mark.asyncio @@ -271,6 +261,9 @@ async def test_weknora_session_endpoint_preserves_reverse_proxy_prefix(monkeypat async def get_authorized(self, *_: object, **__: object) -> dict[str, str]: return {"id": "local-kb", "weknora_id": "remote-kb"} + async def list_visible(self, *_: object, **__: object) -> list[dict[str, str]]: + return [] + monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: object()) monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping) monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store()) @@ -301,6 +294,9 @@ async def test_weknora_session_endpoint_uses_backend_prefix_when_frontend_has_di async def get_authorized(self, *_: object, **__: object) -> dict[str, str]: return {"id": "local-kb", "weknora_id": "remote-kb"} + async def list_visible(self, *_: object, **__: object) -> list[dict[str, str]]: + return [] + monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: object()) monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping) monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store()) @@ -1089,15 +1085,9 @@ def test_weknora_iframe_cookie_is_signed_and_short_lived() -> None: def test_weknora_iframe_proxy_is_fallback_after_deerflow_api_routes() -> None: assert _first_gateway_match("POST", "/api/v1/auth/login/username") == "/api/v1/auth/login/username" assert _first_gateway_match("POST", "/api/v1/knowledge-search") == "/api/v1/{proxied_path:path}" - assert ( - _first_gateway_match("GET", "/api/llmwiki/weknora-embed/api/v1/auth/me") - == "/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}" - ) + assert _first_gateway_match("GET", "/api/llmwiki/weknora-embed/api/v1/auth/me") == "/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}" assert _first_gateway_match("GET", "/platform/knowledge-bases/remote-kb") == "/platform/{proxied_path:path}" - assert ( - _first_gateway_match("GET", "/deerflow/platform/knowledge-bases/remote-kb") - == "/deerflow/platform/{proxied_path:path}" - ) + assert _first_gateway_match("GET", "/deerflow/platform/knowledge-bases/remote-kb") == "/deerflow/platform/{proxied_path:path}" assert _first_gateway_match("GET", "/deerflow/assets/index.js") == "/deerflow/assets/{proxied_path:path}" assert _first_gateway_match("GET", "/deerflow/js/index.js") == "/deerflow/{proxied_path:path}" assert _first_gateway_match("GET", "/deerflow/css/index.css") == "/deerflow/{proxied_path:path}" @@ -1177,21 +1167,13 @@ async def test_prefixed_weknora_proxy_serves_page_static_assets_and_api_without_ if path == "/platform/knowledge-bases/remote-kb": return httpx.Response( 200, - text=( - '' - '' - '' - ), + text=(''), headers={"content-type": "text/html; charset=utf-8"}, ) if path == "/assets/main.js": return httpx.Response( 200, - text=( - 'const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=[' - '"assets/lazy.css","assets/lazy.js"])))=>i.map(i=>d[i]);' - 'const chunk="/static/chunk.js",matcher=/platform\\//;fetch("/api/v1/auth/me")' - ), + text=('const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/lazy.css","assets/lazy.js"])))=>i.map(i=>d[i]);const chunk="/static/chunk.js",matcher=/platform\\//;fetch("/api/v1/auth/me")'), headers={"content-type": "application/javascript"}, ) if path == "/css/app.css": @@ -1246,7 +1228,7 @@ async def test_prefixed_weknora_proxy_serves_page_static_assets_and_api_without_ assert '"deerflow/assets/lazy.css"' in script.text assert '"deerflow/assets/lazy.js"' in script.text assert '"/deerflow/assets/lazy.css"' not in script.text - assert 'matcher=/platform\\//' in script.text + assert "matcher=/platform\\//" in script.text assert 'fetch("/deerflow/api/v1/auth/me")' in script.text stylesheet = await client.get("/deerflow/css/app.css") @@ -1280,6 +1262,7 @@ def test_weknora_iframe_hides_current_outer_sidebar_shell() -> None: is_admin=False, can_write=False, exp=4_102_444_800, + allowed_weknora_ids=("remote-kb", "other-visible-kb"), ) injection = llmwiki_router._weknora_embed_injection( allowed_path="/platform/knowledge-bases/remote-kb", @@ -1302,6 +1285,7 @@ def test_weknora_iframe_html_rewrites_public_prefix_paths() -> None: is_admin=False, can_write=False, exp=4_102_444_800, + allowed_weknora_ids=("remote-kb", "other-visible-kb"), ) html = b'fetch("/api/v1/auth/me")' @@ -1318,9 +1302,12 @@ def test_weknora_iframe_html_rewrites_public_prefix_paths() -> None: assert 'fetch("/deerflow/api/v1/auth/me")' in injected assert '"apiPrefix":"/deerflow/api/llmwiki/weknora-embed"' in injected assert '"allowedPath":"/deerflow/platform/knowledge-bases/remote-kb"' in injected + assert '"allowedWeKnoraIds":["remote-kb","other-visible-kb"]' in injected + assert ".breadcrumb-link.dropdown" not in injected + assert "reloadForKnowledgeBaseSwitch" in injected -def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None: +def test_weknora_iframe_filters_kb_list_to_signed_visible_mappings() -> None: from app.gateway.routers import llmwiki as llmwiki_router ctx = llmwiki_router.WeKnoraEmbedContext( @@ -1330,6 +1317,7 @@ def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None: is_admin=False, can_write=False, exp=4_102_444_800, + allowed_weknora_ids=("remote-kb", "other-visible-kb"), ) response = httpx.Response( 200, @@ -1337,6 +1325,7 @@ def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None: "success": True, "data": [ {"id": "remote-kb", "name": "Allowed"}, + {"id": "other-visible-kb", "name": "Also allowed"}, {"id": "other-kb", "name": "Blocked"}, ], }, @@ -1346,7 +1335,112 @@ def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None: assert filtered is not None payload = json.loads(filtered) - assert payload["data"] == [{"id": "remote-kb", "name": "Allowed"}] + assert payload["data"] == [ + {"id": "remote-kb", "name": "Allowed"}, + {"id": "other-visible-kb", "name": "Also allowed"}, + ] + + +@pytest.mark.asyncio +async def test_weknora_iframe_switch_rechecks_selected_mapping_permissions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from app.gateway.routers import llmwiki as llmwiki_router + + class Store: + async def get_by_weknora_id(self, weknora_id: str) -> dict[str, str] | None: + if weknora_id == "other-visible-kb": + return {"id": "public-kb", "weknora_id": weknora_id, "owner_user_id": "owner-b"} + return None + + async def get_authorized(self, mapping_id: str, _: str, *, write: bool, **__: object) -> dict[str, str] | None: + if mapping_id != "public-kb": + return None + if write: + return None + return {"id": "public-kb", "weknora_id": "other-visible-kb", "owner_user_id": "owner-b"} + + monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store()) + original = llmwiki_router.WeKnoraEmbedContext( + mapping_id="local-kb", + weknora_id="remote-kb", + user_id="user-a", + is_admin=False, + can_write=True, + exp=4_102_444_800, + allowed_weknora_ids=("remote-kb", "other-visible-kb"), + ) + + selected = await llmwiki_router._switch_embed_context( + _request("/platform/knowledge-bases/other-visible-kb"), + original, + "other-visible-kb", + ) + + assert selected.mapping_id == "public-kb" + assert selected.weknora_id == "other-visible-kb" + assert selected.can_write is False + + +@pytest.mark.asyncio +async def test_weknora_platform_proxy_reissues_cookie_for_native_kb_switch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from app.gateway.routers import llmwiki as llmwiki_router + + current = llmwiki_router.WeKnoraEmbedContext( + mapping_id="local-kb", + weknora_id="remote-kb", + user_id="user-a", + is_admin=False, + can_write=False, + exp=4_102_444_800, + allowed_weknora_ids=("remote-kb", "other-visible-kb"), + ) + selected = llmwiki_router.WeKnoraEmbedContext( + mapping_id="public-kb", + weknora_id="other-visible-kb", + user_id="user-a", + is_admin=False, + can_write=False, + exp=4_102_444_800, + allowed_weknora_ids=current.allowed_weknora_ids, + ) + seen: dict[str, object] = {} + + async def read_context(_: Request) -> object: + return current + + async def switch_context(_: Request, ctx: object, weknora_id: str) -> object: + seen["current"] = ctx + seen["target"] = weknora_id + return selected + + async def proxy_request(*_: object, **kwargs: object) -> Response: + seen.update(kwargs) + return Response("ok") + + monkeypatch.setattr(llmwiki_router, "_read_embed_context", read_context) + monkeypatch.setattr(llmwiki_router, "_switch_embed_context", switch_context) + monkeypatch.setattr( + llmwiki_router, + "_runtime_for_iframe_proxy", + lambda _: SimpleNamespace(web_base_url="http://weknora-web"), + ) + monkeypatch.setattr(llmwiki_router, "_proxy_weknora_request", proxy_request) + + response = await llmwiki_router.proxy_weknora_platform_page( + _request("/platform/knowledge-bases/other-visible-kb", request_headers={"X-Forwarded-Proto": "https"}), + "knowledge-bases/other-visible-kb", + ) + + assert seen["current"] == current + assert seen["target"] == "other-visible-kb" + assert seen["upstream_path"] == "/platform/knowledge-bases/other-visible-kb" + raw_cookie = response.headers["set-cookie"].split("deerflow_weknora_embed=", 1)[1].split(";", 1)[0] + switched_context = llmwiki_router._verify_embed_cookie(raw_cookie) + assert switched_context.mapping_id == "public-kb" + assert switched_context.weknora_id == "other-visible-kb" @pytest.mark.asyncio @@ -1365,7 +1459,7 @@ async def test_conversation_deposit_iframe_sanitizes_legacy_brand(monkeypatch: p def __init__(self, **_: object) -> None: pass - async def __aenter__(self) -> "FakeAsyncClient": + async def __aenter__(self) -> FakeAsyncClient: return self async def __aexit__(self, *_: object) -> None:
- {desensitize(agent.job_responsibilities)} -
暂无岗位职责
+ {desensitize(jobResponsibilities)} +
{description}