feat: 岗位职责迁至业务链席位,并支持 WeKnora 嵌入页知识库切换

将岗位职责从智能体配置迁移到业务链席位 JSON,会商面板只读展示;WeKnora 嵌入 iframe 支持在授权可见范围内切换知识库并重新签发会话。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-19 22:26:40 +08:00
1 parent e860357563
commit e881c242fe
16 files changed
+434 -257

No files matched your search

@@ -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({
</section>
{/* ── 岗位职责 ───────────────────────────────────────────────────── */}
<Section
icon={<BriefcaseBusinessIcon className="size-3.5" />}
title="岗位职责"
>
{editing && draft ? (
<Textarea
value={draft.jobResponsibilities}
onChange={(e) =>
setDraft({ ...draft, jobResponsibilities: e.currentTarget.value })
}
placeholder="描述该智能体对应岗位的职责…"
className="min-h-20 text-sm"
/>
) : agent.job_responsibilities?.trim() ? (
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
{desensitize(agent.job_responsibilities)}
</p>
) : canEdit ? (
<button
type="button"
onClick={beginEdit}
className="text-muted-foreground hover:text-foreground hover:border-border w-full rounded-lg border border-dashed px-3 py-4 text-center text-xs transition-colors"
>
<PlusIcon className="mr-1 inline size-3.5 align-[-2px]" />
添加岗位职责
</button>
) : (
<p className="text-muted-foreground/60 text-sm">暂无岗位职责</p>
)}
</Section>
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
{jobResponsibilities !== undefined && (
<Section
icon={<BriefcaseBusinessIcon className="size-3.5" />}
title="岗位职责"
>
{jobResponsibilities?.trim() ? (
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
{desensitize(jobResponsibilities)}
</p>
) : (
<p className="text-muted-foreground/60 text-sm">暂无岗位职责</p>
)}
</Section>
)}
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
-6
View File
@@ -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;
}
@@ -417,6 +417,7 @@ function EightBfAgentWorkspace({
showRecentChats={false}
avatarSrc={agentConfigLogo}
positionName={positionNameById(selectedSeat?.position_id)}
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
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 (
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
<span className="eightbf-roundtable__agent-selector-avatar" aria-hidden="true">{avatarText}</span>
<span className="eightbf-roundtable__agent-selector-avatar" aria-hidden="true">
{avatarText ? avatarText : <img src={agentConfigLogo} alt="" />}
</span>
<span className="eightbf-roundtable__agent-selector-main">
<strong>{name}</strong>
<p>{description}</p>
@@ -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 [];
@@ -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;
@@ -5706,6 +5706,10 @@ function RightWorkspace({
}
skillsTitle={specialAgentConfig ? "技能列表" : undefined}
showCommonQuestions={specialAgentConfig}
jobResponsibilities={
chain?.seats.find((seat) => seat.agent_id === selectedAgentId)
?.job_responsibilities ?? ""
}
/>
</div>
</div>
@@ -55,6 +55,11 @@ export interface ChainSeat {
* 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。
*/
position_id?: string | null;
/**
* 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示;
* 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。
*/
job_responsibilities?: string | null;
}
/** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */
@@ -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<number | null>(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}
</div>
)}
<label className="mt-1.5 flex flex-col gap-0.5">
<span className="text-[10px] font-medium text-muted-foreground">岗位职责</span>
<textarea
value={seat.job_responsibilities ?? ""}
onChange={(event) => 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"
/>
</label>
</div>
{/* 岗位协同从专用入口进来时才展示。使用 TDesign Select,未分配不会阻塞普通圆桌使用。 */}
@@ -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.
@@ -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)
@@ -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
@@ -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
@@ -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):
@@ -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,
)
@@ -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
@@ -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()
@@ -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=(
'<html><head><script src="/assets/main.js"></script>'
'<link rel="stylesheet" href="css/app.css">'
'<script src="/config.js"></script></head><body></body></html>'
),
text=('<html><head><script src="/assets/main.js"></script><link rel="stylesheet" href="css/app.css"><script src="/config.js"></script></head><body></body></html>'),
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'<html><head><script src="/assets/index.js"></script></head><body>fetch("/api/v1/auth/me")</body></html>'
@@ -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: