From 1b43205c1b3305a6bb20154ab52ae149b0b8978e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 27 Aug 2026 11:14:23 +0800 Subject: [PATCH 1/4] feat: improve skill uploads and agent runtime stability --- .../workspace/agents/skill-picker-sheet.tsx | 20 +- .../settings/skill-settings-page.tsx | 97 +++++--- frontend-web/src/core/i18n/locales/en-US.ts | 18 +- frontend-web/src/core/i18n/locales/types.ts | 9 +- frontend-web/src/core/i18n/locales/zh-CN.ts | 16 +- frontend-web/src/core/skills/api.ts | 49 ++-- frontend-web/src/core/skills/hooks.ts | 16 +- frontend-web/src/core/threads/hooks.ts | 89 +++++-- offline-backend-20260512/backend/CLAUDE.md | 8 +- offline-backend-20260512/backend/README.md | 6 +- .../backend/app/gateway/routers/skills.py | 143 +++++++++--- .../deerflow/agents/lead_agent/agent.py | 66 +++++- .../tests/test_es_query_routing_prompt.py | 40 ++++ .../tests/test_skills_install_upload.py | 220 ++++++++++++++++++ 14 files changed, 655 insertions(+), 142 deletions(-) diff --git a/frontend-web/src/components/workspace/agents/skill-picker-sheet.tsx b/frontend-web/src/components/workspace/agents/skill-picker-sheet.tsx index 55d4c5f9..f31fc960 100644 --- a/frontend-web/src/components/workspace/agents/skill-picker-sheet.tsx +++ b/frontend-web/src/components/workspace/agents/skill-picker-sheet.tsx @@ -165,10 +165,26 @@ export function SkillPickerSheet({ return; } try { - const validation = await validateUpload.mutateAsync(file); + const validation = await validateUpload.mutateAsync({ + file, + targetCategory: "custom", + }); if (validation.conflict) { + const owner = validation.owner_username ?? validation.owner_user_id; + if (validation.owned_by_other && owner) { + toast.error( + `技能「${validation.skill_name}」已由用户「${owner}」上传,不能覆盖。`, + ); + return; + } + if (validation.owned_by_me && validation.can_overwrite) { + toast.warning( + `您已上传过同名技能「${validation.skill_name}」,可到「技能」设置页重复上传并覆盖原版本。`, + ); + return; + } toast.warning( - `已存在同名技能「${validation.skill_name}」,请到「技能」设置页上传以选择是否覆盖。`, + `已存在同名技能「${validation.skill_name}」,当前不可覆盖。`, ); return; } diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index 42f92a24..d63df2c6 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -1201,7 +1201,24 @@ function InstallSkillDialog({ const validateMutation = useValidateSkillUpload(); const installMutation = useInstallSkillUpload(); const setNameZhMutation = useSetSkillNameZh(); - const generateNameZhMutation = useGenerateSkillNameZh(); + const generateNameZhMutation = useGenerateSkillNameZh(); + + const validateFile = useCallback( + async (selected: File, category: "custom" | "public") => { + setValidation(null); + setValidationError(null); + try { + const result = await validateMutation.mutateAsync({ + file: selected, + targetCategory: category, + }); + setValidation(result); + } catch (err) { + setValidationError(err instanceof Error ? err.message : String(err)); + } + }, + [validateMutation], + ); const reset = useCallback(() => { setFile(null); @@ -1240,19 +1257,12 @@ function InstallSkillDialog({ if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) { toast.error(dialog.rejectInvalidExt); return; - } - setFile(selected); - setValidation(null); - setValidationError(null); - try { - const result = await validateMutation.mutateAsync(selected); - setValidation(result); - } catch (err) { - setValidationError(err instanceof Error ? err.message : String(err)); - } - }, - [dialog.rejectInvalidExt, validateMutation], - ); + } + setFile(selected); + await validateFile(selected, targetCategory); + }, + [dialog.rejectInvalidExt, targetCategory, validateFile], + ); const onDrop = useCallback( (e: React.DragEvent) => { @@ -1329,12 +1339,29 @@ function InstallSkillDialog({ } }, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]); - const isPublicConflict = validation?.conflict && validation.conflict_kind === "public"; - const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom"; - // Admin targeting public directory can overwrite an existing public skill. - const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict; - const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict); - const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic); + const isPublicConflict = validation?.conflict && validation.conflict_kind === "public"; + const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom"; + // Admin targeting public directory can overwrite an existing public skill. + const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict; + const canInstall = !!validation && !validationError && validation.can_install_as_is; + const canOverwrite = !!validation && !validationError && validation.can_overwrite; + const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite; + const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other; + const customConflictMessage = !validation + ? "" + : otherCustomConflict + ? dialog.conflictOwnedByOther + .replace("{name}", validation.skill_name) + .replace( + "{owner}", + validation.owner_username ?? validation.owner_user_id ?? "—", + ) + : validation.owned_by_me + ? (ownCustomCanOverwrite + ? dialog.conflictOwnedByMe + : dialog.conflictOwnedByMeWrongTarget + ).replace("{name}", validation.skill_name) + : dialog.conflictCustom.replace("{name}", validation.skill_name); return ( !o && handleClose()}> @@ -1453,9 +1480,12 @@ function InstallSkillDialog({ setTargetCategory(cat)} + value={cat} + checked={targetCategory === cat} + onChange={() => { + setTargetCategory(cat); + if (file) void validateFile(file, cat); + }} className="accent-primary" /> {cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic} @@ -1513,15 +1543,18 @@ function InstallSkillDialog({ )} - {isCustomConflict && ( -
- - - {desensitize( - dialog.conflictCustom.replace("{name}", validation.skill_name), - )} - -
+ {isCustomConflict && ( +
+ + + {desensitize(customConflictMessage)} + +
)} {isPublicConflict && (
diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index e8a7ad64..24bc3ff7 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -591,20 +591,26 @@ export const enUS: Translations = { chooseFile: "Choose file", validating: "Validating archive…", installing: "Installing…", - replace: "Replace existing", + replace: "Upload again and replace", install: "Install", cancel: "Cancel", nameLabel: "Name", descriptionLabel: "Description", licenseLabel: "License", toolsLabel: "Allowed tools", - warningsLabel: "Warnings", - conflictCustom: - "A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).", + warningsLabel: "Warnings", + conflictOwnedByMe: + "You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).", + conflictOwnedByMeWrongTarget: + "The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.", + conflictOwnedByOther: + "Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.", + conflictCustom: + "A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.", conflictPublic: "A built-in public skill named '{name}' already exists and cannot be overwritten.", - conflictPublicAdminHint: - "A built-in public skill named '{name}' exists. Switch to 'Built-in' and click 'Replace existing' to overwrite.", + conflictPublicAdminHint: + "A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.", installSuccess: "Skill '{name}' installed.", installSuccessReplaced: "Skill '{name}' replaced.", rejectInvalidExt: "Only .zip or .skill files are accepted.", diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index 3cbdf9c0..0d33ce5d 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -525,9 +525,12 @@ export interface Translations { nameLabel: string; descriptionLabel: string; licenseLabel: string; - toolsLabel: string; - warningsLabel: string; - conflictCustom: string; + toolsLabel: string; + warningsLabel: string; + conflictOwnedByMe: string; + conflictOwnedByMeWrongTarget: string; + conflictOwnedByOther: string; + conflictCustom: string; conflictPublic: string; conflictPublicAdminHint: string; installSuccess: string; diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index 8610ac88..d9bff801 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -566,18 +566,24 @@ export const zhCN: Translations = { chooseFile: "选择文件", validating: "正在校验文件…", installing: "安装中…", - replace: "覆盖已有", + replace: "重复上传并覆盖", install: "安装", cancel: "取消", nameLabel: "名称", descriptionLabel: "描述", licenseLabel: "许可证", toolsLabel: "允许的工具", - warningsLabel: "警告", - conflictCustom: - "已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。", + warningsLabel: "警告", + conflictOwnedByMe: + "您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。", + conflictOwnedByMeWrongTarget: + "同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。", + conflictOwnedByOther: + "技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。", + conflictCustom: + "已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。", conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`, - conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`, + conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`, installSuccess: `技能 '{name}' 已安装。`, installSuccessReplaced: `技能 '{name}' 已替换。`, rejectInvalidExt: `只接受 .zip 或 .skill 文件。`, diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts index 947a7395..0c6188ad 100644 --- a/frontend-web/src/core/skills/api.ts +++ b/frontend-web/src/core/skills/api.ts @@ -175,16 +175,24 @@ export interface PreviewSkillInstallResponse { warnings: string[]; } -export interface ValidateSkillUploadResponse { - ok: boolean; - skill_name: string; - description: string; - license: string | null; - allowed_tools: string[]; - conflict: boolean; - conflict_kind: "public" | "custom" | null; - warnings: string[]; -} +export interface ValidateSkillUploadResponse { + ok: boolean; + skill_name: string; + description: string; + license: string | null; + allowed_tools: string[]; + conflict: boolean; + conflict_kind: "public" | "custom" | null; + can_install_as_is: boolean; + can_overwrite: boolean; + owned_by_me: boolean; + owned_by_other: boolean; + builtin_conflict: boolean; + owner_username?: string | null; + owner_user_id?: string | null; + message: string; + warnings: string[]; +} export async function installSkill( request: InstallSkillRequest, @@ -235,13 +243,20 @@ async function parseDetailedError(response: Response, fallback: string): Promise return new Error(message); } -export async function validateSkillUpload(file: File): Promise { - const form = new FormData(); - form.append("file", file, file.name); - const response = await apiFetch(`${getBackendBaseURL()}/api/skills/validate`, { - method: "POST", - body: form, - }); +export async function validateSkillUpload( + file: File, + targetCategory: "public" | "custom" = "custom", +): Promise { + const form = new FormData(); + form.append("file", file, file.name); + const params = new URLSearchParams({ target_category: targetCategory }); + const response = await apiFetch( + `${getBackendBaseURL()}/api/skills/validate?${params.toString()}`, + { + method: "POST", + body: form, + }, + ); if (!response.ok) { throw await parseDetailedError(response, "Validation failed"); } diff --git a/frontend-web/src/core/skills/hooks.ts b/frontend-web/src/core/skills/hooks.ts index ad68d7d5..9da8298a 100644 --- a/frontend-web/src/core/skills/hooks.ts +++ b/frontend-web/src/core/skills/hooks.ts @@ -192,11 +192,17 @@ export function useSetSkillAlwaysOn() { }); } -export function useValidateSkillUpload() { - return useMutation({ - mutationFn: (file: File) => validateSkillUpload(file), - }); -} +export function useValidateSkillUpload() { + return useMutation({ + mutationFn: ({ + file, + targetCategory, + }: { + file: File; + targetCategory?: "public" | "custom"; + }) => validateSkillUpload(file, targetCategory), + }); +} export function useInstallSkillUpload() { const queryClient = useQueryClient(); diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index e5cc5bce..f83f0cdd 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -244,7 +244,7 @@ export async function stopActiveThreadBeforeClarificationReply( await new Promise((resolve) => setTimeout(resolve, 120)); } -function getStreamErrorMessage(error: unknown): string { +function getStreamErrorMessage(error: unknown): string { if (typeof error === "string" && error.trim()) { return error; } @@ -264,8 +264,21 @@ function getStreamErrorMessage(error: unknown): string { return nestedError; } } - return "Request failed."; -} + return "Request failed."; +} + +/** + * React production error #185 is the minified form of "Maximum update depth + * exceeded". It is a client render-loop guard, not a backend/model failure, + * so it must never be surfaced as the answer's stream error. + */ +function isReactMaximumUpdateDepthError(message: string): boolean { + return ( + /maximum update depth exceeded/i.test(message) || + /minified react error\s*#185\b/i.test(message) || + /react\.dev\/errors\/185\b/i.test(message) + ); +} const TERMINAL_RUN_STATUSES = new Set([ "success", @@ -575,11 +588,17 @@ export function useThreadStream({ runMetadataStorageRef.current = getRunMetadataStorage(); } - const thread = useStream({ - client: getAPIClient(isMock), - assistantId: assistantId ?? "lead_agent", - threadId: onStreamThreadId, - reconnectOnMount: runMetadataStorageRef.current + const thread = useStream({ + client: getAPIClient(isMock), + assistantId: assistantId ?? "lead_agent", + threadId: onStreamThreadId, + // The SDK's current implementation falls back to synchronous external- + // store notifications when this option is omitted. A network chunk can + // contain dozens of token events, causing nested React renders and the + // intermittent production-only React #185 error. Coalesce notifications + // to one visual update per frame; stream data itself is not dropped. + throttle: 16, + reconnectOnMount: runMetadataStorageRef.current ? () => runMetadataStorageRef.current! : false, fetchStateHistory: { limit: 1 }, @@ -720,10 +739,21 @@ export function useThreadStream({ toast(e.message); } }, - onError(error) { - markStreamActivity(); - const message = getStreamErrorMessage(error); - // Replacing a stalled local SSE reader aborts the in-flight fetch. That + onError(error) { + markStreamActivity(); + const message = getStreamErrorMessage(error); + // A synchronous SDK store notification used to let React's update-depth + // guard escape into the stream promise. Do not present that internal UI + // exception as a model failure. Reconcile/rejoin on the next task so the + // backend run can continue and the current answer is preserved. + if (isReactMaximumUpdateDepthError(message)) { + window.setTimeout(() => { + setStreamHoldOpen(true); + void confirmOrRejoinRef.current("error"); + }, 0); + return; + } + // Replacing a stalled local SSE reader aborts the in-flight fetch. That // is expected and must not look like a user-facing "network" failure. if (isAbortLikeError(error)) { return; @@ -1390,19 +1420,28 @@ export function useThreadStream({ } : thread.values; - const mergedMessages = - Array.isArray(mergedValues.messages) - ? (mergedValues.messages as Message[]) - : thread.messages; - - const mergedThread = - optimisticMessages.length > 0 || mergedValues !== thread.values - ? ({ - ...thread, - values: mergedValues, - messages: [...mergedMessages, ...optimisticMessages], - } as typeof thread) - : thread; + const mergedMessages = + Array.isArray(mergedValues.messages) + ? (mergedValues.messages as Message[]) + : thread.messages; + + const visibleThreadError = + thread.error && + isReactMaximumUpdateDepthError(getStreamErrorMessage(thread.error)) + ? undefined + : thread.error; + + const mergedThread = + optimisticMessages.length > 0 || + mergedValues !== thread.values || + visibleThreadError !== thread.error + ? ({ + ...thread, + error: visibleThreadError, + values: mergedValues, + messages: [...mergedMessages, ...optimisticMessages], + } as typeof thread) + : thread; // 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。 // 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话 diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 3038a44e..bfd7fff9 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -732,9 +732,13 @@ MySQL index script synchronized. `lead_agent.prompt._build_es_query_routing_section` injects the rule into the default lead-agent system prompt. Custom-agent-only prompts receive it only when their explicit skill allowlist contains `es_query`; disabling the switch - or leaving the prompt blank emits no block. Tests: + or leaving the prompt blank emits no block. Immediately before an ordinary Q&A + graph is created, `lead_agent.agent._log_es_query_routing_registration` checks + the exact configured block against the final system-prompt string and logs + `enabled`, allowlist eligibility, `registered`, character count, and a short + SHA-256 fingerprint (never the prompt body). Tests: `tests/test_es_query_routing_prompt.py`. -- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory +- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory. Direct uploads use the ownership-aware `POST /api/skills/validate?target_category=...` preflight plus `POST /api/skills/install-upload`: a same-owner custom conflict may be uploaded again with `overwrite=true`, while a different owner's conflict is non-overwritable and returns that owner's username (falling back to user id). The install endpoint repeats the ownership check, including for admins targeting `custom`, so callers cannot bypass the preflight. Public-skill overwrite remains admin-only. - **Installable sentiment analysis package**: `skill-packages/sentiment-analysis.skill` is deliberately separate from the existing frontend virtual-agent path. Its self-contained `config/sentiment-agent.json` owns the external AG-UI address; Basic Auth is resolved from `SENTIMENT_AGENT_AUTH_USERNAME` / `SENTIMENT_AGENT_AUTH_PASSWORD` at runtime (not committed into the package). `scripts/sentiment_stream.py` uses only the Python standard library and converts upstream SSE to stable NDJSON events (`thinking_delta`, `answer_delta`, `done`), with a Markdown convenience mode for normal agent calls. Installation/calling guide: `skill-packages/sentiment-analysis/docs/调用与安装说明.md`. - **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) ? never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill ? owner or admin; built-in/public ? admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (????: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` ? an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) ? distinct from the model returning an unusable name (? `None`). The single endpoint maps that to `502 ?????,?????????:?`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N ?????????". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI ??" plus an **admin-only** header "???????" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action. diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index ce82a135..20fc63f7 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -140,6 +140,7 @@ FastAPI application providing REST endpoints for frontend integration: | `GET/PUT /api/mcp/config` | Manage MCP server configurations | | `GET/PUT /api/skills` | List and manage skills | | `POST /api/skills/install` | Install skill from `.skill` archive | +| `POST /api/skills/validate`, `POST /api/skills/install-upload` | Validate and upload `.zip`/`.skill` packages with owner-aware conflicts: owners may explicitly overwrite their own copy; other users' copies report the uploader and cannot be overwritten | | `GET/POST /api/agents` | List visible agents or create a user-owned agent | | `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent | | `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace, plus `human_validation` to mark a Step 2 client-side pause checkpoint after that seat/stage completes (the original roundtable has no extra validation card) | @@ -508,7 +509,10 @@ Key sections: - `sandbox` - Execution environment provider - `skills` - Skills directory paths plus optional prompt routing; `skills.es_query_routing` can inject an editable system-prompt rule that sends flexible Elasticsearch Query DSL - requests to the `es_query` skill, and can be disabled without changing code + requests to the `es_query` skill, and can be disabled without changing code. Each ordinary + Q&A agent build logs `ES query routing prompt registration` with `registered`, eligibility, + character count, and a short SHA-256 fingerprint so operators can verify the exact configured + block reached the final model system prompt without logging its contents - `title` - Auto-title generation settings - `summarization` - Context summarization settings - `subagents` - Subagent system (enabled/disabled) diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py index 07d3f418..7b07264e 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skills.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py @@ -272,6 +272,14 @@ class SkillUploadValidateResponse(BaseModel): allowed_tools: list[str] = Field(default_factory=list, description="Tools the skill may invoke") conflict: bool = Field(default=False, description="True if a skill with the same name already exists") conflict_kind: str | None = Field(default=None, description="'public' or 'custom' when conflict=true") + can_install_as_is: bool = Field(default=True, description="Whether the archive can be installed without overwrite") + can_overwrite: bool = Field(default=False, description="Whether the caller may overwrite the conflicting skill") + owned_by_me: bool = Field(default=False, description="True when the caller owns the conflicting custom skill") + owned_by_other: bool = Field(default=False, description="True when another user owns the conflicting custom skill") + builtin_conflict: bool = Field(default=False, description="True when the name conflicts with a built-in/public skill") + owner_username: str | None = Field(default=None, description="Friendly username for a conflicting skill owned by another user") + owner_user_id: str | None = Field(default=None, description="Owner user id for a conflicting custom skill") + message: str = Field(default="", description="Human-readable upload guidance") warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings to surface to the user") @@ -972,16 +980,55 @@ async def _persist_uploaded_skill(file: UploadFile) -> Path: "/skills/validate", response_model=SkillUploadValidateResponse, summary="Validate Skill Archive (No Install)", - description="Inspect an uploaded .zip/.skill archive and report parsed metadata + conflicts. Does NOT install or run the LLM security scan.", + description="Inspect an uploaded .zip/.skill archive and report parsed metadata plus ownership-aware conflicts. Does NOT install or run the LLM security scan.", ) async def validate_skill_upload( + request: Request, file: UploadFile = File(..., description="The .zip or .skill archive to validate"), + target_category: SkillCategory = SkillCategory.CUSTOM, config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), ) -> SkillUploadValidateResponse: tmp_path = await _persist_uploaded_skill(file) try: result = get_or_new_skill_storage(app_config=config).validate_skill_archive(tmp_path) - return SkillUploadValidateResponse(**result) + ( + can_install_as_is, + can_overwrite, + owned_by_me, + owned_by_other, + builtin_conflict, + owner_user_id, + owner_username, + message, + ) = await _preview_install_target( + request=request, + skill_store=skill_store, + skill_name=str(result.get("skill_name") or ""), + target_category=target_category, + conflict_kind=result.get("conflict_kind"), + ) + effective_conflict_kind = result.get("conflict_kind") + if effective_conflict_kind is None: + if owned_by_me or owned_by_other: + effective_conflict_kind = "custom" + elif builtin_conflict: + effective_conflict_kind = "public" + return SkillUploadValidateResponse( + **{ + **result, + "conflict": bool(result.get("conflict") or effective_conflict_kind), + "conflict_kind": effective_conflict_kind, + "can_install_as_is": can_install_as_is, + "can_overwrite": can_overwrite, + "owned_by_me": owned_by_me, + "owned_by_other": owned_by_other, + "builtin_conflict": builtin_conflict, + "owner_username": owner_username, + "owner_user_id": owner_user_id, + "message": message, + } + ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except FileNotFoundError as e: @@ -999,7 +1046,7 @@ async def validate_skill_upload( "/skills/install-upload", response_model=SkillInstallResponse, summary="Install Skill from Upload", - description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.", + description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace the caller's own custom skill, or an existing public skill when installing as an admin.", ) async def install_skill_upload( request: Request, @@ -1017,22 +1064,30 @@ async def install_skill_upload( tmp_path = await _persist_uploaded_skill(file) try: storage = get_or_new_skill_storage(app_config=config) - # Pre-parse the archive to learn the skill name without writing anything, - # so we can reject the upload before touching the filesystem if another - # user already owns this name. Admins bypass the ownership check since - # they install to the shared public directory. - try: - validation = storage.validate_skill_archive(tmp_path) - preview_name = validation.get("skill_name") if isinstance(validation, dict) else None - except Exception: - preview_name = None - if preview_name and not is_admin: - existing = await skill_store.get_any(preview_name) - if existing and existing.get("owner_user_id") not in (None, user_id): - raise HTTPException( - status_code=409, - detail=f"Skill '{preview_name}' already exists and is owned by another user", - ) + # Re-run the same ownership-aware preflight used by /skills/validate. + # This closes the time-of-check/time-of-use gap and prevents a crafted + # request (including an admin installing into custom/) from replacing a + # different user's skill. + validation = storage.validate_skill_archive(tmp_path) + preview_name = str(validation.get("skill_name") or "") + ( + can_install_as_is, + can_overwrite, + _owned_by_me, + _owned_by_other, + _builtin_conflict, + _owner_user_id, + _owner_username, + conflict_message, + ) = await _preview_install_target( + request=request, + skill_store=skill_store, + skill_name=preview_name, + target_category=target_category, + conflict_kind=validation.get("conflict_kind"), + ) + if (overwrite and not can_install_as_is and not can_overwrite) or (not overwrite and not can_install_as_is): + raise HTTPException(status_code=409, detail=conflict_message) result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False, target_category=target_category) try: from deerflow.skills.usage import mark_uploaded @@ -1075,7 +1130,8 @@ async def _preview_install_target( skill_store: SkillStore, skill_name: str, target_category: SkillCategory, -) -> tuple[bool, bool, bool, bool, str | None, str | None, str]: + conflict_kind: str | None = None, +) -> tuple[bool, bool, bool, bool, bool, str | None, str | None, str]: """Return installability / ownership info for a parsed skill name.""" user_id = _current_user_id(request) @@ -1085,27 +1141,44 @@ async def _preview_install_target( owner_username: str | None = None if owner_user_id: try: - attached = await _attach_owner_usernames( - [SkillResponse(name=skill_name, description="", license=None, category=SkillCategory.CUSTOM, owner_user_id=owner_user_id)] - ) + attached = await _attach_owner_usernames([SkillResponse(name=skill_name, description="", license=None, category=SkillCategory.CUSTOM, owner_user_id=owner_user_id)]) owner_username = attached[0].owner_username if attached else None except Exception: owner_username = None - builtin_conflict = bool(existing and owner_user_id is None) - owned_by_me = bool(existing and owner_user_id == user_id) - owned_by_other = bool(existing and owner_user_id not in (None, user_id)) + custom_conflict = conflict_kind == "custom" or bool(existing and owner_user_id is not None) + builtin_conflict = conflict_kind == "public" or bool(existing and owner_user_id is None and conflict_kind != "custom") + owned_by_me = bool(custom_conflict and owner_user_id == user_id) + owned_by_other = bool(custom_conflict and owner_user_id not in (None, user_id)) + unattributed_custom_conflict = bool(custom_conflict and owner_user_id is None and not builtin_conflict) if target_category == SkillCategory.PUBLIC and not is_admin: - return False, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, "Only admin users can install skills to the public directory" + return False, False, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, "Only admin users can install skills to the public directory" + + if target_category == SkillCategory.PUBLIC: + if custom_conflict: + who = owner_username or owner_user_id + if owned_by_other and who: + message = f"Skill '{skill_name}' has already been uploaded by user '{who}' and cannot be overwritten." + elif owned_by_me: + message = f"You already own custom skill '{skill_name}'. Switch the target to custom to overwrite it." + else: + message = f"Skill '{skill_name}' already exists as a custom skill and its owner could not be determined." + return False, False, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, message + if builtin_conflict: + return False, True, False, False, True, owner_user_id, owner_username, f"Built-in/public skill '{skill_name}' already exists and can be overwritten by an admin." + return True, False, False, False, False, owner_user_id, owner_username, f"Skill '{skill_name}' can be installed." + if builtin_conflict: - return False, owned_by_me, owned_by_other, True, owner_user_id, owner_username, f"Skill '{skill_name}' already exists as a built-in/public skill. Please install it under a different name." + return False, False, owned_by_me, owned_by_other, True, owner_user_id, owner_username, f"Skill '{skill_name}' already exists as a built-in/public skill. Please install it under a different name." if owned_by_other: - who = owner_username or "another user" - return False, owned_by_me, True, builtin_conflict, owner_user_id, owner_username, f"Skill '{skill_name}' has already been installed by {who}. Please choose a different name." + who = owner_username or owner_user_id or "another user" + return False, False, False, True, False, owner_user_id, owner_username, f"Skill '{skill_name}' has already been uploaded by user '{who}' and cannot be overwritten." if owned_by_me: - return False, True, False, builtin_conflict, owner_user_id, owner_username, f"You have already installed skill '{skill_name}'. You can keep using it or install this copy under a different name." - return True, False, False, builtin_conflict, owner_user_id, owner_username, f"Skill '{skill_name}' can be installed." + return False, True, True, False, False, owner_user_id, owner_username, f"You have already uploaded skill '{skill_name}'. You may upload it again and overwrite your existing copy." + if unattributed_custom_conflict: + return False, False, False, False, False, owner_user_id, owner_username, f"Skill '{skill_name}' already exists, but its owner could not be determined. It cannot be overwritten safely." + return True, False, False, False, False, owner_user_id, owner_username, f"Skill '{skill_name}' can be installed." async def _build_renamed_skill_archive( @@ -1193,11 +1266,12 @@ async def preview_skill_install( skill_file_path = await aresolve_thread_virtual_path(body.thread_id, body.path) validation = get_or_new_skill_storage(app_config=config).validate_skill_archive(skill_file_path, require_skill_suffix=False) skill_name = str(validation.get("skill_name") or "") - can_install_as_is, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, message = await _preview_install_target( + can_install_as_is, _can_overwrite, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, message = await _preview_install_target( request=request, skill_store=skill_store, skill_name=skill_name, target_category=body.target_category, + conflict_kind=validation.get("conflict_kind"), ) return SkillInstallPreviewResponse( ok=True, @@ -1248,11 +1322,12 @@ async def install_skill( storage = get_or_new_skill_storage(app_config=config) validation = storage.validate_skill_archive(skill_file_path, require_skill_suffix=False) preview_name = requested_name or str(validation.get("skill_name") or "") - can_install_as_is, owned_by_me, owned_by_other, builtin_conflict, _owner_user_id, _owner_username, message = await _preview_install_target( + can_install_as_is, _can_overwrite, owned_by_me, owned_by_other, builtin_conflict, _owner_user_id, _owner_username, message = await _preview_install_target( request=request, skill_store=skill_store, skill_name=preview_name, target_category=target_category, + conflict_kind=(validation.get("conflict_kind") if requested_name is None or requested_name == validation.get("skill_name") else None), ) if not can_install_as_is: if requested_name and requested_name != validation.get("skill_name") and not owned_by_other and not builtin_conflict: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py index 83e78c1e..6c38fd14 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -1,4 +1,5 @@ -import logging +import hashlib +import logging from langchain.agents import create_agent from langchain.agents.middleware import AgentMiddleware @@ -32,7 +33,45 @@ logger = logging.getLogger(__name__) # 写作配置 Q&A 现在「先生成大纲并与用户多轮确认 → 再提交配置」,需要更多来回,故上限放宽。 # 仍是兜底安全阀:到顶才用 tool_choice 强制提交,正常流程由用户确认大纲后自然提交。 WRITING_SETUP_MAX_ROUNDS = 8 -REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8 +REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8 + +_ES_QUERY_ROUTING_OPEN_TAG = '' + + +def _log_es_query_routing_registration( + *, + system_prompt: str, + app_config: AppConfig, + agent_id: str | None, + available_skills: set[str] | None, +) -> None: + """Log whether the exact configured ES routing block reached this Q&A prompt.""" + routing = app_config.skills.es_query_routing + configured_prompt = (routing.prompt or "").strip() + expected_block = ( + f"{_ES_QUERY_ROUTING_OPEN_TAG}\n{configured_prompt}\n" + if configured_prompt + else "" + ) + eligible = available_skills is None or "es_query" in available_skills + registered = bool(expected_block) and expected_block in system_prompt + fingerprint = ( + hashlib.sha256(configured_prompt.encode("utf-8")).hexdigest()[:12] + if configured_prompt + else "-" + ) + + log = logger.info if registered or not routing.enabled or not eligible else logger.warning + log( + "ES query routing prompt registration: agent_id=%s enabled=%s eligible=%s " + "registered=%s prompt_chars=%d prompt_sha256=%s", + agent_id or "default", + routing.enabled, + eligible, + registered, + len(configured_prompt), + fingerprint, + ) def _get_runtime_config(config: RunnableConfig) -> dict: @@ -661,8 +700,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): writing_mode=writing_mode, writing_artifact_path=writing_artifact_path, ) - else: - system_prompt = apply_prompt_template( + else: + system_prompt = apply_prompt_template( subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, agent_id=agent_id, @@ -672,12 +711,19 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): available_skills=prompt_available_skills, app_config=resolved_app_config, memory_injection_enabled=memory_injection_enabled, - rag_mode_enabled=rag_mode_enabled, - is_notebook_mode=is_notebook_mode, - notebook_source_ids=notebook_source_ids, - ) - - return create_agent( + rag_mode_enabled=rag_mode_enabled, + is_notebook_mode=is_notebook_mode, + notebook_source_ids=notebook_source_ids, + ) + + _log_es_query_routing_registration( + system_prompt=system_prompt, + app_config=resolved_app_config, + agent_id=agent_id, + available_skills=prompt_available_skills, + ) + + return create_agent( model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config), tools=default_tools, middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config), diff --git a/offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py b/offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py index 4837f9a0..cca471e4 100644 --- a/offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py +++ b/offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py @@ -1,5 +1,7 @@ +import logging from types import SimpleNamespace +from deerflow.agents.lead_agent import agent as agent_module from deerflow.agents.lead_agent import prompt as prompt_module from deerflow.config.skills_config import EsQueryRoutingConfig, SkillsConfig @@ -68,3 +70,41 @@ def test_es_query_routing_is_injected_into_lead_and_allowed_custom_agent_prompts assert "自定义 ES 路由规则" in lead_prompt assert '' in allowed_custom_prompt assert '' not in denied_custom_prompt + + +def test_es_query_routing_registration_log_confirms_exact_final_prompt(caplog) -> None: + config = _app_config(enabled=True, prompt="自定义 ES 路由规则") + system_prompt = '\n自定义 ES 路由规则\n' + + with caplog.at_level(logging.INFO, logger=agent_module.__name__): + agent_module._log_es_query_routing_registration( + system_prompt=system_prompt, + app_config=config, + agent_id=None, + available_skills=None, + ) + + assert "ES query routing prompt registration" in caplog.text + assert "agent_id=default" in caplog.text + assert "enabled=True" in caplog.text + assert "eligible=True" in caplog.text + assert "registered=True" in caplog.text + assert "prompt_chars=11" in caplog.text + assert "prompt_sha256=" in caplog.text + + +def test_es_query_routing_registration_log_explains_custom_agent_exclusion(caplog) -> None: + config = _app_config(enabled=True, prompt="自定义 ES 路由规则") + + with caplog.at_level(logging.INFO, logger=agent_module.__name__): + agent_module._log_es_query_routing_registration( + system_prompt="custom agent prompt", + app_config=config, + agent_id="agent-without-es", + available_skills={"other_skill"}, + ) + + assert "agent_id=agent-without-es" in caplog.text + assert "enabled=True" in caplog.text + assert "eligible=False" in caplog.text + assert "registered=False" in caplog.text diff --git a/offline-backend-20260512/backend/tests/test_skills_install_upload.py b/offline-backend-20260512/backend/tests/test_skills_install_upload.py index 969eea15..975cef69 100644 --- a/offline-backend-20260512/backend/tests/test_skills_install_upload.py +++ b/offline-backend-20260512/backend/tests/test_skills_install_upload.py @@ -5,11 +5,16 @@ from __future__ import annotations import io import zipfile from pathlib import Path +from types import SimpleNamespace import pytest +from fastapi import HTTPException +from app.gateway.routers import skills as skills_router +from deerflow.persistence.skills import MemorySkillStore from deerflow.skills.installer import SkillAlreadyExistsError from deerflow.skills.storage.local_skill_storage import LocalSkillStorage +from deerflow.skills.types import SkillCategory def _make_skill_zip( @@ -239,3 +244,218 @@ async def test_install_refuses_to_overwrite_public_skill( await storage.ainstall_skill_from_archive( archive, overwrite=True, require_skill_suffix=False ) + + +# ---------- ownership-aware direct upload conflicts ---------- + + +def _request(user_id: str, *, is_admin: bool = False) -> SimpleNamespace: + return SimpleNamespace( + state=SimpleNamespace( + user=SimpleNamespace( + id=user_id, + system_role="admin" if is_admin else "user", + ) + ) + ) + + +async def _attach_test_owner_names(responses): + for response in responses: + if response.owner_user_id == "user-b": + response.owner_username = "bob" + return responses + + +class _UploadStorage: + def __init__(self) -> None: + self.install_calls = 0 + + def validate_skill_archive(self, _path: Path) -> dict: + return { + "ok": True, + "skill_name": "demo-skill", + "description": "demo", + "license": None, + "allowed_tools": [], + "conflict": True, + "conflict_kind": "custom", + "warnings": [], + } + + async def ainstall_skill_from_archive(self, *_args, **_kwargs) -> dict: + self.install_calls += 1 + return { + "success": True, + "skill_name": "demo-skill", + "message": "installed", + "overwritten": True, + "category": "custom", + } + + +@pytest.mark.asyncio +async def test_upload_preflight_allows_owner_to_overwrite( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = MemorySkillStore() + await store.create({"name": "demo-skill", "owner_user_id": "user-a"}) + monkeypatch.setattr(skills_router, "_attach_owner_usernames", _attach_test_owner_names) + + result = await skills_router._preview_install_target( + request=_request("user-a"), + skill_store=store, + skill_name="demo-skill", + target_category=SkillCategory.CUSTOM, + conflict_kind="custom", + ) + + can_install_as_is, can_overwrite, owned_by_me, owned_by_other, *_rest = result + assert can_install_as_is is False + assert can_overwrite is True + assert owned_by_me is True + assert owned_by_other is False + assert "upload it again and overwrite" in result[-1] + + +@pytest.mark.asyncio +async def test_upload_preflight_names_the_other_owner( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = MemorySkillStore() + await store.create({"name": "demo-skill", "owner_user_id": "user-b"}) + monkeypatch.setattr(skills_router, "_attach_owner_usernames", _attach_test_owner_names) + + result = await skills_router._preview_install_target( + request=_request("user-a"), + skill_store=store, + skill_name="demo-skill", + target_category=SkillCategory.CUSTOM, + conflict_kind="custom", + ) + + can_install_as_is, can_overwrite, owned_by_me, owned_by_other = result[:4] + assert can_install_as_is is False + assert can_overwrite is False + assert owned_by_me is False + assert owned_by_other is True + assert result[6] == "bob" + assert "user 'bob'" in result[-1] + + +@pytest.mark.asyncio +async def test_validate_upload_surfaces_the_other_owner( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + store = MemorySkillStore() + await store.create({"name": "demo-skill", "owner_user_id": "user-b"}) + storage = _UploadStorage() + upload_path = tmp_path / "validate.skill" + upload_path.write_bytes(b"placeholder") + + async def _persist(_file) -> Path: + return upload_path + + monkeypatch.setattr(skills_router, "_persist_uploaded_skill", _persist) + monkeypatch.setattr( + skills_router, + "get_or_new_skill_storage", + lambda **_kwargs: storage, + ) + monkeypatch.setattr( + skills_router, + "_attach_owner_usernames", + _attach_test_owner_names, + ) + + response = await skills_router.validate_skill_upload( + request=_request("user-a"), + file=object(), + target_category=SkillCategory.CUSTOM, + config=object(), + skill_store=store, + ) + + assert response.conflict is True + assert response.conflict_kind == "custom" + assert response.can_overwrite is False + assert response.owned_by_other is True + assert response.owner_username == "bob" + + +@pytest.mark.asyncio +async def test_direct_upload_owner_can_overwrite_own_skill( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + store = MemorySkillStore() + await store.create({"name": "demo-skill", "owner_user_id": "user-a"}) + storage = _UploadStorage() + upload_path = tmp_path / "owner-upload.skill" + upload_path.write_bytes(b"placeholder") + + async def _persist(_file) -> Path: + return upload_path + + async def _refresh() -> None: + return None + + monkeypatch.setattr(skills_router, "_persist_uploaded_skill", _persist) + monkeypatch.setattr( + skills_router, + "get_or_new_skill_storage", + lambda **_kwargs: storage, + ) + monkeypatch.setattr( + skills_router, + "refresh_skills_system_prompt_cache_async", + _refresh, + ) + monkeypatch.setattr("deerflow.skills.usage.mark_uploaded", lambda _name: None) + + response = await skills_router.install_skill_upload( + request=_request("user-a"), + file=object(), + overwrite=True, + target_category=SkillCategory.CUSTOM, + config=object(), + skill_store=store, + ) + + assert response.success is True + assert response.overwritten is True + assert storage.install_calls == 1 + + +@pytest.mark.asyncio +async def test_direct_upload_rechecks_owner_before_overwrite( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + store = MemorySkillStore() + await store.create({"name": "demo-skill", "owner_user_id": "user-b"}) + storage = _UploadStorage() + upload_path = tmp_path / "upload.skill" + upload_path.write_bytes(b"placeholder") + + async def _persist(_file) -> Path: + return upload_path + + monkeypatch.setattr(skills_router, "_persist_uploaded_skill", _persist) + monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **_kwargs: storage) + monkeypatch.setattr(skills_router, "_attach_owner_usernames", _attach_test_owner_names) + + with pytest.raises(HTTPException) as exc_info: + await skills_router.install_skill_upload( + request=_request("user-a", is_admin=True), + file=object(), + overwrite=True, + target_category=SkillCategory.CUSTOM, + config=object(), + skill_store=store, + ) + + assert exc_info.value.status_code == 409 + assert "user 'bob'" in str(exc_info.value.detail) + assert storage.install_calls == 0 From dd0ee282dfc02dcbce855d5260ef3a8ec64ddcc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 1 Sep 2026 18:18:28 +0800 Subject: [PATCH 2/4] feat: add artifact library and improve public wiki --- .gitignore | 2 + .../docs/技能归纳至WeKnora知识库-实现设计.md | 2533 +++++++++++++++++ .../components/workspace/chats/chat-box.tsx | 52 +- frontend-web/src/core/artifacts/library.ts | 102 + .../src/core/llmwiki/wiki-page-markdown.ts | 2 +- .../src/core/page-layout/sidebar-menu.ts | 1 + .../src/pages/ArtifactLibraryPage.tsx | 433 +++ frontend-web/src/pages/WorkspaceRoutes.tsx | 10 + .../src/strategy-components/api/llmwiki.ts | 58 +- .../llmwiki/WeKnoraWikiPageView.tsx | 64 +- offline-backend-20260512/backend/CLAUDE.md | 19 +- offline-backend-20260512/backend/README.md | 17 +- .../backend/app/gateway/routers/artifacts.py | 278 +- .../backend/app/gateway/routers/llmwiki.py | 324 ++- .../backend/tests/test_artifact_library.py | 142 + .../tests/test_public_llmwiki_embed.py | 142 +- 16 files changed, 4081 insertions(+), 98 deletions(-) create mode 100644 frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md create mode 100644 frontend-web/src/core/artifacts/library.ts create mode 100644 frontend-web/src/pages/ArtifactLibraryPage.tsx create mode 100644 offline-backend-20260512/backend/tests/test_artifact_library.py diff --git a/.gitignore b/.gitignore index f3b7b43e..bd77aabb 100644 --- a/.gitignore +++ b/.gitignore @@ -92,6 +92,8 @@ offline-backend-20260512/backend/tests/* !offline-backend-20260512/backend/tests/test_health_auth_bypass.py # Keep formal Word OOXML and embedded-font regression coverage versioned. !offline-backend-20260512/backend/tests/test_word_export.py +# Keep generated-file library aggregation and filtering regression coverage versioned. +!offline-backend-20260512/backend/tests/test_artifact_library.py # Keep the model configuration regression coverage versioned even though # this deployment repository otherwise ignores newly added backend tests. !offline-backend-20260512/backend/tests/test_model_management.py diff --git a/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md b/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md new file mode 100644 index 00000000..a8a5b80e --- /dev/null +++ b/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md @@ -0,0 +1,2533 @@ +# 技能归纳、WeKnora 普通知识库与助手知识库:实现设计 + +本文档用于指导大模型或开发者实现知识库体系改造。整体包含两条线:一条是“将一个或多个技能归纳到一个或多个目标知识库”;另一条是新增一个自研的全局助手知识库,用于承接 WeKnora 已处理、已向量化后的 Wiki、实体和关系数据,也支持技能和管理员本地数据直接导入,并做统一实体对齐、Wiki 展示和版本管理。 + +本文档中,目标知识库既可以是 WeKnora 普通知识库,也可以是自研助手知识库。普通知识库负责向量化、检索、Wiki 和图谱;助手知识库负责全局知识梳理、实体对齐、Wiki 展示、搜索和版本管理。 + +## 1. 背景与目标 + +当前系统已有: + +- 技能管理:后台可管理运行中的技能,技能文件位于 `skills/public`、`skills/custom`。 +- 技能地址管理:已经在管理员技能管理页中以标签页形式接入,支持扫描技能内 `.md`、`.py` 中的地址并批量替换。 +- WeKnora / LLM Wiki 接入:前后端已有知识库、文档、Wiki 页面、Wiki 图谱等 API。 + +本次要新增一条管理员工作流: + +管理员在“技能管理”页面选择多个技能,再选择一个或多个目标知识库,点击归纳。系统将每个技能作为独立来源读取、解析、抽取和归纳,把结果写入对应知识库,并在技能列表和技能详情中展示“该技能已被哪些知识库归纳”。 + +用户已确认的业务约束: + +- 一次操作可以选择多个技能,也可以选择多个目标知识库;系统按 `技能 x 目标知识库` 生成处理矩阵。 +- 同一个技能可以在不同时间归纳到多个知识库,所以最终关系是技能与知识库多对多。 +- V1 支持归纳到多种目标:Wiki WeKnora、非 Wiki WeKnora、FAQ 知识库、系统会话存档知识库、只读知识库,以及自研助手知识库。 +- 技能归纳时只抽取数据、业务说明、实体、概念、对象、事件、关系等知识;Python、TypeScript、Shell 等代码和脚本类文件完全排除,不读取注释、不抽取函数、不上传、不进入 prompt。 +- 技能里的 Markdown、业务数据文件、表格、PDF、Office、图片等要纳入处理范围。 +- 需要同步三类内容:原始资料、归纳后的 Wiki、实体与关系。 +- 关系来源包括 Markdown 自然语言描述、目录和文件引用、模型自动推断。 +- 允许模型推断关系,同时前端需要提供逐条实体/关系预览审核能力。 +- 支持手动重新归纳,也支持自动文件监听、定时后台同步和技能变更后的自动远程写入。 +- 解除关联时弹窗让用户选择:保留知识库内容,或删除该技能贡献的内容。 +- 权限仅管理员。 +- 总技能数约 200 个,需要支持批量进度、后台执行、失败重试。 +- 同一目标知识库内,不同技能抽取出的同一实体需要合并。 +- 重归纳时先生成新版本,成功后再替换旧版本;失败时旧版本继续可用。 +- 新增知识库类型区分:普通知识库是 WeKnora,助手知识库是自研存储。 +- 助手知识库只有一个全局大库,建议命名为“知识梳理”,所有用户可见。 +- 助手知识库只提供 Wiki 页面展示、搜索和数据导入;不落到 WeKnora。 +- 助手知识库数据来源包括 WeKnora 导出、技能直接归纳、管理员本地文件导入。 +- WeKnora 已向量化和处理完成的 Wiki、文档、实体、关系、图谱数据默认沉淀到助手知识库。 +- 普通知识库需要提供导出或“导入到助手知识库”的功能,把 WeKnora 里已经处理好的数据导入助手知识库;导出数据需要包含 Wiki、文档、chunk、实体、关系、图谱。 +- 即使没有配置 WeKnora,也要默认展示助手知识库的创建或初始化入口。 +- 管理员可切换知识库产品版本:`v1` / `v2`,默认 `v1`。 +- 助手知识库普通用户只读,管理员可导入、编辑、删除、查看版本和回滚 Wiki 页面。 + +## 2. 必须支持的扩展能力 + +以下能力原先按非目标处理,最新需求已确认全部要支持。实现时必须把它们作为正式目标,并加上必要的权限、状态和审计护栏。 + +- 支持一次选择多个目标知识库。一个归纳任务可包含多个技能和多个目标,后端按矩阵拆分 item。 +- 支持非 Wiki 知识库、FAQ 知识库、系统会话存档知识库、只读知识库作为目标。不同目标使用不同写入 adapter;只读目标使用本地 sidecar 投影和待推送状态。 +- 支持直接写 WeKnora 的 Neo4j 或底层图数据库,但必须通过受控 graph writer、显式配置开关、事务和审计,不允许 LLM 生成任意 Cypher 直接执行。 +- 支持自动文件监听、定时后台同步、技能变更自动远程写入。自动写入仍必须走 staged 版本和失败保留旧版本机制。 +- 前端提供逐条实体/关系预览审核,管理员可批准、拒绝、编辑、批量处理。 +- 前端和 API 暴露远端 WeKnora ID,并可作为跨系统业务 key 使用;本地仍保留 surrogate id 以保证数据库关系稳定。 +- 支持技能直接归纳到助手知识库,不必先经过 WeKnora。 +- 支持管理员向助手知识库导入任意本地文件,导入前必须做文件类型、安全扫描和敏感信息阻断。 +- 助手知识库 V1 支持 Wiki 页面版本管理、版本对比、发布、回滚和导入审计。 + +## 3. 术语 + +| 术语 | 含义 | +| --- | --- | +| 技能 | DeerFlow 技能目录,通常包含 `SKILL.md`、代码、配置、示例、文档、数据文件等。 | +| 目标知识库 | 管理员选择的一个或多个知识库,可以是 WeKnora 普通知识库,也可以是自研助手知识库。 | +| 普通知识库 | WeKnora 知识库,负责向量化、Wiki、文档检索和图谱能力。 | +| 助手知识库 | DeerFlow 自研知识库,只维护一个全局大库,用于 Wiki 展示、搜索、WeKnora 导入、技能直接导入、本地文件导入和实体对齐。 | +| 知识梳理底库 | 助手知识库的默认全局库名,所有用户可见,普通用户只读。 | +| 产品版本 | 知识库产品形态开关。`v1` 默认为普通知识库体验,`v2` 默认为助手知识库体验。 | +| WeKnora 导出包 | 从普通知识库导出的已处理知识集合,包含 Wiki 页面、文档、chunk、实体、关系、图谱等。 | +| 绑定 | 一个技能与一个目标知识库之间的持久关联。`skill_name + target_type + target_id` 唯一。 | +| 快照 | 某次扫描技能目录得到的内容版本,包含文件清单、摘要、抽取结果和源 digest。 | +| 贡献 | 某个技能快照对目标知识库内实体、关系、Wiki 页面、原始资料的增量内容。 | +| 规范实体 | 在同一个目标知识库内合并后的实体。多个技能可以贡献到同一个实体。 | +| 规范关系 | 在同一个目标知识库内合并后的关系。多个技能可以给同一关系提供证据。 | +| 归纳任务 | 一次管理员发起的批量操作,可包含多个目标知识库和多个技能 item。 | +| item | 归纳任务中的单个技能处理单元。 | + +## 4. 总体方案 + +整体采用“双知识库形态 + 本地持久绑定 + 后台任务 + WeKnora 投影 + 助手底库实体对齐”的架构。 + +```mermaid +flowchart LR + A[管理员选择多个技能] --> B[选择一个或多个目标知识库] + B --> C[创建归纳任务矩阵] + C --> D[逐技能扫描与解析] + D --> E[生成技能快照] + E --> F[LLM 归纳 Wiki 与关系抽取] + F --> R[前端实体/关系预览审核] + R --> G[按目标知识库合并实体/关系] + G --> H[写入 WeKnora 或本地助手库] + G --> I[写入 Wiki/FAQ/会话存档/sidecar] + G --> J[写入 WeKnora GraphRAG 或 Neo4j] + H --> K[校验远端写入] + I --> K + J --> K + K --> L[本地原子激活新版本] + L --> M[技能页展示已归纳知识库] + K --> N[WeKnora 导出已处理数据] + N --> O[沉淀到助手知识库] + O --> P[全局实体对齐] + P --> Q[知识梳理 Wiki 展示与搜索] +``` + +关键原则: + +- DeerFlow 本地数据库保存权威绑定关系、版本状态、来源贡献和远端 artifact 映射。 +- WeKnora 保存可检索知识内容,包括原文、Wiki 页面、FAQ、会话存档、关系投影文档和图谱。 +- 对 WeKnora 图谱能力采取能力探测和分层写入:优先使用 WeKnora API;需要底层图写入时,使用受控 Neo4j graph writer。 +- 重归纳使用 staged artifact,远端写入和校验成功后,本地才激活新版本。 +- 删除或解除关联只影响该技能在目标知识库中的贡献,不误删其他技能共享的实体和关系。 +- 助手知识库只有一个全局库,可以接收技能直接归纳、WeKnora 导出和管理员本地文件导入。 +- WeKnora 的已处理结果默认进入助手知识库,由助手底库做全局实体对齐、Wiki 展示、搜索和版本管理。 +- 未配置 WeKnora 时,仍展示助手知识库创建或初始化能力;与 WeKnora 相关的归纳和导入动作置灰或隐藏。 + +## 5. 现有代码落点 + +实现前必须先读仓库说明: + +- `D:/cmzs-clean/deerflow-code/AGENTS.md` +- `D:/cmzs-clean/deerflow-code/offline-backend-20260512/backend/AGENTS.md` +- `D:/cmzs-clean/deerflow-code/offline-backend-20260512/backend/CLAUDE.md` + +建议改动位置: + +| 模块 | 建议文件 | +| --- | --- | +| 后端 router | `offline-backend-20260512/backend/app/gateway/routers/skill_knowledge.py` | +| 后台任务调度 | `offline-backend-20260512/backend/app/gateway/skill_knowledge_job_dispatcher.py` | +| 后台任务执行 | `offline-backend-20260512/backend/app/gateway/skill_knowledge_job_executor.py` | +| 技能归纳核心逻辑 | `offline-backend-20260512/backend/packages/harness/deerflow/skill_knowledge/` | +| 持久化模型 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/skill_knowledge/` | +| WeKnora 客户端扩展 | `offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/client.py` | +| 助手知识库核心逻辑 | `offline-backend-20260512/backend/packages/harness/deerflow/assistant_knowledge/` | +| 助手知识库持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/assistant_knowledge/` | +| 助手知识库 Gateway | `offline-backend-20260512/backend/app/gateway/routers/assistant_knowledge.py` | +| WeKnora 导出到助手库任务 | `offline-backend-20260512/backend/app/gateway/assistant_knowledge_import_executor.py` | +| 前端 API | `frontend-web/src/strategy-components/api/skill-knowledge.ts` | +| 助手知识库前端 API | `frontend-web/src/strategy-components/api/assistant-knowledge.ts` | +| 管理员技能页 | `frontend-web/src/pages/AdminSkillsPage.tsx` | +| 技能行组件 | `frontend-web/src/components/workspace/settings/skill-settings-page.tsx` | +| WeKnora API hooks | `frontend-web/src/strategy-components/api/llmwiki.ts` | +| 知识库产品版本设置 | `frontend-web/src/core/system-settings/api.ts` | + +注意边界: + +- `packages/harness/deerflow/*` 不能 import `app.*`。 +- 路由、鉴权、FastAPI app state 等放在 `app/gateway/*`。 +- 纯扫描、解析、抽取、合并、投影计划生成放在 `deerflow.*`。 +- 文件 IO、Office/PDF 转换、CPU 密集处理必须通过 `asyncio.to_thread`,避免阻塞事件循环。 + +## 6. WeKnora 能力使用策略 + +现有 WeKnora 客户端已支持: + +- 创建知识库。 +- 上传文件。 +- 导入 URL。 +- 创建手动文档。 +- 创建、更新、删除、查询 Wiki 页面。 +- 获取 Wiki 图谱。 + +V1 需要新增或补齐: + +- 目标知识库能力探测:是否启用 Wiki,是否可写,是否支持 GraphRAG / extract_config。 +- 目标知识库分类和能力标识:Wiki、非 Wiki、FAQ、会话存档、只读、图谱可写。 +- 导出已处理数据的能力:Wiki 页面、文档、chunk、实体、关系、图谱。 +- WeKnora 处理完成后的自动沉淀触发点:在文件向量化、Wiki 生成和图谱抽取完成后,创建“导入助手知识库”的后台任务。 +- 如果 WeKnora 当前实例 Swagger 暴露了图谱相关配置更新接口,则在目标知识库启用 `graph_enabled` 与 `extract_config`。 +- 如果未暴露稳定的实体/关系写入 API,优先使用“关系投影文档 + Wiki 关系页 + Wiki 链接”让 WeKnora 自己抽取图谱;在配置显式开启时,可通过受控 Neo4j direct writer 写入底层图数据库。 + +参考官方资料: + +- [WeKnora Knowledge Graph 文档](https://github.com/Tencent/WeKnora/blob/main/docs/KnowledgeGraph.md) +- [WeKnora knowledge-base API 文档](https://github.com/Tencent/WeKnora/blob/main/docs/api/knowledge-base.md) +- [WeKnora QA 文档](https://github.com/Tencent/WeKnora/blob/main/docs/QA.md) +- [WeKnora API 文档入口](https://github.com/Tencent/WeKnora/blob/main/docs/api/README.md) + +实现要求: + +- 以部署实例的 Swagger 为准,不要硬编码未确认的远端 API。 +- 可以封装 `probe_capabilities()`,返回 `wiki_enabled`、`graph_enabled_supported`、`extract_config_supported`、`wiki_page_upsert_supported`、`file_upload_supported` 等能力。 +- GraphRAG 失败不能导致 Wiki 归纳整体回滚。Wiki 与原始资料成功、GraphRAG 写入失败时,绑定状态为 `partial_failed`,错误信息明确写“图谱投影失败,可重新归纳重试”。 +- 如果 WeKnora 不支持直接导出向量、chunk 或图谱原始结构,导出层应退化为读取 Wiki 页面、文档状态、Wiki graph 和可查询的图谱摘要;助手知识库不强依赖复用 WeKnora embedding 向量。 + +### 6.1 目标知识库适配策略 + +一次归纳任务允许选择多个技能和多个目标知识库。后端不要把它当成一个大事务,而是展开为矩阵: + +```text +items = selected_skills x selected_targets +``` + +每个 item 对应一个 `binding`,一个技能归纳到三个目标知识库,就生成三个 binding。父 job 负责展示整体进度,item 独立成功、失败、取消和重试。 + +目标知识库需要通过 adapter 写入: + +| 目标类型 | 写入策略 | Wiki | 关系/图谱 | +| --- | --- | --- | --- | +| WeKnora Wiki 知识库 | 上传原始资料、upsert Wiki 页面、写 graph projection | 写 WeKnora Wiki | WeKnora GraphRAG + 可选 Neo4j direct writer | +| WeKnora 非 Wiki 知识库 | 上传原始资料和归纳文档,不强制生成 Wiki | 不写 WeKnora Wiki,可写本地 sidecar Wiki | graph projection 文档 + 可选 Neo4j direct writer | +| WeKnora FAQ 知识库 | 把归纳结果转换为 FAQ 条目、来源文档和关系摘要 | 不写或只写 sidecar Wiki | 关系作为 FAQ metadata、projection 文档和图谱边 | +| 系统会话存档知识库 | 把技能归纳结果作为系统会话归档条目写入 | sidecar Wiki 展示 | 关系作为归档 metadata 和图谱边 | +| 只读 WeKnora 知识库 | 不修改远端原库;写本地 sidecar artifact,并生成待推送状态 | sidecar Wiki 展示 | sidecar graph + 待推送 Neo4j plan | +| 助手知识库 | 写入自研底库、实体对齐、生成 Wiki 页面版本 | 写自研 Wiki | 写自研实体/关系表 | + +只读目标说明: + +- “支持只读知识库作为目标”不代表绕过远端权限强行写入。 +- 对只读 WeKnora,系统创建本地 sidecar 投影:归纳结果与该远端知识库绑定展示,但远端写入状态为 `pending_remote_write`。 +- 当管理员后续授予可写权限时,可执行“推送到远端”,把 sidecar staged artifact 写入 WeKnora。 +- 前端必须清楚展示“只读目标:已本地归纳,未写入远端”。 + +### 6.2 Neo4j 直接写入设计 + +需求确认需要支持直接写 WeKnora 的 Neo4j 或底层图数据库。该能力风险较高,必须作为受控能力实现。 + +必须满足: + +- 通过配置显式开启,例如 `weknora.graph.direct_write_enabled = true`。 +- 只允许管理员触发。 +- Neo4j 连接串、账号、库名只允许后端配置,不允许前端传入。 +- LLM 不得生成任意 Cypher 并直接执行。 +- graph writer 只能执行代码中预定义的参数化模板。 +- 所有写入使用事务。 +- 每个节点和边都带稳定业务 key、来源 binding、snapshot、job、confidence 和 evidence。 +- 支持 dry-run plan、写入审计、失败重试、回滚计划。 + +节点 key 建议: + +```text +provider = deerflow +scope = target_type + target_id +entity_key = entity_type + normalized_name 或 external_id +``` + +边 key 建议: + +```text +source_entity_key + predicate + target_entity_key + contribution_scope +``` + +Neo4j 写入只作为图谱层加速和展示,不替代本地 contribution 表。本地 contribution 表仍是删除、回滚、重建、审计的权威来源。 + +### 6.3 实体/关系预览审核 + +前端必须提供逐条实体/关系预览审核能力。审核发生在“抽取完成、写入目标前”。 + +审核对象: + +- 实体。 +- 关系。 +- 实体对齐候选。 +- 关系合并候选。 +- Wiki 页面草稿。 + +管理员操作: + +- 批准。 +- 拒绝。 +- 编辑名称、类型、谓词、别名、摘要、confidence。 +- 批量批准高置信结果。 +- 批量拒绝低置信结果。 + +审核模式建议: + +| 模式 | 说明 | +| --- | --- | +| `required` | 所有实体/关系必须审核后才能写入。 | +| `auto_high_confidence` | 高置信自动通过,低置信进入审核队列。 | +| `off` | 不阻塞写入,但仍保留预览页面和事后审计。 | + +默认建议 `auto_high_confidence`。如果用户希望“必须逐条审核”,可在系统设置中改为 `required`。 + +审核记录必须入库: + +- 操作人。 +- 操作时间。 +- 原始抽取值。 +- 修改后值。 +- 通过或拒绝原因。 +- 对应 job/item/binding/snapshot。 + +### 6.4 自动同步设计 + +需要支持三类自动同步: + +- 文件监听:技能知识文件变化后自动创建归纳任务。 +- 定时同步:按管理员配置的周期重新扫描并归纳。 +- 技能变更自动远程写入:技能保存、启停、导入后触发相关 binding 同步。 + +自动同步仍需遵守: + +- 只处理非代码知识文件。 +- 代码文件变化不触发归纳。 +- 自动任务必须 debounced,避免保存过程中连续触发。 +- 自动任务必须走 staged 版本,成功后激活,失败保留旧版本。 +- 同一 binding 同一时间只能有一个 active item。 +- 审核模式为 `required` 时,自动任务停在 `awaiting_review`,不写远端。 + +建议新增配置: + +```json +{ + "skill_knowledge_auto_sync": { + "watch_enabled": true, + "schedule_enabled": true, + "schedule_cron": "0 */6 * * *", + "debounce_seconds": 120, + "write_remote_enabled": true + } +} +``` + +### 6.5 远端 ID 暴露与业务 key + +需求确认前端需要暴露 WeKnora 远端 ID,并可作为跨系统业务 key 使用。 + +实现要求: + +- API 返回 `remote_refs`,包含 provider、remote kind、remote id、slug、url。 +- 前端可以展示、复制、筛选远端 ID。 +- 与外部系统交互时,可使用 `provider + remote_id` 作为业务 key。 +- 本地数据库仍保留 `id` 作为 surrogate primary key,避免远端 ID 变更导致本地关系断裂。 + +建议响应结构: + +```json +{ + "id": "local-artifact-id", + "business_key": "weknora:wiki_page:remote-page-id", + "remote_refs": [ + { + "provider": "weknora", + "remote_kind": "wiki_page", + "remote_id": "remote-page-id", + "slug": "skills/example/overview", + "url": "/llmwiki/kb/xxx/wiki/skills/example/overview" + } + ] +} +``` + +不要把远端 ID 当作唯一存储来源;本地仍需要维护 mapping、审计和状态。 + +## 7. 数据处理范围 + +归纳一个技能时,扫描技能根目录下所有有效的非代码知识文件。代码类文件不作为知识源,也不影响知识 digest。 + +必须处理: + +| 类型 | 策略 | +| --- | --- | +| `.md` / `.mdx` / `.txt` | 直接读取,保留标题、段落、链接、表格和自然语言说明;代码块内容默认跳过,只保留“此处存在示例代码”的弱提示。 | +| `.json` / `.yaml` / `.yml` / `.toml` / `.ini` | 仅当它们承载业务数据、数据字典、枚举、模板或知识配置时处理;运行配置、依赖配置、工具入口、工程配置不入库。 | +| `.csv` / `.tsv` | 结构化读取,抽样概要 + 全量原始资料上传。 | +| `.xlsx` / `.xls` | 使用已有文件转换或表格解析能力,保留 sheet、列名、样例和统计。 | +| `.pdf` / `.docx` / `.pptx` | 复用现有 `deerflow.utils.file_conversion` 或 MarkItDown 路径转文本,同时上传原始文件。 | +| 图片 | 上传原始文件;如果已有 VLM/OCR 能力则抽取图片描述,否则记录为二进制附件。 | +| 压缩包 | 安全解压到临时目录后递归处理,必须限制大小、文件数、路径穿越、符号链接。 | +| 其他二进制 | 记录 manifest 并尝试作为原始附件上传;无法解析时不生成文本摘要。 | + +必须排除: + +- `.py` +- `.ts` +- `.tsx` +- `.js` +- `.jsx` +- `.sh` +- `.bat` +- `.ps1` +- 其他脚本、源码、构建脚本、依赖锁文件 +- `.git` +- `node_modules` +- `__pycache__` +- `.pytest_cache` +- `dist` +- `build` +- 临时缓存目录 +- 明确生成物和大体积模型文件 + +必须阻断: + +- `.env` +- 私钥、公钥私钥对、证书私钥 +- token、password、secret、access key 等高置信密钥文件 + +阻断规则是安全底线:发现疑似密钥时,该文件不能上传到 WeKnora,也不能进入 LLM prompt。任务 item 标记为 `partial_failed` 或 `failed`,前端展示“因安全策略跳过敏感文件”,并给出文件路径,不输出密钥内容。 + +代码排除规则: + +- 不读取源码内容。 +- 不抽取源码注释。 +- 不抽取函数、类、import、依赖、入口、调用链。 +- 不上传源码文件。 +- 不把源码路径作为 Wiki 知识展示;最多在管理员任务详情中显示“已跳过代码类文件数量”。 +- 计算 `source_digest` 时只包含知识文件;纯代码变更不应导致技能归纳状态变为 stale。 + +## 8. 中间表示 IR + +扫描和抽取后生成统一 IR,后续 Wiki 生成、实体合并、远端投影都基于 IR,而不是直接从文件内容临时拼装。 + +建议结构: + +```json +{ + "skill": { + "name": "example-skill", + "display_name": "示例技能", + "description": "从 SKILL.md 和元数据提取", + "source_root": "skills/public/example-skill", + "source_digest": "sha256:..." + }, + "files": [ + { + "path": "SKILL.md", + "kind": "markdown", + "sha256": "...", + "size": 12345, + "text_digest": "sha256:...", + "title": "Example Skill", + "summary": "文件摘要", + "anchors": [ + { + "id": "heading-install", + "line_start": 12, + "line_end": 36, + "text_digest": "sha256:..." + } + ], + "references": [ + { + "target_path": "data/schema.yaml", + "evidence": "Markdown link or import", + "confidence": 1.0 + } + ], + "safety": { + "status": "allowed", + "warnings": [] + } + } + ], + "entities": [ + { + "local_id": "ent_001", + "type": "tool", + "name": "web_search", + "aliases": ["网络搜索"], + "description": "用于检索外部信息的工具", + "attributes": { + "entrypoint": "config.yaml tools.web_search" + }, + "evidence": [ + { + "file_path": "config.yaml", + "line_start": 10, + "line_end": 20, + "quote": "短证据,不包含敏感信息" + } + ], + "confidence": 0.94 + } + ], + "relations": [ + { + "local_id": "rel_001", + "source_local_id": "ent_001", + "predicate": "used_by", + "target_local_id": "ent_002", + "description": "web_search 被示例技能用于首步检索", + "relation_source": "llm_inferred", + "evidence": [ + { + "file_path": "SKILL.md", + "line_start": 22, + "line_end": 28, + "quote": "短证据" + } + ], + "confidence": 0.88 + } + ], + "wiki": { + "skill_page": { + "slug": "skills/example-skill/overview", + "title": "示例技能", + "content": "Markdown Wiki 内容" + }, + "pages": [] + } +} +``` + +IR 需要版本化: + +- `extractor_version`:扫描、解析、提示词和抽取 schema 的整体版本。 +- `prompt_hash`:LLM 抽取提示词 hash。 +- `model_name`:抽取模型名。 +- `source_digest`:技能目录内容 digest。 + +只要 `source_digest + extractor_version + prompt_hash + model_name` 相同,就可以复用已有 snapshot,避免 200 个技能批量时反复抽取。 + +## 9. 关系抽取规则 + +关系来源分三类: + +| 来源 | 示例 | 置信度策略 | +| --- | --- | --- | +| 确定性结构关系 | 目录包含文件、Markdown 链接、数据文件引用、业务配置引用 | `confidence = 1.0` | +| 显式文本关系 | Markdown 中写明“本技能依赖 A”、“输出 B”、“调用接口 C” | LLM 抽取,但要求证据;通常 `0.75` 以上 | +| 模型推断关系 | 从上下文推断某工具服务于某步骤、某配置控制某行为 | 必须有证据片段和解释;无证据不得入库 | + +无人工审核的前提下,必须设置阈值: + +- `confidence >= 0.75`:写入 Wiki 关系页和图谱投影文档。 +- `0.50 <= confidence < 0.75`:只写入技能 Wiki 的“可能关系”小节,不进入规范关系。 +- `confidence < 0.50`:丢弃。 + +每条关系都必须包含: + +- `source` +- `predicate` +- `target` +- `confidence` +- `relation_source` +- `evidence` + +如果没有证据路径和证据文本,不允许写入规范关系。 + +建议谓词归一化: + +| 原始表达 | 规范 predicate | +| --- | --- | +| 依赖、需要、requires | `depends_on` | +| 调用、使用、uses | `uses` | +| 生成、输出、produces | `produces` | +| 读取、输入、consumes | `consumes` | +| 配置、控制 | `configures` | +| 包含、目录下有 | `contains` | +| 引用、链接到 | `references` | +| 替换、迁移到 | `replaces` | +| 属于 | `belongs_to` | + +实现可以先内置有限谓词表,再允许保留 `custom_predicate`,但 UI 与关系页要展示规范谓词。 + +## 10. 同一知识库内的实体合并 + +实体合并范围只在一个目标知识库内生效,不跨知识库合并。 + +规范实体 key: + +```text +kb_mapping_id + entity_type + normalized_name +``` + +`normalized_name` 规则: + +- Unicode NFKC 归一化。 +- trim。 +- 多空白折叠成单空格。 +- ASCII 字母转小写。 +- 中英文标点归一化。 +- 去除无意义包裹符号,例如反引号、引号。 + +如果实体带有明确外部 ID,可优先使用: + +```text +kb_mapping_id + entity_type + external_id_type + external_id +``` + +合并规则: + +- `normalized_name` 相同且 `entity_type` 兼容:合并。 +- 名称相同但类型不兼容:不合并,例如 `config:web_search` 和 `tool:web_search` 可保持两个实体,必要时用 `configures` 关系连接。 +- LLM 判断别名合并:只有 `confidence >= 0.90` 且有明确证据时允许。 +- 属性冲突不做最后写入覆盖,必须按来源保留 provenance。 + +实体贡献示例: + +```json +{ + "canonical_entity_id": "ce_123", + "binding_id": "bind_001", + "snapshot_id": "snap_001", + "local_entity_id": "ent_001", + "attributes": { + "entrypoint": { + "value": "config.yaml tools.web_search", + "evidence": "config.yaml:10" + } + }, + "confidence": 0.94 +} +``` + +删除贡献时: + +- 只删除该 binding 的贡献。 +- 如果规范实体仍有其他贡献,保留实体页并重新生成聚合内容。 +- 如果规范实体贡献数为 0,删除或标记归档该规范实体及其 Wiki 页面。 + +## 11. 关系合并 + +规范关系 key: + +```text +kb_mapping_id + source_canonical_entity_id + normalized_predicate + target_canonical_entity_id +``` + +合并规则: + +- 同一 key 的关系合并为一条规范关系。 +- 每个技能的证据作为 relation contribution 追加。 +- `confidence` 可存储最大值、平均值和证据数,但展示优先显示最高置信证据。 +- 如果来源实体或目标实体被删除到无贡献,相关关系也需要重新计算。 + +关系 Wiki 页面内容必须保留: + +- 关系三元组。 +- 关系说明。 +- 贡献技能列表。 +- 证据路径。 +- 置信度。 +- 最近更新时间。 + +## 12. WeKnora 内容投影 + +每个技能归纳到一个目标知识库后,按目标 adapter 写入三类内容。目标是 WeKnora 时写入 WeKnora;目标是助手知识库或只读 sidecar 时写入 DeerFlow 自研存储。 + +### 12.1 原始资料 + +原始资料分两种: + +- 可直接上传的文件:走 WeKnora 文件上传。 +- 纯文本或需要展开的内容:创建手动文档,保留路径和 fenced code。 + +原始资料命名建议: + +```text +skills/{skill_name}/source/{relative_path} +``` + +文档 metadata 建议: + +```json +{ + "source_type": "deerflow_skill", + "skill_name": "example-skill", + "binding_id": "bind_001", + "snapshot_id": "snap_001", + "source_digest": "sha256:...", + "relative_path": "SKILL.md", + "artifact_kind": "raw_source" +} +``` + +如果 WeKnora 不支持自定义 metadata,则本地 artifact 表必须保存远端 ID、slug、digest、kind 和来源关系。 + +### 12.2 技能 Wiki 页面 + +每个技能至少生成: + +```text +skills/{skill_name}/overview +skills/{skill_name}/files +skills/{skill_name}/entities +skills/{skill_name}/relations +skills/{skill_name}/evidence +``` + +内容要求: + +- `overview`:技能用途、输入、输出、依赖、使用方式、风险提示。 +- `files`:重要文件清单、每个文件摘要、关键入口。 +- `entities`:该技能贡献的实体,使用 Wiki 链接指向规范实体页。 +- `relations`:确定关系和高置信推断关系,使用 Wiki 链接指向规范关系页。 +- `evidence`:证据目录,只包含短摘录和位置,不包含敏感内容。 + +### 12.3 规范实体 Wiki 页面 + +规范实体页 slug: + +```text +entities/{entity_type}/{stable_entity_id}-{slugified_name} +``` + +页面内容: + +- 实体名称和类型。 +- 别名。 +- 归并来源技能。 +- 聚合描述。 +- 属性表,冲突属性按来源列出。 +- 相关关系列表。 +- 证据索引。 + +### 12.4 规范关系 Wiki 页面 + +规范关系页 slug: + +```text +relations/{predicate}/{stable_relation_id} +``` + +页面内容: + +- `[[source_entity]] --predicate--> [[target_entity]]` +- 中文说明。 +- 贡献技能。 +- 证据。 +- 置信度。 +- 更新时间。 + +说明:即便 WeKnora Wiki graph 只能展示无类型链接,关系类型也不能丢。类型和证据由关系页保存,Wiki 链接负责把实体页和关系页连接起来。 + +### 12.5 图谱投影文档 + +为提高 WeKnora GraphRAG 抽取成功率,每个 binding 生成一份机器可读关系文档: + +```text +skills/{skill_name}/graph-projection.jsonl +``` + +每行一个关系: + +```json +{ + "source": "web_search", + "source_type": "tool", + "predicate": "used_by", + "target": "example-skill", + "target_type": "skill", + "description": "web_search 被 example-skill 用于首步检索", + "confidence": 0.88, + "evidence": "SKILL.md:22-28" +} +``` + +同时生成一份人类可读 Markdown: + +```text +skills/{skill_name}/graph-projection.md +``` + +该文档使用短句和固定模板,降低 WeKnora 抽取歧义: + +```markdown +# 技能关系投影:example-skill + +- 实体 `web_search`(tool)与实体 `example-skill`(skill)存在 `used_by` 关系。 + - 说明:web_search 被 example-skill 用于首步检索。 + - 证据:SKILL.md:22-28 + - 置信度:0.88 +``` + +GraphRAG 可用时上传这些投影文档;Neo4j direct writer 开启时,同时把通过审核的规范实体和关系写入底层图数据库;两者都不可用时仍保留 Wiki 关系页和本地关系表。 + +## 13. 重归纳一致性 + +重归纳必须满足“成功后替换,失败不影响旧版本”。 + +流程: + +1. 读取当前 binding 和 active snapshot。 +2. 重新扫描技能目录,生成新 snapshot。 +3. 如果 digest 未变化且未强制重归纳,可以直接返回 `synced`。 +4. 在本地创建 staged artifact 记录。 +5. 写入 WeKnora 新版本原始资料、Wiki 页面、关系投影文档。 +6. 校验远端可读取或状态完成。 +7. 在一个数据库事务中: + - 将旧 artifact 标记为 `retired`。 + - 将 staged artifact 标记为 `active`。 + - 将 binding 的 `current_snapshot_id` 指向新 snapshot。 + - 更新实体和关系贡献。 + - 设置 binding 状态为 `synced` 或 `partial_failed`。 +8. 对旧远端文件执行 best-effort 清理。 +9. 清理失败只记录 warning,不回滚已经激活的新版本。 + +如果第 5 或第 6 步失败: + +- binding 仍指向旧 snapshot。 +- 旧 WeKnora 内容继续有效。 +- staged artifact 标记为 `failed`。 +- item 状态为 `failed` 或 `partial_failed`。 +- 前端显示错误,允许 retry / 重新归纳。 + +Wiki 页面建议采用稳定 slug upsert。原始资料如果 WeKnora 没有 update API,则创建新远端文档,确认成功后再删除旧远端文档。 + +## 14. 解除关联 + +解除一个技能与一个知识库的绑定时,前端必须弹窗让管理员选择: + +| 选项 | 行为 | +| --- | --- | +| 保留知识库内容 | 绑定标记为 `detached_keep`。WeKnora 远端内容、助手库 Wiki revision 或 sidecar 内容保留为冻结知识,不再随技能重归纳更新。技能页不再显示为“当前归纳”,但可在历史中显示“已解除,内容保留”。 | +| 删除该技能贡献的内容 | 删除该 binding 的原始资料、技能 Wiki 页、关系投影文档、助手库 Wiki revision contribution、sidecar artifact 和实体/关系贡献;共享实体/关系只减少该技能贡献,仍有其他贡献则重建聚合页,没有贡献才删除。 | + +如果删除远端内容部分失败: + +- 本地记录 artifact `delete_failed`。 +- binding 状态为 `detached_delete_pending_cleanup`。 +- 提供“重试清理”动作。 + +不要因为某个共享实体由该技能参与过,就删除其他技能还在使用的实体页。 + +## 15. 状态机 + +绑定状态建议: + +| 状态 | 含义 | +| --- | --- | +| `pending` | 已创建绑定,但尚未开始处理。 | +| `queued` | 已进入任务队列。 | +| `running` | 正在处理。 | +| `awaiting_review` | 抽取完成,等待管理员审核实体、关系或 Wiki 草稿。 | +| `synced` | 当前版本已成功归纳到目标知识库。 | +| `pending_remote_write` | 只读目标已完成本地 sidecar 归纳,等待远端具备写权限后推送。 | +| `stale` | 技能源文件已变化,但尚未手动重新归纳。 | +| `partial_failed` | 核心 Wiki/原始资料成功,但部分文件、图谱或清理失败。 | +| `failed` | 当前归纳失败,旧版本若存在则继续有效。 | +| `detached_keep` | 已解除关联,远端内容保留。 | +| `detached_delete_pending_cleanup` | 已解除关联,但删除远端内容未完全成功。 | +| `detached_deleted` | 已解除关联,远端贡献已清理。 | + +item 阶段建议: + +```text +scanning +parsing +summarizing +extracting +awaiting_review +merging +uploading_sources +writing_wiki +writing_graph_projection +verifying +activating +cleanup +completed +``` + +前端展示时,父任务进度由 item 数量和阶段加权计算,不要只显示一个不可解释的 spinner。 + +## 16. 持久化设计 + +下面是建议表结构,字段命名可按项目现有风格调整。 + +### 16.1 `skill_knowledge_bindings` + +保存技能与知识库的多对多关系。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `skill_name` | 技能名。 | +| `target_type` | `weknora` / `assistant`。 | +| `target_mode` | `wiki` / `document` / `faq` / `conversation_archive` / `readonly_sidecar`。 | +| `target_id` | 目标知识库 ID。WeKnora 使用本地 mapping ID,助手库使用全局 base ID。 | +| `remote_business_key` | 暴露给前端和外部系统的远端业务 key,例如 `weknora:kb:xxx`,可为空。 | +| `status` | 绑定状态。 | +| `active` | 是否当前有效绑定。 | +| `current_snapshot_id` | 当前激活的技能快照。 | +| `source_digest` | 最近扫描到的技能源 digest。 | +| `synced_digest` | 最近成功归纳的 digest。 | +| `last_job_id` | 最近一次父任务。 | +| `last_item_id` | 最近一次 item。 | +| `last_synced_at` | 最近成功归纳时间。 | +| `last_error_code` | 最近错误码。 | +| `last_error_message` | 最近错误摘要。 | +| `created_by` | 管理员用户。 | +| `created_at` / `updated_at` | 时间戳。 | + +约束: + +```text +unique(skill_name, target_type, target_id) +``` + +### 16.2 `skill_knowledge_snapshots` + +保存技能扫描和抽取结果。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `skill_name` | 技能名。 | +| `source_digest` | 技能目录 digest。 | +| `extractor_version` | 抽取器版本。 | +| `prompt_hash` | 提示词 hash。 | +| `model_name` | 模型名。 | +| `manifest_json` | 文件清单、hash、安全状态。 | +| `ir_json` | 抽取后的 IR;较大时可存 blob 引用。 | +| `status` | `ready` / `failed`。 | +| `counts_json` | 文件数、实体数、关系数、跳过数。 | +| `created_at` | 创建时间。 | + +约束: + +```text +unique(skill_name, source_digest, extractor_version, prompt_hash, model_name) +``` + +### 16.3 `skill_knowledge_sync_jobs` + +保存一次批量归纳任务。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_count` | 本次目标知识库数量。 | +| `targets_json` | 目标知识库列表,每项包含 `target_type`、`target_mode`、`target_id`、展示名和远端业务 key。 | +| `status` | `queued` / `running` / `completed` / `partial_failed` / `failed` / `canceled`。 | +| `total_count` | 技能数。 | +| `success_count` | 成功数。 | +| `partial_failed_count` | 部分失败数。 | +| `failed_count` | 失败数。 | +| `cancel_requested` | 是否请求取消。 | +| `created_by` | 管理员用户。 | +| `created_at` / `started_at` / `completed_at` | 时间戳。 | + +### 16.4 `skill_knowledge_sync_items` + +保存每个技能的任务项。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `job_id` | 父任务。 | +| `binding_id` | 绑定。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `skill_name` | 技能名。 | +| `snapshot_id` | 本次输入快照。 | +| `status` | item 状态。 | +| `phase` | 当前阶段。 | +| `progress` | 0-100。 | +| `active_dedupe_key` | 活跃任务去重 key。 | +| `lease_owner` | 执行器标识。 | +| `lease_until` | 租约过期时间。 | +| `attempts` | 尝试次数。 | +| `last_error_code` | 错误码。 | +| `last_error_message` | 错误摘要。 | +| `created_at` / `updated_at` / `started_at` / `completed_at` | 时间戳。 | + +活跃去重 key: + +```text +skill_name + target_type + target_id +``` + +同一绑定同一时间只能有一个 active item。 + +### 16.5 `skill_knowledge_artifacts` + +保存本地到 WeKnora 的远端 artifact 映射。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `binding_id` | 绑定,可为空;规范实体/关系聚合页可只属于知识库。 | +| `snapshot_id` | 来源快照。 | +| `scope` | `binding` / `kb_aggregate`。 | +| `kind` | `raw_source` / `skill_wiki` / `entity_wiki` / `relation_wiki` / `graph_projection`。 | +| `stable_key` | 本地稳定 key。 | +| `remote_kind` | `file` / `manual_document` / `wiki_page`。 | +| `remote_id` | 远端 ID。后端保存完整映射,前端可按权限展示和复制。 | +| `wiki_slug` | Wiki 页面 slug。 | +| `content_hash` | 写入内容 hash。 | +| `version` | 版本号。 | +| `status` | `staged` / `active` / `retired` / `failed` / `delete_failed`。 | +| `created_at` / `updated_at` | 时间戳。 | + +### 16.6 `skill_knowledge_entities` + +规范实体表。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `entity_type` | 实体类型。 | +| `normalized_name` | 归一化名称。 | +| `display_name` | 展示名称。 | +| `aliases_json` | 别名。 | +| `description` | 聚合描述。 | +| `stable_slug` | Wiki slug。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +约束: + +```text +unique(target_type, target_id, entity_type, normalized_name) +``` + +### 16.7 `skill_knowledge_entity_contributions` + +规范实体的技能贡献。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `canonical_entity_id` | 规范实体 ID。 | +| `binding_id` | 绑定。 | +| `snapshot_id` | 快照。 | +| `local_entity_id` | IR 中的实体 ID。 | +| `attributes_json` | 该技能贡献的属性。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +### 16.8 `skill_knowledge_relations` + +规范关系表。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `source_entity_id` | 源规范实体。 | +| `predicate` | 规范谓词。 | +| `target_entity_id` | 目标规范实体。 | +| `description` | 聚合说明。 | +| `stable_slug` | Wiki slug。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +约束: + +```text +unique(target_type, target_id, source_entity_id, predicate, target_entity_id) +``` + +### 16.9 `skill_knowledge_relation_contributions` + +规范关系的技能贡献。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `canonical_relation_id` | 规范关系 ID。 | +| `binding_id` | 绑定。 | +| `snapshot_id` | 快照。 | +| `local_relation_id` | IR 中的关系 ID。 | +| `relation_source` | `deterministic` / `explicit_text` / `llm_inferred`。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +## 17. 后台任务设计 + +参考现有 deep research job 的持久化后台任务模式: + +- 任务入库后返回 `202 Accepted`。 +- 执行器通过 DB 原子条件 claim item。 +- 每个 item 有 `lease_owner`、`lease_until`。 +- 执行中定期 heartbeat。 +- 服务重启后可恢复过期 lease。 +- cancel 使用 `cancel_requested`,任务在阶段边界检查并安全停止。 + +并发建议: + +- 同时处理技能数:2 到 4。 +- 同时调用 WeKnora 写入:2 到 3。 +- 同时 LLM 抽取:按系统现有限流,建议默认 2。 + +200 个技能批量时: + +- 父 job 只保存聚合进度,不串行阻塞 HTTP 请求。 +- 每个技能 item 独立失败、独立重试。 +- 前端按 job polling,不为每个技能单独发 N 个请求。 +- 获取技能列表时批量查 binding summary,避免 N+1。 +- 快照缓存可复用,跨知识库只重新投影,不重复解析同一技能内容。 + +## 18. 后端 API 设计 + +新增 router: + +```text +/api/skill-knowledge +``` + +所有写接口仅管理员可用。 + +### 18.1 获取技能绑定摘要 + +```http +GET /api/skill-knowledge/bindings?skill_names=a,b,c +``` + +返回: + +```json +{ + "items": [ + { + "skill_name": "example-skill", + "knowledge_bases": [ + { + "binding_id": "bind_001", + "target_type": "weknora", + "target_mode": "wiki", + "target_id": "local-kb-id", + "knowledge_base_name": "作战知识库", + "remote_business_key": "weknora:kb:remote-kb-id", + "status": "synced", + "last_synced_at": "2026-08-31T10:00:00Z", + "source_digest": "sha256:...", + "synced_digest": "sha256:...", + "stale": false + } + ] + } + ] +} +``` + +### 18.2 创建归纳任务 + +```http +POST /api/skill-knowledge/sync-jobs +``` + +请求: + +```json +{ + "skill_names": ["skill-a", "skill-b"], + "targets": [ + { + "target_type": "weknora", + "target_mode": "wiki", + "target_id": "local-kb-id" + }, + { + "target_type": "assistant", + "target_mode": "wiki", + "target_id": "assistant-global-base" + } + ], + "force": false +} +``` + +行为: + +- 校验管理员。 +- 校验技能存在。 +- 校验目标知识库存在,并解析目标 adapter。 +- 对只读目标创建 sidecar binding,不强行写远端。 +- 对每个 `skill x target` upsert binding。 +- 创建父 job 和 item。 +- 如果同一 binding 已有 active item,返回当前 item,不重复创建。 +- 返回 `202`。 + +返回: + +```json +{ + "job_id": "job_001", + "status": "queued", + "skill_count": 2, + "target_count": 2, + "total_count": 4, + "items": [ + { + "item_id": "item_001", + "skill_name": "skill-a", + "target_type": "weknora", + "target_mode": "wiki", + "target_id": "local-kb-id", + "binding_id": "bind_001", + "status": "queued" + } + ] +} +``` + +### 18.3 查询任务 + +```http +GET /api/skill-knowledge/sync-jobs/{job_id} +``` + +返回父 job、item 列表、每个 item 当前阶段、错误摘要、绑定结果。 + +### 18.4 取消任务 + +```http +POST /api/skill-knowledge/sync-jobs/{job_id}/cancel +``` + +设置 `cancel_requested = true`。已激活成功的 item 不回滚;未完成 item 在阶段边界停止。 + +### 18.5 重试 item + +```http +POST /api/skill-knowledge/sync-items/{item_id}/retry +``` + +只允许失败或部分失败 item。重试复用原 binding。 + +### 18.6 对单个绑定重新归纳 + +```http +POST /api/skill-knowledge/bindings/{binding_id}/resync +``` + +请求: + +```json +{ + "force": true +} +``` + +行为:创建一个只包含该 binding 的新 job。 + +### 18.7 解除关联 + +建议使用显式 POST,避免 DELETE body 兼容性问题: + +```http +POST /api/skill-knowledge/bindings/{binding_id}/detach +``` + +请求: + +```json +{ + "remote_content_action": "keep" +} +``` + +或: + +```json +{ + "remote_content_action": "delete" +} +``` + +返回解除结果、清理状态和仍被共享的实体/关系数量。 + +### 18.8 知识库反查技能 + +```http +GET /api/skill-knowledge/knowledge-bases/{knowledge_base_id}/skills +``` + +用于知识库详情页展示“该知识库由哪些技能归纳而来”。 + +### 18.9 可选:源状态扫描 + +```http +POST /api/skill-knowledge/source-status +``` + +请求: + +```json +{ + "skill_names": ["skill-a", "skill-b"], + "knowledge_base_id": "local-kb-id" +} +``` + +只做本地快速 fingerprint 比对,标记是否可能 stale,不写 WeKnora。 + +### 18.10 审核队列 + +```http +GET /api/skill-knowledge/reviews?job_id=xxx +POST /api/skill-knowledge/reviews/{review_id}/approve +POST /api/skill-knowledge/reviews/{review_id}/reject +PUT /api/skill-knowledge/reviews/{review_id} +POST /api/skill-knowledge/reviews/bulk-approve +POST /api/skill-knowledge/reviews/bulk-reject +``` + +审核通过后 item 才能进入写入阶段。审核模式为 `off` 时,接口仍保留预览和审计能力。 + +### 18.11 自动同步配置 + +```http +GET /api/skill-knowledge/auto-sync-settings +PUT /api/skill-knowledge/auto-sync-settings +POST /api/skill-knowledge/bindings/{binding_id}/enable-auto-sync +POST /api/skill-knowledge/bindings/{binding_id}/disable-auto-sync +``` + +自动同步设置只允许管理员修改。 + +## 19. 前端交互设计 + +### 19.1 技能管理页 + +在 `AdminSkillsPage.tsx` 的“技能列表”页签增加: + +- 技能行 checkbox。 +- 顶部批量操作按钮:“归纳到知识库”。 +- 支持全选当前页、清空选择。 +- 选择状态跨前端分页保留。 +- 技能较多时保持现有搜索/过滤能力。 + +点击“归纳到知识库”打开弹窗: + +- 显示已选技能数量。 +- 多选目标知识库。 +- 按类型分组展示:WeKnora Wiki、WeKnora 非 Wiki、FAQ、系统会话存档、只读 sidecar、助手知识库。 +- 只读目标要展示“本地归纳,暂不写远端”的状态说明。 +- 助手知识库作为目标时,提示“直接写入知识梳理底库,不经过 WeKnora”。 +- 二次确认文案说明:“将生成原始资料、Wiki 页面和关系内容;抽取完成后进入实体/关系预览审核。” + +提交后: + +- 创建后台 job。 +- 跳转或打开“归纳任务”抽屉/页签。 +- 展示总进度、成功、失败、部分失败。 +- 任务运行中每 2-3 秒 polling。 +- item 到达 `awaiting_review` 时,显示“待审核”并提供审核入口。 + +### 19.1.1 实体/关系审核页 + +审核页按 job 聚合展示: + +- 左侧筛选:技能、目标知识库、类型、置信度、来源。 +- 中间列表:实体、关系、实体对齐候选、关系合并候选、Wiki 草稿。 +- 右侧详情:原始值、模型解释、证据、目标写入位置。 + +管理员可以: + +- 单条批准、拒绝、编辑。 +- 批量批准高置信结果。 +- 批量拒绝低置信结果。 +- 修改实体类型、名称、别名。 +- 修改关系 predicate、说明、confidence。 +- 查看远端 ID 或 sidecar ID。 + +审核完成后,任务继续写入目标知识库。 + +### 19.2 技能行提示 + +`SkillRow` 增加归纳状态区域: + +- 未归纳:不显示或显示弱提示“未归纳”。 +- 已归纳:显示 `已归纳至 N 个知识库`。 +- 展开或悬浮可看到知识库 chips: + - 知识库名称 + - 状态 + - 最近归纳时间 + - stale 标识 + +每个绑定提供动作: + +- 查看知识库。 +- 重新归纳。 +- 解除关联。 +- 失败时重试。 + +状态建议: + +| 状态 | UI 文案 | +| --- | --- | +| `synced` | 已归纳 | +| `stale` | 待重新归纳 | +| `running` | 归纳中 | +| `partial_failed` | 部分完成 | +| `failed` | 失败 | +| `detached_keep` | 已解除,内容保留 | + +### 19.3 解除关联弹窗 + +弹窗必须让管理员明确选择: + +- 保留知识库内容。 +- 删除该技能贡献的内容。 + +弹窗文案应提醒: + +- 删除只删除该技能贡献,不删除其他技能共享的实体与关系。 +- 若删除远端内容失败,可稍后重试清理。 + +### 19.4 归纳任务页签 + +建议在管理员技能管理页新增第三个页签: + +```text +技能列表 | 技能地址管理 | 归纳任务 +``` + +任务列表显示: + +- 创建时间。 +- 目标知识库。 +- 操作人。 +- 总技能数。 +- 成功 / 部分失败 / 失败。 +- 状态。 +- 操作:查看详情、取消、重试失败项。 + +详情显示 item: + +- 技能名。 +- 目标知识库。 +- 目标类型。 +- 当前阶段。 +- 进度。 +- 错误摘要。 +- 绑定状态。 + +### 19.6 自动同步设置 + +管理员可在技能归纳设置中配置: + +- 是否开启文件监听。 +- 是否开启定时同步。 +- 同步周期。 +- 自动写远端开关。 +- 审核模式。 +- 单个 binding 是否参与自动同步。 + +### 19.5 知识库详情反向展示 + +如果现有 WeKnora / LLM Wiki 页面有知识库详情入口,增加“来源技能”区域: + +- 技能名。 +- 归纳状态。 +- 最近归纳时间。 +- 查看技能。 +- 重新归纳。 + +## 20. stale 检测与自动同步 + +系统既要支持手动重新归纳,也要支持自动同步。stale 检测负责判断“源知识文件是否变化”;自动同步负责在配置允许时创建后台任务并远程写入。 + +本地标识 stale: + +- 技能编辑、启停、删除等已知写操作完成后,查找相关 binding 并标记 `stale`。 +- 技能列表加载时可做轻量 fingerprint:路径、大小、mtime。 +- 真正归纳时计算完整 SHA digest。 +- 如果归纳过程中源文件变化,最终激活前再次校验 expected digest;不一致则失败并标记 stale,避免一个版本混入两次源内容。 +- 代码文件和脚本文件不参与 fingerprint,也不触发 stale。 + +自动同步: + +- 当 `watch_enabled` 开启时,知识文件变化后自动创建相关 binding 的归纳任务。 +- 当 `schedule_enabled` 开启时,scheduler 按周期扫描并归纳 stale binding。 +- 当技能保存、导入、删除或启停时,可触发相关 binding 的自动任务。 +- 自动任务必须遵循审核模式;如果需要审核,任务停在 `awaiting_review`。 +- 自动任务必须遵循 staged 激活;失败不覆盖旧版本。 + +## 21. 权限与安全 + +权限: + +- 所有归纳、重归纳、解除关联、取消、重试接口仅管理员。 +- 读接口如果包含后台任务错误或源文件路径,也建议仅管理员。 + +文件安全: + +- 技能根必须限制在 `skills/public` 或 `skills/custom`。 +- 禁止路径穿越。 +- 默认不跟随 symlink;如果要支持 symlink,必须确认 resolved path 仍在技能根内。 +- 压缩包解压必须限制总大小、文件数量、单文件大小、目录层级。 +- 不执行技能代码。 +- 不读取或上传密钥文件。 +- 日志和错误信息不得包含文件全文、token、secret。 + +远端安全: + +- 不新增外部 URL 抓取能力。 +- 如果技能内容中包含 URL,只作为文本关系或知识内容处理,不主动访问。 +- WeKnora 调用失败时,记录错误摘要和 request id,不记录认证头。 + +## 22. LLM 抽取设计 + +抽取可分两级: + +- 小技能:一次性把文本摘要和结构信息送入模型。 +- 大技能:先按文件或章节生成局部摘要与候选实体关系,再合并。 + +提示词需要明确: + +- 只基于提供的内容抽取,不凭空编造。 +- 允许推断,但推断必须有 evidence。 +- 输出必须符合 JSON schema。 +- 不输出敏感信息。 +- 关系必须有 confidence。 +- 低置信关系要标记,不进入规范关系。 + +建议 schema: + +```json +{ + "summary": { + "purpose": "技能用途", + "inputs": ["输入"], + "outputs": ["输出"], + "usage": "使用方式", + "risks": ["风险"] + }, + "entities": [ + { + "id": "ent_001", + "type": "skill|tool|api|file|config|dataset|concept|workflow|external_system|output", + "name": "实体名", + "aliases": [], + "description": "描述", + "attributes": {}, + "evidence": [ + { + "file_path": "SKILL.md", + "line_start": 1, + "line_end": 3, + "quote": "短证据" + } + ], + "confidence": 0.9 + } + ], + "relations": [ + { + "id": "rel_001", + "source_entity_id": "ent_001", + "predicate": "uses", + "target_entity_id": "ent_002", + "description": "关系描述", + "relation_source": "explicit_text|llm_inferred", + "evidence": [], + "confidence": 0.86 + } + ] +} +``` + +模型输出后必须做程序校验: + +- JSON schema 校验。 +- entity id 存在性校验。 +- relation source/target 必须存在。 +- evidence path 必须来自本次 manifest。 +- confidence 必须在 0 到 1。 +- 敏感内容二次扫描。 + +## 23. 后端实现步骤 + +建议按以下顺序实现。 + +### 23.1 持久化层 + +新增 `deerflow.persistence.skill_knowledge`: + +- SQL model。 +- memory store,用于测试或无 DB 环境。 +- repository API。 +- migration 或 `create_all` 接入。 + +Repository API 建议: + +```python +class SkillKnowledgeStore: + async def upsert_binding(...) + async def list_bindings(...) + async def create_job(...) + async def create_items(...) + async def claim_next_item(...) + async def heartbeat_item(...) + async def complete_item(...) + async def fail_item(...) + async def create_or_get_snapshot(...) + async def stage_artifacts(...) + async def activate_snapshot(...) + async def detach_binding(...) +``` + +### 23.2 扫描与解析 + +新增: + +```text +deerflow/skill_knowledge/scanner.py +deerflow/skill_knowledge/parsers.py +deerflow/skill_knowledge/safety.py +deerflow/skill_knowledge/digest.py +``` + +输出 manifest 和初始文本块。 + +### 23.3 抽取与归纳 + +新增: + +```text +deerflow/skill_knowledge/extractor.py +deerflow/skill_knowledge/wiki_builder.py +``` + +职责: + +- 构造 prompt。 +- 调用项目已有模型封装。 +- 解析 JSON。 +- 校验 schema。 +- 构建技能 Wiki 页面内容。 + +### 23.4 实体关系合并 + +新增: + +```text +deerflow/skill_knowledge/merge.py +``` + +职责: + +- 名称归一化。 +- 实体 upsert。 +- 关系 upsert。 +- 贡献增删。 +- 共享实体/关系重建计划。 + +### 23.5 WeKnora 投影 + +新增: + +```text +deerflow/skill_knowledge/projector.py +deerflow/skill_knowledge/target_adapters.py +deerflow/skill_knowledge/graph_writer.py +``` + +职责: + +- 根据 IR 和聚合结果生成 artifact plan。 +- 按目标知识库类型选择 adapter。 +- 调用 WeKnora client 上传原文、FAQ、会话归档或 Wiki 页面。 +- 为只读目标写本地 sidecar artifact。 +- 上传 graph projection docs。 +- 在配置允许时通过受控 graph writer 写 Neo4j。 +- 校验远端状态。 + +### 23.6 Gateway + +新增: + +```text +app/gateway/routers/skill_knowledge.py +app/gateway/skill_knowledge_job_dispatcher.py +app/gateway/skill_knowledge_job_executor.py +app/gateway/skill_knowledge_watcher.py +app/gateway/skill_knowledge_scheduler.py +``` + +在 app 初始化中注册: + +- store。 +- dispatcher。 +- watcher。 +- scheduler。 +- router。 + +### 23.7 前端 + +新增: + +```text +frontend-web/src/strategy-components/api/skill-knowledge.ts +frontend-web/src/components/workspace/skills/skill-knowledge-sync-dialog.tsx +frontend-web/src/components/workspace/skills/skill-knowledge-bindings.tsx +frontend-web/src/components/workspace/skills/skill-knowledge-jobs-panel.tsx +``` + +修改: + +- `AdminSkillsPage.tsx`:技能列表批量选择、归纳弹窗、归纳任务页签。 +- `skill-settings-page.tsx`:`SkillRow` 展示归纳知识库 chips 和操作。 +- 知识库详情页:展示来源技能。 + +### 23.8 助手知识库 + +新增: + +```text +offline-backend-20260512/backend/packages/harness/deerflow/assistant_knowledge/ +offline-backend-20260512/backend/packages/harness/deerflow/persistence/assistant_knowledge/ +offline-backend-20260512/backend/app/gateway/routers/assistant_knowledge.py +offline-backend-20260512/backend/app/gateway/assistant_knowledge_import_executor.py +frontend-web/src/strategy-components/api/assistant-knowledge.ts +frontend-web/src/pages/AssistantKnowledgePage.tsx +``` + +职责: + +- 初始化唯一全局助手知识库“知识梳理”。 +- 从 WeKnora 导出包导入 Wiki、文档、chunk、实体、关系、图谱。 +- 做全局实体对齐和关系合并。 +- 生成助手库 Wiki 页面。 +- 建立搜索索引。 +- 支持普通用户只读、管理员导入和维护。 +- 支持知识库产品版本 `v1` / `v2` 切换。 + +### 23.9 审核队列 + +新增: + +```text +offline-backend-20260512/backend/packages/harness/deerflow/persistence/skill_knowledge/review_model.py +frontend-web/src/components/workspace/skills/skill-knowledge-review-panel.tsx +``` + +职责: + +- 保存实体、关系、对齐候选、Wiki 草稿的审核项。 +- 支持批准、拒绝、编辑、批量处理。 +- 审核完成后唤醒对应 item 继续写入。 +- 保留完整审计。 + +## 24. 测试计划 + +后端测试: + +| 测试 | 覆盖点 | +| --- | --- | +| `test_skill_knowledge_scanner.py` | 文件扫描、排除目录、敏感文件阻断、压缩包安全。 | +| `test_skill_knowledge_extractor.py` | LLM 输出 schema 校验、低置信关系过滤、证据路径校验。 | +| `test_skill_knowledge_entity_merge.py` | 同知识库实体合并、跨知识库不合并、属性冲突 provenance。 | +| `test_skill_knowledge_repository.py` | 绑定唯一、多对多、状态机、detach keep/delete。 | +| `test_skill_knowledge_jobs.py` | job/item claim、lease、重试、取消、崩溃恢复、200 item 聚合进度。 | +| `test_skill_knowledge_weknora_projection.py` | Wiki slug upsert、artifact staged/active、GraphRAG 不可用 partial_failed。 | +| `test_skill_knowledge_api.py` | 管理员鉴权、目标知识库校验、创建任务、查询任务、解除关联。 | +| `test_skill_knowledge_target_adapters.py` | Wiki、非 Wiki、FAQ、会话存档、只读 sidecar、助手库目标写入。 | +| `test_skill_knowledge_review.py` | 实体/关系逐条审核、编辑、批量批准、审核阻塞写入。 | +| `test_skill_knowledge_auto_sync.py` | 文件监听、定时同步、代码文件变化不触发、staged 自动写入。 | +| `test_skill_knowledge_graph_writer.py` | Neo4j 参数化写入、事务、审计、回滚计划、禁用任意 Cypher。 | +| `test_assistant_knowledge_repository.py` | 唯一全局底库、来源、导入任务、实体关系贡献。 | +| `test_assistant_knowledge_import.py` | WeKnora 导出包导入、实体对齐、关系合并、搜索索引。 | +| `test_assistant_knowledge_api.py` | 普通用户只读、管理员写入、未配置 WeKnora 场景。 | + +前端测试: + +- 管理员技能页可以多选技能并打开知识库选择弹窗。 +- 知识库选择弹窗支持多选目标,包含 Wiki、非 Wiki、FAQ、会话存档、只读 sidecar 和助手知识库。 +- 创建 job 后显示进度并 polling。 +- 抽取后进入实体/关系审核页,审核通过后继续写入。 +- 技能行展示已归纳知识库 chips。 +- 重归纳按钮调用正确接口。 +- 解除关联弹窗必须选择 keep/delete。 +- 200 个技能绑定摘要使用批量接口,不触发 N+1。 +- 未配置 WeKnora 时仍能展示助手知识库初始化入口。 +- 普通知识库详情页可触发导入到助手知识库。 +- 助手知识库搜索、Wiki 页面、来源列表按权限展示。 + +建议命令: + +```bash +cd offline-backend-20260512/backend +PYTHONPATH=. uv run pytest tests/test_skill_knowledge_*.py -v +PYTHONPATH=. uv run pytest tests/test_harness_boundary.py -v +``` + +```bash +cd frontend-web +pnpm typecheck +pnpm test -- skill-knowledge +``` + +## 25. 验收场景 + +必须通过: + +- 管理员选择 3 个技能归纳到 2 个目标知识库,生成 1 个 job、6 个 item、6 个 binding。 +- 同一个技能随后归纳到另一个知识库,新增第二个 binding,技能行显示已归纳至 2 个知识库。 +- 技能可以直接归纳到助手知识库,并生成实体对齐、Wiki 页面版本和搜索索引。 +- 同一个技能同一个 digest 重复归纳,不产生重复 Wiki 页面或重复实体关系。 +- 两个技能在同一知识库中抽取出同名同类型实体,只生成一个规范实体页,并列出两个贡献技能。 +- 解除其中一个技能并选择删除内容后,共享实体仍保留另一个技能贡献。 +- 关系必须带 evidence 和 confidence;低于 0.75 的推断关系不进入规范关系。 +- 重归纳成功后,新版本替换旧版本;旧 artifact 进入 retired。 +- 重归纳失败时,旧版本继续显示为当前版本。 +- 解除关联选择保留内容后,远端 Wiki 和文档保留,但 binding 不再参与后续重归纳。 +- 非管理员访问写接口返回 403。 +- 非 Wiki、FAQ、系统会话存档知识库可作为目标,并通过对应 adapter 写入。 +- 只读知识库可作为目标,结果写入本地 sidecar,远端状态显示 `pending_remote_write`。 +- 直接 Neo4j 写入只在配置开启时执行,且只使用参数化 graph writer。 +- 自动文件监听和定时同步可以创建后台任务,代码文件变化不会触发同步。 +- 前端可逐条审核实体/关系,也可按策略批量处理。 +- 前端展示 WeKnora 远端 ID,并可复制业务 key。 +- WeKnora GraphRAG 不可用时,原始资料和 Wiki 成功,状态为 `partial_failed`,可重试图谱投影。 +- 200 个技能批量归纳时,页面显示整体进度,失败技能可单独重试,后端没有 N+1 查询。 +- 技能中出现 `.env` 或私钥时,该文件不上传、不进 prompt,页面显示安全跳过。 +- 技能中的 `.py`、`.ts`、`.sh` 等代码文件不读取、不上传、不摘要,代码文件变化不触发知识 stale。 +- 未配置 WeKnora 时,助手知识库仍可初始化和查看。 +- 管理员可从普通知识库触发导入到助手知识库。 +- 管理员可向助手知识库导入本地文件,安全扫描通过后生成 Wiki 页面版本。 +- WeKnora 导出包中的 Wiki、文档、chunk、实体、关系、图谱都能进入助手底库。 +- 助手底库把多个来源中的同一人或同一对象合并为一个实体 Wiki 页。 +- 助手 Wiki 支持历史版本、对比和回滚。 +- 助手知识库普通用户只读,管理员可导入、重导、删除来源。 + +## 26. 分阶段交付 + +第一阶段:数据模型与 API 骨架 + +- 建表和 store。 +- 绑定列表、创建 job、查询 job、detach API。 +- 管理员鉴权。 +- 前端技能行能展示空绑定状态。 + +第二阶段:扫描、快照与后台任务 + +- 技能目录扫描。 +- 文件 digest 和 manifest。 +- job/item 执行器、lease、heartbeat、失败重试。 +- 200 技能批量进度。 + +第三阶段:Wiki 归纳与原始资料上传 + +- LLM 摘要和 Wiki 页面生成。 +- WeKnora 原文上传。 +- Wiki page upsert。 +- staged/active 替换。 + +第四阶段:实体关系合并与关系投影 + +- 规范实体和关系表。 +- contribution 机制。 +- 关系 Wiki 页面。 +- graph projection docs。 +- GraphRAG 能力探测和 partial_failed。 + +第五阶段:完整前端体验 + +- 多选技能归纳弹窗。 +- 归纳任务页签。 +- 技能行 chips、详情、重归纳、解除关联。 +- 知识库详情来源技能反查。 + +第六阶段:安全、测试、文档收口 + +- 敏感文件阻断。 +- 压缩包安全。 +- 崩溃恢复。 +- README / CLAUDE 更新。 +- 后端和前端测试。 + +第七阶段:助手知识库与产品版本 + +- 新增唯一全局助手知识库“知识梳理”。 +- 新增 WeKnora 导出到助手库导入任务。 +- 新增技能直接导入助手知识库。 +- 新增管理员本地文件导入助手知识库。 +- 新增助手库实体对齐、关系合并和 Wiki 展示。 +- 新增助手库搜索。 +- 新增助手库 Wiki 页面版本管理、对比和回滚。 +- 新增知识库产品版本 `v1` / `v2` 切换。 +- 处理未配置 WeKnora 时的助手库默认展示。 + +第八阶段:多目标、审核、自动同步和底层图写入 + +- 支持一次选择多个目标知识库。 +- 支持非 Wiki、FAQ、系统会话存档和只读目标 adapter。 +- 支持实体/关系逐条审核。 +- 支持文件监听、定时同步、技能变更自动远程写入。 +- 支持受控 Neo4j direct writer。 +- 支持远端 WeKnora ID 作为业务 key 暴露给前端。 + +## 27. 给实现大模型的硬性指令 + +实现时必须遵守: + +- 先完整阅读根目录 `AGENTS.md`、后端 `AGENTS.md`、后端 `CLAUDE.md`。 +- 不要回滚用户已有改动。 +- 支持直接写 WeKnora 的 Neo4j,但必须通过受控 graph writer、显式配置、参数化模板、事务和审计。 +- 支持把远端 WeKnora ID 作为前端业务 key 暴露,但本地仍保留 surrogate id。 +- 不要让 `deerflow.*` import `app.*`。 +- 不要执行技能里的代码。 +- 不要读取、上传、摘要或展示技能里的代码文件、脚本文件和代码块内容。 +- 不要上传或提示词输入密钥文件。 +- 技能可以直接归纳到助手知识库,必须走同样的安全扫描、审核和实体对齐。 +- 不要创建多个助手知识库;助手知识库只有一个全局“知识梳理”底库。 +- 自动 watcher、定时同步和技能变更远程写入必须使用 staged 激活、去重、审核和失败保留旧版本。 +- 重归纳必须 staged 写入,成功后再激活。 +- 删除绑定贡献时必须按 contribution refcount 处理共享实体和关系。 +- 助手知识库需要 Wiki 页面版本管理、版本对比、回滚和导入任务审计。 +- 直接写 Neo4j 必须通过受控 graph writer 和参数化模板,不允许执行 LLM 生成的任意 Cypher。 +- 远端 WeKnora ID 可以作为前端业务 key 暴露,但本地仍要保留 surrogate id 和 mapping。 +- 后端代码变更后按仓库要求更新相关 README / CLAUDE。 +- 涉及文件编辑使用 `apply_patch` 或项目正常格式化工具,不做无关重构。 + +## 28. 实现任务清单 + +- [ ] 新增 `skill_knowledge` 持久化模型和 store。 +- [ ] 新增绑定、job、item、artifact、entity、relation、contribution 表。 +- [ ] 新增管理员 router `/api/skill-knowledge`。 +- [ ] 接入知识库能力校验,支持 Wiki、非 Wiki、FAQ、会话存档、只读 sidecar 和助手知识库。 +- [ ] 支持一次选择多个目标知识库,并按 `技能 x 目标` 生成 item。 +- [ ] 新增技能扫描器,支持全部非代码知识文件、安全排除和 digest。 +- [ ] 技能扫描器完全排除代码文件、脚本文件和 Markdown 代码块内容。 +- [ ] 新增 snapshot 缓存。 +- [ ] 新增 LLM 抽取器,输出 schema 校验。 +- [ ] 新增 Wiki 页面生成器。 +- [ ] 新增实体归一化与合并逻辑。 +- [ ] 新增关系归一化与合并逻辑。 +- [ ] 新增 WeKnora artifact projector。 +- [ ] 新增 target adapters。 +- [ ] 新增 graph projection 文档生成和上传。 +- [ ] 新增受控 Neo4j direct graph writer。 +- [ ] 新增实体/关系审核队列。 +- [ ] 新增后台 job dispatcher / executor。 +- [ ] 新增文件 watcher、定时同步 scheduler、技能变更自动写入触发。 +- [ ] 实现 resync 成功替换、失败保留旧版本。 +- [ ] 实现 detach keep/delete。 +- [ ] 技能变更后标记 binding stale。 +- [ ] 前端新增 `skill-knowledge.ts` API。 +- [ ] 技能管理页新增多选归纳入口。 +- [ ] 新增选择知识库弹窗。 +- [ ] 新增归纳任务页签。 +- [ ] 技能行展示已归纳知识库状态。 +- [ ] 实现重归纳、重试、取消、解除关联 UI。 +- [ ] 知识库详情展示来源技能。 +- [ ] 新增唯一全局助手知识库“知识梳理”。 +- [ ] 新增助手知识库持久化模型、来源、导入任务、Wiki、实体、关系、贡献表。 +- [ ] 新增 WeKnora 导出到助手知识库任务。 +- [ ] 支持技能直接导入助手知识库。 +- [ ] 支持管理员本地文件导入助手知识库。 +- [ ] 导出范围覆盖 Wiki、文档、chunk、实体、关系、图谱。 +- [ ] 助手知识库实现全局实体对齐和关系合并。 +- [ ] 助手知识库生成 Wiki 页面并支持搜索。 +- [ ] 助手知识库支持 Wiki 页面版本管理、对比、发布和回滚。 +- [ ] 新增知识库产品版本 `v1` / `v2` 设置,默认 `v1`。 +- [ ] 未配置 WeKnora 时仍展示助手知识库创建或初始化入口。 +- [ ] 助手知识库普通用户只读,管理员可导入、重导、删除。 +- [ ] 后端测试覆盖核心状态机和安全边界。 +- [ ] 前端测试覆盖主要交互。 +- [ ] 更新后端 README / CLAUDE 与必要前端文档。 + +## 29. 风险与处理 + +| 风险 | 处理 | +| --- | --- | +| WeKnora 图谱 API 在部署版本不稳定 | 以 Swagger 能力探测为准;Wiki 关系页和 graph projection 文档作为稳定方案。 | +| LLM 推断关系误判 | 强制 evidence、confidence 阈值、低置信不进入规范关系。 | +| 200 技能批量耗时长 | 后台 job、item 并发、snapshot 缓存、前端批量查询。 | +| 重归纳中断导致新旧混杂 | staged artifact + 本地事务激活 + 失败保留旧版本。 | +| 删除共享实体误删其他技能知识 | contribution refcount,删除只移除该 binding 贡献。 | +| 敏感信息进入知识库 | 文件级安全阻断、prompt 前二次扫描、日志脱敏。 | +| 技能目录处理阻塞 Gateway | 文件 IO 和转换统一 `asyncio.to_thread`。 | +| 技能源文件归纳中变化 | expected digest 校验,不一致则失败并标记 stale。 | + +## 30. 新增:普通知识库与助手知识库双体系 + +本节是基于新需求的补充设计。后续实现以本节为准;如果本节与前文旧的 WeKnora-only 表述冲突,优先采用本节。 + +### 30.1 产品形态 + +系统需要区分两类知识库: + +| 类型 | 存储 | 用途 | 可见性 | 写入来源 | +| --- | --- | --- | --- | --- | +| 普通知识库 | WeKnora | 文档向量化、Wiki、检索、图谱、技能归纳目标 | 按现有 WeKnora 逻辑 | 技能归纳、现有 WeKnora 导入 | +| 助手知识库 | DeerFlow 自研库 | 全局知识梳理、实体对齐、Wiki 展示、搜索、版本管理 | 所有人可见,普通用户只读 | WeKnora 导出、WeKnora 自动沉淀、技能直接归纳、管理员本地文件导入 | + +产品版本开关: + +- `v1`:默认值,优先展示现有普通知识库体验。 +- `v2`:优先展示助手知识库体验。 +- 管理员可在系统设置中切换版本。 +- 如果未配置 WeKnora,即使当前版本默认是 `v1`,页面也应展示助手知识库创建/初始化入口;WeKnora 相关能力隐藏或置灰,并提示“未配置普通知识库服务”。 + +建议新增系统设置项: + +```json +{ + "knowledge_base_product_version": "v1" +} +``` + +### 30.2 助手知识库定位 + +助手知识库只有一个全局底库,建议默认名: + +```text +知识梳理 +``` + +它不是 WeKnora 的另一个知识库,也不向 WeKnora 写数据。它是 DeerFlow 自己维护的结构化知识底库。 + +助手知识库对用户展示: + +- Wiki 页面列表。 +- Wiki 页面详情。 +- Wiki 页面版本、对比和回滚。 +- 实体页。 +- 关系页。 +- 搜索。 +- 数据导入记录。 + +助手知识库内部可以存实体、关系、贡献、来源、搜索索引,但前端主体验以 Wiki 为中心,不做复杂图数据库管理页。 + +权限: + +- 普通用户:可查看、搜索。 +- 管理员:可初始化助手知识库、从 WeKnora 导入、重新导入、删除导入来源、编辑或删除 Wiki 页面。 + +### 30.3 数据来源 + +助手知识库的数据来源包括 WeKnora、技能归纳和管理员本地文件。 + +支持四种触发方式: + +| 方式 | 说明 | +| --- | --- | +| 自动沉淀 | WeKnora 普通知识库完成文档向量化、Wiki 生成和图谱抽取后,默认创建一个导入任务,把处理结果沉淀到助手知识库。 | +| 手动导入 | 管理员在普通知识库页面点击“导入到助手知识库”或“导出到知识梳理”,本质仍是导出 WeKnora 数据后导入助手库。 | +| 技能直接归纳 | 管理员在技能管理页选择助手知识库作为目标,系统直接写入自研助手底库。 | +| 本地文件导入 | 管理员在助手知识库页面上传本地文件,系统安全扫描后抽取实体、关系和 Wiki 页面。 | + +限制: + +- 普通用户不能写入助手知识库。 +- 本地文件导入必须走敏感信息扫描、文件类型白名单、大小限制和导入审计。 +- 技能直接归纳仍然完全排除代码文件、脚本文件和 Markdown 代码块内容。 + +### 30.4 WeKnora 导出范围 + +从 WeKnora 导入助手知识库时,需要尽可能导出全部已处理数据: + +- 知识库元数据。 +- Wiki 页面。 +- Wiki 页面之间的链接。 +- 已上传文档信息。 +- 已向量化完成的 chunk 文本、chunk metadata、来源文档映射。 +- WeKnora 可读取的实体。 +- WeKnora 可读取的关系。 +- WeKnora 可读取的图谱节点和边。 +- 技能归纳产生的 source metadata,例如 `skill_name`、`binding_id`、`snapshot_id`。 + +说明: + +- 助手知识库不强依赖复用 WeKnora embedding 向量。若 WeKnora 不暴露 embedding 原始向量,导入 chunk 文本和 metadata 即可。 +- 如果 WeKnora 不暴露稳定的图谱导出 API,则使用现有 Wiki graph、关系投影文档、Wiki 关系页和可查询接口重建实体关系。 +- 导入过程要记录能力缺失:例如“已导入 Wiki 与文档,图谱 API 不可用,关系由 Wiki 关系页重建”。 + +导出包建议: + +```json +{ + "export_id": "wk_export_001", + "source": { + "type": "weknora", + "knowledge_base_id": "local-kb-id", + "knowledge_base_name": "作战知识库", + "exported_at": "2026-09-01T10:00:00Z" + }, + "wiki_pages": [], + "documents": [], + "chunks": [], + "entities": [], + "relations": [], + "graph": { + "nodes": [], + "edges": [] + } +} +``` + +### 30.5 助手库实体对齐 + +助手知识库的核心价值是把来自不同 WeKnora 普通知识库、不同技能来源、不同 Wiki 页中的同一实体合并。 + +实体对齐范围是全局助手库: + +```text +assistant_base_id + entity_type + normalized_name +``` + +如果存在更强 ID,则优先: + +```text +assistant_base_id + entity_type + external_id_type + external_id +``` + +对齐规则: + +- 确定性同一:证件号、统一社会信用代码、装备编号、系统唯一 ID 等完全一致,直接合并。 +- 高置信名称同一:同类型实体 normalized name 一致,合并。 +- 模型推断同一:允许模型判断“张三”“张三同志”“Zhang San”是否同一人,但必须给出 evidence 和 confidence。 +- `confidence >= 0.90` 的模型对齐可以自动合并。 +- `0.70 <= confidence < 0.90` 的模型对齐不自动合并,可记录为候选别名或候选关联。 +- `confidence < 0.70` 丢弃。 + +合并后必须保留来源贡献: + +- 来自哪个 WeKnora 知识库。 +- 来自哪个 Wiki 页面或 chunk。 +- 来自哪个技能 binding。 +- 证据文本。 +- 导入任务 ID。 + +属性冲突不覆盖: + +- 不同来源给出不同姓名、职务、时间、地点、数值时,按来源分组展示。 +- Wiki 页面中展示“多来源信息”而不是最后写入覆盖。 + +### 30.6 助手库关系对齐 + +关系 key: + +```text +assistant_base_id + source_assistant_entity_id + normalized_predicate + target_assistant_entity_id +``` + +关系来源: + +- WeKnora 图谱边。 +- WeKnora Wiki graph 链接。 +- 技能归纳时写入 WeKnora 的关系页。 +- graph projection 文档。 +- 模型从 WeKnora 导出包中推断的高置信关系。 + +关系合并后保留多个证据贡献。 + +关系 Wiki 页面建议: + +```text +assistant/relations/{predicate}/{stable_relation_id} +``` + +关系页内容: + +- 源实体。 +- 关系类型。 +- 目标实体。 +- 关系描述。 +- 来源知识库。 +- 来源 Wiki 页。 +- 证据和置信度。 + +### 30.7 助手库 Wiki 生成 + +助手库最终以 Wiki 页面展示。 + +页面类型: + +| 页面 | slug 示例 | 说明 | +| --- | --- | --- | +| 首页 | `assistant/home` | 知识梳理总览、实体数量、来源数量、最近导入。 | +| 来源页 | `assistant/sources/{source_id}` | 某个 WeKnora 知识库导入结果。 | +| 实体页 | `assistant/entities/{type}/{entity_id}-{name}` | 对齐后的全局实体页面。 | +| 关系页 | `assistant/relations/{predicate}/{relation_id}` | 对齐后的全局关系页面。 | +| 主题页 | `assistant/topics/{topic}` | 从 WeKnora Wiki 分类或目录生成。 | + +实体页结构: + +- 标题:实体名。 +- 类型:人物、组织、地点、装备、事件、概念、数据对象等。 +- 摘要:模型基于多来源融合生成。 +- 别名。 +- 多来源属性。 +- 相关实体。 +- 相关关系。 +- 来源 Wiki 页面。 +- 证据。 + +助手库 Wiki 页面需要版本管理。每次 WeKnora 导入、技能直接归纳、管理员本地文件导入、实体对齐重建或人工编辑,都生成新 revision。新 revision 先处于 draft 或 staged 状态,发布成功后成为 current。 + +版本能力: + +- 查看历史版本。 +- 对比两个版本。 +- 回滚到历史版本。 +- 查看版本来源:导入任务、技能归纳任务、人工编辑。 +- 查看版本发布人和发布时间。 + +### 30.8 助手库搜索 + +助手知识库需要支持搜索。 + +V1 建议实现: + +- Wiki 标题搜索。 +- 实体名称和别名搜索。 +- Wiki 正文全文搜索。 +- 来源知识库过滤。 +- 实体类型过滤。 + +实现选择: + +- SQLite 可使用 FTS5。 +- PostgreSQL 可使用 tsvector 或 trigram。 +- 如果现有持久化层不方便,先实现数据库 LIKE + normalized name 索引,后续再升级全文索引。 + +搜索结果只展示 Wiki 页面,不直接展示底层 chunk。 + +### 30.9 助手库持久化表 + +建议新增: + +```text +deerflow.persistence.assistant_knowledge +``` + +核心表: + +#### `assistant_knowledge_base` + +只允许一条 active 记录。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `name` | 默认“知识梳理”。 | +| `status` | `active` / `disabled`。 | +| `created_by` | 初始化管理员。 | +| `created_at` / `updated_at` | 时间戳。 | + +#### `assistant_knowledge_sources` + +记录每个来源。来源可以是 WeKnora 知识库、技能归纳或管理员本地导入。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `source_type` | `weknora` / `skill` / `local_file`。 | +| `knowledge_base_mapping_id` | DeerFlow 本地 WeKnora 映射 ID,可为空。 | +| `skill_name` | 技能名,可为空。 | +| `upload_batch_id` | 本地文件导入批次,可为空。 | +| `source_name` | 来源名称。 | +| `last_import_job_id` | 最近导入任务。 | +| `status` | `active` / `import_failed` / `deleted`。 | +| `created_at` / `updated_at` | 时间戳。 | + +#### `assistant_knowledge_import_jobs` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `source_id` | 来源 ID。 | +| `trigger` | `auto_after_weknora_indexed` / `manual_from_weknora` / `skill_direct` / `manual_file_upload` / `scheduled` / `watcher`。 | +| `status` | `queued` / `running` / `completed` / `partial_failed` / `failed`。 | +| `phase` | `exporting` / `scanning` / `parsing` / `awaiting_review` / `aligning_entities` / `aligning_relations` / `writing_wiki` / `indexing_search` / `completed`。 | +| `counts_json` | 导入页面、实体、关系、chunk 数。 | +| `last_error_message` | 错误摘要。 | +| `created_by` | 操作人;自动任务可为空或 system。 | +| `created_at` / `completed_at` | 时间戳。 | + +#### `assistant_knowledge_wiki_pages` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `slug` | 全局唯一 slug。 | +| `title` | 标题。 | +| `page_type` | `home` / `source` / `entity` / `relation` / `topic`。 | +| `current_revision_id` | 当前发布版本。 | +| `content_markdown` | 当前版本冗余内容,便于列表和搜索。 | +| `summary` | 摘要。 | +| `status` | `active` / `deleted`。 | +| `search_text` | 搜索文本。 | +| `updated_at` | 更新时间。 | + +#### `assistant_knowledge_wiki_revisions` + +保存 Wiki 页面版本。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `page_id` | Wiki 页面。 | +| `revision_no` | 递增版本号。 | +| `content_markdown` | 版本内容。 | +| `summary` | 版本摘要。 | +| `source_id` | 来源,可为空。 | +| `import_job_id` | 导入任务,可为空。 | +| `change_type` | `import` / `skill_sync` / `manual_edit` / `entity_rebuild` / `rollback`。 | +| `status` | `draft` / `staged` / `published` / `superseded`。 | +| `created_by` | 创建人。 | +| `created_at` / `published_at` | 时间戳。 | + +#### `assistant_knowledge_entities` + +保存全局对齐后的实体。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `entity_type` | 实体类型。 | +| `normalized_name` | 归一化名称。 | +| `display_name` | 展示名称。 | +| `aliases_json` | 别名。 | +| `attributes_json` | 聚合属性。 | +| `wiki_page_id` | 实体 Wiki 页。 | +| `status` | `active` / `deleted`。 | + +#### `assistant_knowledge_entity_contributions` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `entity_id` | 全局实体。 | +| `source_id` | WeKnora 来源。 | +| `import_job_id` | 导入任务。 | +| `source_wiki_slug` | 来源 Wiki 页。 | +| `source_chunk_id` | 来源 chunk,可为空。 | +| `source_skill_name` | 来源技能,可为空。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | + +#### `assistant_knowledge_relations` + +保存全局对齐后的关系。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `source_entity_id` | 源实体。 | +| `predicate` | 关系类型。 | +| `target_entity_id` | 目标实体。 | +| `description` | 聚合描述。 | +| `wiki_page_id` | 关系 Wiki 页。 | +| `status` | `active` / `deleted`。 | + +#### `assistant_knowledge_relation_contributions` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `relation_id` | 全局关系。 | +| `source_id` | WeKnora 来源。 | +| `import_job_id` | 导入任务。 | +| `source_wiki_slug` | 来源 Wiki 页。 | +| `graph_edge_id` | WeKnora 图谱边 ID,可为空。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | + +### 30.10 助手库 API + +新增 router: + +```text +/api/assistant-knowledge +``` + +建议接口: + +```http +GET /api/assistant-knowledge/base +POST /api/assistant-knowledge/base/initialize +GET /api/assistant-knowledge/wiki-pages +GET /api/assistant-knowledge/wiki-pages/{slug} +GET /api/assistant-knowledge/wiki-pages/{slug}/revisions +GET /api/assistant-knowledge/wiki-pages/{slug}/revisions/{revision_id} +POST /api/assistant-knowledge/wiki-pages/{slug}/revisions/{revision_id}/rollback +GET /api/assistant-knowledge/search?q=xxx&type=entity&source_id=xxx +GET /api/assistant-knowledge/entities/{id} +GET /api/assistant-knowledge/relations/{id} +GET /api/assistant-knowledge/sources +GET /api/assistant-knowledge/import-jobs +GET /api/assistant-knowledge/import-jobs/{id} +POST /api/assistant-knowledge/sources/{source_id}/reimport +POST /api/assistant-knowledge/imports/files +POST /api/assistant-knowledge/imports/skills +DELETE /api/assistant-knowledge/sources/{source_id} +``` + +新增 WeKnora 普通知识库导出或导入触发接口: + +```http +POST /api/llmwiki/knowledge-bases/{knowledge_base_id}/export-to-assistant +``` + +行为: + +- 校验管理员。 +- 校验助手知识库已初始化;未初始化则自动创建“知识梳理”或提示管理员确认创建。 +- 校验 WeKnora 知识库已完成处理。 +- 创建 `assistant_knowledge_import_jobs`。 +- 后台导出 WeKnora 数据并导入助手库。 + +本地文件导入接口: + +```http +POST /api/assistant-knowledge/imports/files +``` + +使用 multipart 上传文件。后端必须先完成安全扫描和敏感信息阻断,再进入解析、抽取、审核、实体对齐和 Wiki 版本生成。 + +技能直接导入接口: + +```http +POST /api/assistant-knowledge/imports/skills +``` + +请求: + +```json +{ + "skill_names": ["skill-a", "skill-b"], + "force": false +} +``` + +该接口也可以由 `/api/skill-knowledge/sync-jobs` 在目标类型为 `assistant` 时内部复用。 + +系统设置接口需要支持: + +```http +GET /api/system-settings/knowledge-base-product-version +PUT /api/system-settings/knowledge-base-product-version +``` + +### 30.11 前端页面 + +知识库入口需要支持产品版本切换。 + +建议: + +- 管理员设置页提供“知识库版本”切换:`v1 普通知识库` / `v2 助手知识库`,默认 `v1`。 +- 知识库页面顶部用 Tabs 或 Segmented Control 区分“普通知识库”和“助手知识库”。 +- 未配置 WeKnora 时,普通知识库 tab 隐藏或禁用;助手知识库 tab 正常显示。 +- 助手知识库未初始化时,展示“创建知识梳理库”按钮。 +- 创建后展示 Wiki 首页、搜索框、来源列表、最近导入和页面版本入口。 +- 普通知识库详情页增加按钮:“导入到助手知识库”。 +- 普通知识库列表中可显示“已沉淀到知识梳理 / 待导入 / 导入失败”状态。 +- 助手知识库页面为管理员提供“导入本地文件”和“导入技能”入口。 + +助手知识库页面建议: + +```text +知识梳理 +├── 搜索 +├── Wiki 首页 +├── 实体 +├── 关系 +├── 来源知识库 +├── 导入记录 +└── 页面版本 +``` + +普通用户只看到搜索、Wiki、实体、关系、来源和只读版本历史。管理员额外看到导入、重新导入、删除来源、版本对比和回滚。 + +### 30.12 自动沉淀时机 + +WeKnora 普通知识库完成以下任意流程后,应触发或排队助手库导入: + +- 管理员从技能归纳到 WeKnora 成功。 +- WeKnora 文档上传并完成向量化。 +- WeKnora Wiki 页面生成或更新完成。 +- WeKnora 图谱抽取完成。 +- 管理员手动点击“导入到助手知识库”。 + +为了避免频繁重复导入: + +- 对每个 WeKnora 知识库维护 `last_export_digest`。 +- 导出前计算 Wiki 页面、文档 chunk、图谱摘要的 digest。 +- digest 未变化时跳过导入。 +- 同一 WeKnora 知识库同一时间只允许一个 active 导入任务。 + +### 30.13 与技能归纳的关系 + +技能归纳支持两条链路。 + +链路 A:先进入 WeKnora,再沉淀助手库。 + +```text +技能知识文件 + -> WeKnora 普通知识库 + -> WeKnora 向量化 / Wiki / 图谱 + -> 导出已处理数据 + -> 助手知识库“知识梳理” + -> 全局实体对齐 + -> Wiki 展示与搜索 +``` + +链路 B:直接进入助手知识库。 + +```text +技能知识文件 + -> 助手知识库“知识梳理” + -> 实体/关系预览审核 + -> 全局实体对齐 + -> Wiki 页面版本发布 + -> 搜索索引 +``` + +代码文件在第一步就被排除,不进入后续任何知识库。 + +### 30.14 新增测试与验收 + +新增后端测试: + +- 未配置 WeKnora 时,助手知识库 base 接口可用,普通知识库导入接口返回明确不可用。 +- 初始化助手知识库后,只存在一个 active base。 +- 普通用户可读助手 Wiki,写接口返回 403。 +- 管理员可从 WeKnora 知识库触发导入任务。 +- 管理员可把技能直接导入助手知识库。 +- 管理员可上传本地文件导入助手知识库。 +- WeKnora 导出包包含 Wiki、文档、chunk、实体、关系、图谱时,助手库全部导入。 +- 同一个人或同一对象来自多个 WeKnora 来源时,在助手库合并为一个实体。 +- 模型推断实体对齐 `confidence >= 0.90` 自动合并,低于阈值不合并。 +- 删除某个来源时,只删除该来源贡献;其他来源贡献仍保留。 +- 助手库搜索能命中 Wiki 标题、实体别名和正文。 +- 助手 Wiki 页面支持 revision、对比和回滚。 +- 技能归纳扫描不读取、不上传、不摘要代码文件,代码文件变化不触发 stale。 + +新增前端测试: + +- 知识库产品版本默认 `v1`。 +- 管理员可切换 `v1` / `v2`。 +- 未配置 WeKnora 时仍可看到“创建知识梳理库”。 +- 普通知识库详情页有“导入到助手知识库”按钮。 +- 助手知识库页面展示 Wiki、搜索、来源、导入记录。 +- 助手知识库页面展示页面版本、对比和回滚入口。 +- 普通用户看不到导入、删除、重新导入和回滚动作。 diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index e2a6c51a..a95ab5b8 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -1,7 +1,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import { useQuery } from "@tanstack/react-query"; import { FilesIcon, XIcon } from "lucide-react"; -import { usePathname } from "next/navigation"; +import { usePathname, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import type { GroupImperativeHandle } from "react-resizable-panels"; @@ -15,6 +15,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { apiFetch } from "@/core/api"; import { + artifactPathname, listThreadArtifacts, mergeArtifactPaths, } from "@/core/artifacts/listing"; @@ -87,7 +88,13 @@ const ChatBox: React.FC<{ }) => { const { isMock, thread } = useThread(); const pathname = usePathname(); + const searchParams = useSearchParams(); + const requestedArtifact = useMemo(() => { + const value = searchParams.get("artifact")?.trim(); + return value ? artifactPathname(value) : null; + }, [searchParams]); const threadIdRef = useRef(threadId); + const openedRequestedArtifactRef = useRef(null); const artifactsCountRef = useRef(0); const layoutRef = useRef(null); const latestReferenceBatchIdRef = useRef(null); @@ -120,7 +127,7 @@ const ChatBox: React.FC<{ const { skills } = useSkills(); const canScanArtifactDirectory = - artifactDirectoryModeEnabled && + (artifactDirectoryModeEnabled || !!requestedArtifact) && !disableArtifacts && !isMock && !!threadId && @@ -138,17 +145,21 @@ const ChatBox: React.FC<{ ); const checkpointArtifactSignature = checkpointArtifacts.join("\n"); const availableArtifacts = useMemo( - () => - artifactDirectoryModeEnabled - ? mergeArtifactPaths( - checkpointArtifacts, - (artifactListingQuery.data?.files ?? []).map((file) => file.path), - ) - : checkpointArtifacts, + () => { + if (!artifactDirectoryModeEnabled && !requestedArtifact) { + return checkpointArtifacts; + } + const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map( + (file) => file.path, + ); + if (requestedArtifact) discoveredArtifacts.push(requestedArtifact); + return mergeArtifactPaths(checkpointArtifacts, discoveredArtifacts); + }, [ artifactDirectoryModeEnabled, artifactListingQuery.data?.files, checkpointArtifacts, + requestedArtifact, ], ); @@ -191,6 +202,29 @@ const ChatBox: React.FC<{ thread.isLoading, ); }, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]); + useEffect(() => { + if ( + disableArtifacts || + !requestedArtifact || + !threadId || + threadId === "new" + ) { + return; + } + const requestKey = `${threadId}:${artifactPathname(requestedArtifact)}`; + if (openedRequestedArtifactRef.current === requestKey) return; + openedRequestedArtifactRef.current = requestKey; + selectArtifact(requestedArtifact); + setArtifactsOpen(true); + setSideTab("document"); + }, [ + disableArtifacts, + requestedArtifact, + selectArtifact, + setArtifactsOpen, + threadId, + ]); + useEffect(() => { if (!shouldFetchBackendReferenceBatches) { setBackendReferenceBatches([]); diff --git a/frontend-web/src/core/artifacts/library.ts b/frontend-web/src/core/artifacts/library.ts new file mode 100644 index 00000000..a088791a --- /dev/null +++ b/frontend-web/src/core/artifacts/library.ts @@ -0,0 +1,102 @@ +import { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; + +export type ArtifactLibraryKind = + | "all" + | "document" + | "spreadsheet" + | "text_data" + | "archive" + | "other"; + +export interface ArtifactLibraryItem { + threadId: string; + threadTitle: string; + agentId: string | null; + threadType: string | null; + path: string; + name: string; + sizeBytes: number; + mimeType: string | null; + modifiedAt: string; + fileKind: Exclude; +} + +export interface ArtifactLibraryResponse { + total: number; + offset: number; + limit: number; + items: ArtifactLibraryItem[]; +} + +export async function fetchArtifactLibrary({ + query, + fileKind, + offset, + limit, + refresh = false, + signal, +}: { + query?: string; + fileKind: ArtifactLibraryKind; + offset: number; + limit: number; + refresh?: boolean; + signal?: AbortSignal; +}): Promise { + const search = new URLSearchParams({ + file_kind: fileKind, + offset: String(offset), + limit: String(limit), + }); + const normalizedQuery = query?.trim(); + if (normalizedQuery) search.set("query", normalizedQuery); + if (refresh) search.set("refresh", "true"); + + const response = await apiFetch( + `${getBackendBaseURL()}/api/artifact-library?${search.toString()}`, + { method: "GET", signal }, + ); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error( + `Failed to load artifact library (${response.status})${detail ? `: ${detail}` : ""}`, + ); + } + + const data = (await response.json()) as { + total?: number; + offset?: number; + limit?: number; + items?: Array<{ + thread_id: string; + thread_title: string; + agent_id?: string | null; + thread_type?: string | null; + path: string; + name: string; + size_bytes: number; + mime_type?: string | null; + modified_at: string; + file_kind: Exclude; + }>; + }; + + return { + total: data.total ?? 0, + offset: data.offset ?? offset, + limit: data.limit ?? limit, + items: (data.items ?? []).map((item) => ({ + threadId: item.thread_id, + threadTitle: item.thread_title, + agentId: item.agent_id ?? null, + threadType: item.thread_type ?? null, + path: item.path, + name: item.name, + sizeBytes: item.size_bytes, + mimeType: item.mime_type ?? null, + modifiedAt: item.modified_at, + fileKind: item.file_kind, + })), + }; +} diff --git a/frontend-web/src/core/llmwiki/wiki-page-markdown.ts b/frontend-web/src/core/llmwiki/wiki-page-markdown.ts index 17a2a60f..6a47dfc0 100644 --- a/frontend-web/src/core/llmwiki/wiki-page-markdown.ts +++ b/frontend-web/src/core/llmwiki/wiki-page-markdown.ts @@ -149,7 +149,7 @@ export function buildWikiIndexCatalogMarkdown( buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl, indexGroups: WikiIndexGroup[] = [], ): string { - if (indexGroups.length > 0) { + if (indexGroups.some((group) => group.items.length > 0)) { const sections: string[] = []; for (const group of indexGroups) { if (group.items.length === 0) continue; diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 9409b02f..5c3cfa2d 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -61,6 +61,7 @@ export const sidebarMenuItems: MenuItem[] = [ icon: HelpCircle, children: [ { id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, + { id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" }, { id: "multi-agent-roundtable", label: "多智能体会商", diff --git a/frontend-web/src/pages/ArtifactLibraryPage.tsx b/frontend-web/src/pages/ArtifactLibraryPage.tsx new file mode 100644 index 00000000..576003e6 --- /dev/null +++ b/frontend-web/src/pages/ArtifactLibraryPage.tsx @@ -0,0 +1,433 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { + ArrowDownWideNarrow, + ChevronLeft, + ChevronRight, + Download, + FileX2, + Library, + Loader2, + MessageSquareShare, + RefreshCw, + Search, +} from "lucide-react"; +import Link from "next/link"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + WorkspaceBody, + WorkspaceContainer, + WorkspaceHeader, +} from "@/components/workspace/workspace-container"; +import { downloadArtifactFile } from "@/core/artifacts/download"; +import { + fetchArtifactLibrary, + type ArtifactLibraryItem, + type ArtifactLibraryKind, +} from "@/core/artifacts/library"; +import { useI18n } from "@/core/i18n/hooks"; +import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils"; +import { formatDateTime } from "@/core/utils/datetime"; +import { + getFileExtensionDisplayName, + getFileIcon, +} from "@/core/utils/files"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +const PAGE_SIZE = 50; + +const FILTERS: Array<{ value: ArtifactLibraryKind; label: string }> = [ + { value: "all", label: "全部" }, + { value: "document", label: "文档" }, + { value: "spreadsheet", label: "表格" }, + { value: "text_data", label: "文本与数据" }, + { value: "archive", label: "压缩包" }, + { value: "other", label: "其他" }, +]; + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const index = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + const value = bytes / 1024 ** index; + const digits = index === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(digits)} ${units[index]}`; +} + +function sourceConversationPath(item: ArtifactLibraryItem): string { + const basePath = + item.threadType === "bootstrap" + ? bootstrapResumePath(item.threadId) + : pathOfThread({ + thread_id: item.threadId, + metadata: item.agentId ? { agent_id: item.agentId } : undefined, + }); + const search = new URLSearchParams({ artifact: item.path }); + return `${basePath}?${search.toString()}`; +} + +function FileRow({ + item, + downloading, + onDownload, +}: { + item: ArtifactLibraryItem; + downloading: boolean; + onDownload: (item: ArtifactLibraryItem) => void; +}) { + const sourcePath = sourceConversationPath(item); + const displayName = desensitize(item.name); + const sourceTitle = desensitize(item.threadTitle || "新对话"); + + return ( + + + + + {getFileIcon(item.name, "size-5")} + + + + {displayName} + + + {getFileExtensionDisplayName(item.name)} · 来自「{sourceTitle}」 + + + {getFileExtensionDisplayName(item.name)} 文件 + + + + + + + {sourceTitle} + + + + {formatDateTime(item.modifiedAt)} + + + {formatBytes(item.sizeBytes)} + + +
+ + +
+ + + ); +} + +function LoadingRows() { + return Array.from({ length: 5 }, (_, index) => ( + + +
+ +
+ + +
+
+ + + + + + + )); +} + +export default function ArtifactLibraryPage() { + const { t } = useI18n(); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [fileKind, setFileKind] = useState("all"); + const [page, setPage] = useState(1); + const [downloadingKey, setDownloadingKey] = useState(null); + const forceRefreshRef = useRef(false); + + useEffect(() => { + document.title = `资料库 - ${t.pages.appName}`; + }, [t.pages.appName]); + + useEffect(() => { + const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300); + return () => window.clearTimeout(timer); + }, [search]); + + useEffect(() => { + setPage(1); + }, [debouncedSearch, fileKind]); + + const offset = (page - 1) * PAGE_SIZE; + const libraryQuery = useQuery({ + queryKey: ["artifact-library", debouncedSearch, fileKind, page], + queryFn: async ({ signal }) => { + const refresh = forceRefreshRef.current; + try { + return await fetchArtifactLibrary({ + query: debouncedSearch, + fileKind, + offset, + limit: PAGE_SIZE, + refresh, + signal, + }); + } finally { + forceRefreshRef.current = false; + } + }, + retry: false, + }); + + const total = libraryQuery.data?.total ?? 0; + const items = libraryQuery.data?.items ?? []; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const hasPrevious = page > 1; + const hasNext = page < totalPages; + + useEffect(() => { + if (page > totalPages) setPage(totalPages); + }, [page, totalPages]); + + const itemKeys = useMemo( + () => new Set(items.map((item) => `${item.threadId}:${item.path}`)), + [items], + ); + useEffect(() => { + if (downloadingKey && !itemKeys.has(downloadingKey)) setDownloadingKey(null); + }, [downloadingKey, itemKeys]); + + const handleDownload = async (item: ArtifactLibraryItem) => { + const key = `${item.threadId}:${item.path}`; + if (downloadingKey) return; + setDownloadingKey(key); + try { + await downloadArtifactFile({ filepath: item.path, threadId: item.threadId }); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : "下载失败"); + } finally { + setDownloadingKey(null); + } + }; + + const handleRefresh = () => { + forceRefreshRef.current = true; + void libraryQuery.refetch(); + }; + + return ( + + + +
+
+
+
+
+
+ +

资料库

+
+

+ 汇总当前账号在对话中生成的文件,不包含上传文件、图片、音频和视频。 +

+
+ +
+ +
+ + setSearch(event.target.value)} + /> +
+ +
+
+ {FILTERS.map((filter) => ( + + ))} +
+
+ + 最近生成 +
+
+
+
+ + +
+
+ {libraryQuery.isLoading ? "正在统计文件…" : `共 ${total} 个文件`} +
+ +
+ + + + + + + + + + + + {libraryQuery.isLoading ? ( + + ) : ( + items.map((item) => { + const key = `${item.threadId}:${item.path}`; + return ( + void handleDownload(target)} + /> + ); + }) + )} + +
文件名来源对话生成时间大小 + 操作 +
+
+ + {!libraryQuery.isLoading && libraryQuery.isError && ( +
+ +
+

资料库加载失败

+

+ {libraryQuery.error instanceof Error + ? libraryQuery.error.message + : "请稍后重试"} +

+
+ +
+ )} + + {!libraryQuery.isLoading && !libraryQuery.isError && items.length === 0 && ( +
+ +
+

+ {debouncedSearch || fileKind !== "all" + ? "没有找到匹配的文件" + : "还没有生成文件"} +

+

+ 对话中生成的文档、表格和数据文件会自动出现在这里。 +

+
+
+ )} + + {!libraryQuery.isLoading && !libraryQuery.isError && totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 12c5e6de..92bab8fc 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -28,6 +28,7 @@ import { RouteBaseProvider } from '@/core/route-base'; import ChatsPage from './ChatsPage'; import ChatPage from './ChatPage'; +import ArtifactLibraryPage from './ArtifactLibraryPage'; import IframeChatPage from './IframeChatPage'; import AgentsPage from './AgentsPage'; import AgentChatPage from './AgentChatPage'; @@ -93,6 +94,7 @@ function shouldShowCompactTopActions(pathname: string): boolean { pathname === '/page/workspace/agents' || pathname === '/page/workspace/skills' || pathname === '/page/workspace/memory' || + pathname === '/page/workspace/library' || pathname === '/page/workspace/knowledge' || pathname.startsWith('/page/workspace/knowledge/') ); @@ -444,6 +446,14 @@ export default function WorkspaceRoutes({ path="chats/:thread_id" element={} /> + + + + } + /> } diff --git a/frontend-web/src/strategy-components/api/llmwiki.ts b/frontend-web/src/strategy-components/api/llmwiki.ts index 40f0fd38..7e4202d7 100644 --- a/frontend-web/src/strategy-components/api/llmwiki.ts +++ b/frontend-web/src/strategy-components/api/llmwiki.ts @@ -29,7 +29,7 @@ export interface LlmWikiKnowledgeBase { remote_status: "available" | "missing"; name: string; description: string; - type: "document" | "faq"; + type: "document" | "faq" | "wiki"; publication_status: "private" | "pending" | "published" | "rejected"; owner_user_id?: string | null; is_owner: boolean; @@ -37,6 +37,9 @@ export interface LlmWikiKnowledgeBase { knowledge_count: number; chunk_count: number; processing_count: number; + share_count?: number; + is_processing?: boolean; + is_temporary?: boolean; embedding_model_id?: string | null; vector_store_id?: string | null; vector_store_name?: string | null; @@ -44,19 +47,65 @@ export interface LlmWikiKnowledgeBase { vector_store_engine_type?: string | null; vector_store_status?: string | null; chunking_config?: Record; + image_processing_config?: Record; indexing_strategy?: Record; capabilities?: Record; extract_config?: Record; + faq_config?: Record; wiki_config?: Record; question_generation_config?: Record; + auto_tag_config?: Record; + vlm_config?: Record; + asr_config?: Record; summary_model_id?: string | null; storage_provider?: string | null; created_at?: string | null; updated_at?: string | null; published_at?: string | null; + remote_created_at?: string | null; + remote_updated_at?: string | null; is_conversation_deposit?: boolean; } +export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase { + wiki: { + status: "ready" | "empty" | "unavailable"; + page_count: number; + loaded_page_count: number; + stats_complete: boolean; + page_type_counts: Record; + page_status_counts: Record; + index_mode: "explicit" | "generated" | "none" | "unknown"; + has_explicit_index: boolean; + index_slug: string | null; + default_slug: string | null; + created_at: string | null; + updated_at: string | null; + embed_route: string; + }; + local_index: { + enabled: boolean; + wiki_index_enabled: boolean; + state: LlmWikiWikiIndexStatus["state"]; + fully_vectorized: boolean; + retrieval_mode: "vector" | "wiki_api"; + remote_page_count: number; + local_page_count: number; + ready_page_count: number; + failed_page_count: number; + vector_count: number; + last_started_at?: string | null; + last_completed_at?: string | null; + last_success_at?: string | null; + updated_at?: string | null; + }; +} + +export interface PublicLlmWikiKnowledgeBaseList { + knowledge_bases: PublicLlmWikiKnowledgeBase[]; + total: number; +} + export interface LlmWikiDocument { id: string; type?: string; @@ -742,6 +791,13 @@ export async function getPublicLlmWikiKnowledgeBase( ); } +export async function listPublicLlmWikiKnowledgeBases(): Promise { + return readJson( + await apiFetch(`${PUBLIC_API()}/knowledge-bases`), + "读取公开知识库列表", + ); +} + export async function listPublicLlmWikiWikiPages( knowledgeBaseId: string, page: number, diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView.tsx index 88297f48..2e8606d5 100644 --- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView.tsx +++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView.tsx @@ -54,6 +54,7 @@ import { import { WikiProtectedImage } from "./WikiProtectedImage"; const LIST_PATH = "/page/workspace/knowledge"; +const GENERATED_PUBLIC_INDEX_SLUG = "__index__"; function cleanText(value?: string | null) { return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); @@ -144,13 +145,40 @@ export function WeKnoraWikiPageView({ !!noteId && publicEmbed, ); const pages = publicEmbed ? publicPages : privatePages; - const pagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]); - const indexPage = useMemo( - () => (publicEmbed ? pagesList.find(isWikiIndexPage) : undefined), - [pagesList, publicEmbed], + const remotePagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]); + const explicitIndexPage = useMemo( + () => (publicEmbed ? remotePagesList.find(isWikiIndexPage) : undefined), + [publicEmbed, remotePagesList], + ); + const generatedIndexPage = useMemo( + () => publicEmbed && remotePagesList.length > 0 && !explicitIndexPage + ? { + id: "__generated_public_index__", + slug: GENERATED_PUBLIC_INDEX_SLUG, + title: "索引", + page_type: "index", + status: "generated", + content: "# 索引\n\n知识库页面目录", + summary: "", + aliases: [], + source_refs: [], + in_links: [], + out_links: [], + version: 0, + } + : undefined, + [explicitIndexPage, publicEmbed, remotePagesList.length], + ); + const indexPage = explicitIndexPage || generatedIndexPage; + const pagesList = useMemo( + () => generatedIndexPage ? [generatedIndexPage, ...remotePagesList] : remotePagesList, + [generatedIndexPage, remotePagesList], ); const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]); const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : ""); + const generatedIndexActive = generatedIndexPage?.slug === wikiSlug; + const generatedIndexRequested = + publicEmbed && wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage; const privatePage = useLlmWikiWikiPage( noteId || null, wikiSlug || null, @@ -159,7 +187,11 @@ export function WeKnoraWikiPageView({ const publicPage = usePublicLlmWikiWikiPage( noteId || null, wikiSlug || null, - !!noteId && !!wikiSlug && publicEmbed, + !!noteId && + !!wikiSlug && + publicEmbed && + !generatedIndexActive && + !generatedIndexRequested, ); const page = publicEmbed ? publicPage : privatePage; const publicWikiIndex = usePublicLlmWikiWikiIndex( @@ -170,7 +202,9 @@ export function WeKnoraWikiPageView({ const [directoryQuery, setDirectoryQuery] = useState(""); const [collapsedDirectories, setCollapsedDirectories] = useState>(() => new Set()); const routedPage = (location.state as { wikiPage?: LlmWikiWikiPage } | null)?.wikiPage; - const pageData = page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined); + const pageData = generatedIndexActive + ? generatedIndexPage + : page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined); const filteredPages = useMemo( () => filterWikiPagesWithAncestors(pagesList, directoryQuery), [directoryQuery, pagesList], @@ -345,6 +379,24 @@ export function WeKnoraWikiPageView({
); } + if (publicEmbed && pages.error) { + return ( +
+
+ +
+
+
无法读取 Wiki 目录
+
+ {pages.error instanceof Error ? pages.error.message : "公开 Wiki 服务暂时不可用"} +
+
+ +
+ ); + } return (
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index bfd7fff9..2894db84 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -321,7 +321,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api | **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete | | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | | **Threads search** (`POST /threads/search`, LangGraph-compat) | Conversation listing. **System threads are excluded by default**: rows whose `metadata.system` is truthy (scheduler runs, roundtable workers) never crowd the user's list; a caller whose `metadata` filter explicitly references `thread_type`/`system` (e.g. studio notebooks) opts out of the exclusion. New `query` field = case-insensitive title (`display_name`) substring search, used by the chats page's server-side pagination. `POST /threads/count` takes the same request body (limit/offset ignored) and returns `{total}` under identical filtering/exclusion semantics ? powers the chats page's ??/??? display without fetching rows (`ThreadMetaStore.count`). `ThreadMetaStore.search` gained `exclude_system`/`query` params; JSON-blob filters batch-scan until the requested `limit`/`offset` window is full (so pagination over a heavily-filtered set never under-fills), with a `thread_id` tie-breaker on the `updated_at DESC` ordering for stable OFFSET paging. Tests: `tests/test_thread_meta_search.py` | -| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types | +| **Artifacts** (`/api/threads/{id}/artifacts`, `/api/artifact-library`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types. `GET /api/artifact-library` scans every current-user normal thread's authoritative `outputs/` directory (including files omitted from `present_files`), excludes uploads and image/audio/video media, joins the owning conversation title/agent route metadata, and exposes search/filter/pagination with a bounded 15-second per-user cache plus `refresh=true` bypass. Frontend route `/page/workspace/library` links each item back to the source chat with `?artifact=` so the chat opens the existing artifact panel directly. | | **Word export** (`POST /api/writing/export/docx`) | Shared Markdown → DOCX generator used by conversation, artifact, scheduled-task, Deep Research, and AI-writing download surfaces. `app/gateway/word_export.py` builds OOXML with no Python conversion dependency: A4 portrait, 3.7/3.5/2.8/2.6 cm margins, real multilevel heading numbering, formal body/table styles, page numbers beginning at 1 with odd pages left-aligned and even pages right-aligned, and embedded `app/gateway/assets/fonts/方正小标宋简体.ttf`. The font loader validates its internal family, SHA-256, and OpenType embedding permission (`OS/2.fsType`) before export. Tests: `tests/test_word_export.py`. | | **Deep Research** (`/api/deep-research`) | Durable, user-owned research sessions/jobs with replayable SSE events and hidden-thread artifacts. `basic`/`quick` use the compact runner; `detailed` creates bounded subtopics, searches/writes sections concurrently, and assembles a report; `deep` recursively searches follow-up questions under a deterministic breadth/depth query budget. `multi_agent` is a LangGraph 1.x editor/researcher/writer/reviewer graph: its initial plan emits `awaiting_input`, the recorder stores the interrupt and releases the lease, and `POST /jobs/{id}/resume` rebuilds the graph from the persisted approved/revised plan. A bounded `custom_outline` is frozen into the session; headings/list entries deterministically supply the detailed and multi-agent plan, and the shared writer receives it only as a guarded content constraint. Completed reports support owner-scoped follow-up (`GET /sessions/{id}/messages`, `POST /sessions/{id}/chat`): context is only the current report, selected sources and recent messages; source-id citations are persisted, while `allow_new_research=true` is explicitly rejected. Owners may change that evidence set only after the job stops via `PATCH /sessions/{id}/sources/{source_id}`; this applies to future follow-ups rather than rewriting a completed report. Optional report illustrations are gated by `config.yaml -> deep_research.image_generation`: the Harness `OpenAICompatibleImageProvider` requires base64 output, the shared runner step writes only bounded `research-image-01..04.(png|jpg|webp)` artifacts and Markdown uses private `deep-research://` links. `GET /sessions/{id}/artifacts/{name}` checks session ownership before serving an image; an unavailable provider yields a warning but never fails the text report. Runners live in `packages/harness/deerflow/agents/deep_research/runners/`, may never import `app.*`, and use only the `AdapterBundle`. | | **Gateway auth** | Owner-scoped Deep Research routes await the platform's asynchronous `get_current_user` resolver before they read or write persistence, ensuring each session and job operation receives a concrete user ID. Deep Research artifacts are always resolved from a whitelisted filename via `/mnt/user-data/outputs/` before mapping to a host path, preserving the sandbox guard on native Windows and Linux hosts. | @@ -1527,8 +1527,10 @@ same DOM value, so a fetched-and-revoked object URL is not acceptable. Third-party pages embed published Wiki content through the chrome-free route `/#/embed/knowledge/{mapping_id}` (optional exact-page `wikiSlug`). It defaults to a recognized Wiki index page (title `索引`/`index`, page type `index`, or an -`index`/`home`/`readme` slug), promotes that page to the first public-directory -entry, then falls back to an overview/root page when no index exists. It retains +`index`/`home`/`readme` slug) and promotes that page to the first public-directory +entry. When upstream pages exist without an explicit index shell, the frontend +injects a virtual `__index__` page and builds its catalogue from the page list; +an all-empty dynamic index response must also fall back to that page list. It retains directory navigation and supports the shared public-embed `theme` / `bg` / `text` appearance inputs. WeKnora dynamically renders index page-type counts, nested category headings, page summaries, and `data-slug` links even though the @@ -1544,9 +1546,14 @@ the loaded page title, slug, or alias and rewrites them to exact embedded `wikiSlug` routes. Preserve WeKnora's `wiki-content-link` interaction: primary brand color, medium weight, dashed underline becoming solid on hover, focus outline, and in-reader SPA navigation rather than a page reload. `GET -/api/public/llmwiki/knowledge-bases` is the -anonymous discovery endpoint and returns only each published ordinary mapping's -`id` and `name` (plus the collection total). All anonymous reader calls use that +/api/public/llmwiki/knowledge-bases` is the anonymous discovery endpoint. It +validates published ordinary mappings against WeKnora, drops stale mappings, +and returns safe comprehensive metadata from the WeKnora list/detail/page APIs: +document/chunk/processing/share counts, non-secret configuration and timestamps, +Wiki page type/status counts and explicit/generated index entry, plus aggregate +local-vector state/counts/timestamps. Never expose remote WeKnora ids, +tenant/creator identity, storage credentials, API credentials, raw vectors, or +per-page indexing errors. All anonymous reader calls use that public namespace and the `/knowledge-bases/{id}` Wiki-page children. Those endpoints must resolve with a synthetic anonymous user and `is_admin=False`, explicitly require `publication_status=published`, and hard-deny the system diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 20fc63f7..65270ad7 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -153,6 +153,7 @@ FastAPI application providing REST endpoints for frontend integration: | `POST /api/threads/{id}/uploads` | Upload files (auto-converts PDF/PPT/Excel/Word to Markdown, rejects directory paths) | | `GET /api/threads/{id}/uploads/list` | List uploaded files | | `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | +| `GET /api/artifact-library` | List the current user's generated conversation files across normal threads, with filename/conversation search, file-kind filtering, pagination, and an optional cache-bypassing refresh. The filesystem `outputs/` directories remain authoritative; uploads and image/audio/video files are excluded. | | `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts | | `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. Whole-document artifact/report rewrites likewise create their reversible version snapshots from writer-local ORM data and never refresh after commit, so replica lag cannot restore an otherwise successful rewrite. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Whole-report rewrite accepts an optional `reportOutline` Markdown template; the durable job persists it and applies it as the chapter structure constraint. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. | | `/api/enterprise-research/*` | Temporarily disabled. Its router and persistence initialization are not registered, so these endpoints return 404 and no enterprise-research tables are created or migrated during startup. | @@ -865,8 +866,10 @@ report-structure column for databases stamped by either old variant. Published knowledge bases also have a login-free, chrome-free reader at `/#/embed/knowledge/{mapping_id}` for direct third-party iframe use. With no `wikiSlug`, it selects the Wiki **索引** page first (recognized by title, page -type, or `index`/`home`/`readme` slug), pins that page to the top of the public -directory, and otherwise falls back to an overview/root page; +type, or `index`/`home`/`readme` slug) and pins that page to the top of the +public directory. If WeKnora has pages but no explicit index page, the reader +creates a virtual `__index__` landing page from the public directory; empty +dynamic-index groups also fall back to those page summaries; `?wikiSlug=...` deep-links one page. The reader supports `theme=light|dark`, `bg`, and `text` embed appearance parameters. Index-article catalogue sections (page-type counts, nested categories, page summaries) are @@ -882,8 +885,14 @@ only from the read-only `/api/public/llmwiki/knowledge-bases/{id}/...` namespace which rechecks `publication_status=published` on every request and denies the system conversation-deposit knowledge base. Private or unpublished bases are never exposed by this route. Anonymous consumers can discover the available -public Wiki libraries through `GET /api/public/llmwiki/knowledge-bases`; each -entry deliberately contains only the DeerFlow mapping `id` and display `name`. +public Wiki libraries through `GET /api/public/llmwiki/knowledge-bases`. The +endpoint validates each published mapping against WeKnora, omits stale remote +mappings, and returns safe comprehensive metadata: description/type, +document/chunk/processing/share counts, non-secret configuration and +timestamps, Wiki page/type/status counts plus explicit/generated index mode and +default embed route, and aggregate local-vector state/counts/timestamps. It +never returns the WeKnora id, tenant/creator identity, storage credentials, API +credentials, raw vectors, or per-page indexing errors. Protected `resource://` and supported object-storage images are fetched through the current mapping instead of being exposed as upstream URLs. Authenticated readers use `GET /api/llmwiki/knowledge-bases/{id}/files?file_path=...`; public diff --git a/offline-backend-20260512/backend/app/gateway/routers/artifacts.py b/offline-backend-20260512/backend/app/gateway/routers/artifacts.py index b5738d00..e88b5d4f 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/artifacts.py +++ b/offline-backend-20260512/backend/app/gateway/routers/artifacts.py @@ -1,11 +1,15 @@ +import asyncio import logging import mimetypes +import time import zipfile -from datetime import datetime, timezone +from collections import OrderedDict +from datetime import UTC, datetime from pathlib import Path +from typing import Literal from urllib.parse import quote -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import FileResponse, PlainTextResponse, Response from pydantic import BaseModel @@ -13,6 +17,7 @@ from app.gateway.authz import require_permission from app.gateway.path_utils import aresolve_thread_virtual_path from deerflow.config.paths import get_paths from deerflow.runtime.thread_paths import aresolve_path_user_id +from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) @@ -38,6 +43,84 @@ EDITABLE_TEXT_SUFFIXES = { _VIRTUAL_USER_DATA_PREFIX = "mnt/user-data" +ArtifactLibraryKind = Literal["all", "document", "spreadsheet", "text_data", "archive", "other"] + +_MEDIA_MIME_PREFIXES = ("image/", "audio/", "video/") +_MEDIA_SUFFIXES = { + ".aac", + ".aiff", + ".ape", + ".avi", + ".bmp", + ".flac", + ".gif", + ".heic", + ".ico", + ".jpeg", + ".jpg", + ".m4a", + ".m4v", + ".mkv", + ".mov", + ".mp3", + ".mp4", + ".ogg", + ".png", + ".svg", + ".tiff", + ".wav", + ".webm", + ".webp", + ".wma", +} +_DOCUMENT_SUFFIXES = { + ".doc", + ".docx", + ".epub", + ".odt", + ".pdf", + ".ppt", + ".pptx", + ".rtf", +} +_SPREADSHEET_SUFFIXES = {".csv", ".ods", ".tsv", ".xls", ".xlsx"} +_TEXT_DATA_SUFFIXES = { + ".bash", + ".c", + ".conf", + ".cpp", + ".css", + ".go", + ".h", + ".html", + ".ini", + ".java", + ".js", + ".json", + ".jsonl", + ".log", + ".markdown", + ".md", + ".mmd", + ".py", + ".rs", + ".sh", + ".sql", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", +} +_ARCHIVE_SUFFIXES = {".7z", ".gz", ".rar", ".skill", ".tar", ".tgz", ".zip"} +_ARTIFACT_LIBRARY_CACHE_TTL_SECONDS = 15.0 +_ARTIFACT_LIBRARY_CACHE_MAX_USERS = 128 +_ARTIFACT_LIBRARY_SCAN_CONCURRENCY = 8 +_ARTIFACT_LIBRARY_THREAD_BATCH_SIZE = 500 +_artifact_library_cache: OrderedDict[str, tuple[float, list[dict]]] = OrderedDict() + def coerce_artifact_virtual_path(path: str) -> str: """Normalize an artifacts URL path to a sandbox virtual path. @@ -60,6 +143,26 @@ def coerce_artifact_virtual_path(path: str) -> str: class ArtifactUpdateRequest(BaseModel): content: str + + +class ArtifactLibraryItem(BaseModel): + thread_id: str + thread_title: str + agent_id: str | None = None + thread_type: str | None = None + path: str + name: str + size_bytes: int + mime_type: str | None = None + modified_at: str + file_kind: str + + +class ArtifactLibraryResponse(BaseModel): + total: int + offset: int + limit: int + items: list[ArtifactLibraryItem] def _build_content_disposition(disposition_type: str, filename: str) -> str: @@ -139,21 +242,184 @@ def collect_outputs_listing(outputs_dir: Path) -> list[dict]: resolved.relative_to(outputs_root) except (OSError, ValueError): continue - relative = resolved.relative_to(outputs_root).as_posix() - mime_type, _ = mimetypes.guess_type(resolved.name) - stat = resolved.stat() + try: + relative = resolved.relative_to(outputs_root).as_posix() + mime_type, _ = mimetypes.guess_type(resolved.name) + stat = resolved.stat() + except OSError: + # A run may atomically replace or remove a file while this directory + # is being scanned. Treat that transient entry as absent instead of + # failing the complete thread/library listing. + continue files.append( { "path": f"mnt/user-data/outputs/{relative}", "name": resolved.name, "size_bytes": stat.st_size, "mime_type": mime_type, - "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(), } ) files.sort(key=lambda f: f["modified_at"], reverse=True) return files + + +def artifact_library_kind(name: str) -> str: + """Classify one non-media artifact for the library filters.""" + suffix = Path(name).suffix.lower() + if suffix in _DOCUMENT_SUFFIXES: + return "document" + if suffix in _SPREADSHEET_SUFFIXES: + return "spreadsheet" + if suffix in _TEXT_DATA_SUFFIXES: + return "text_data" + if suffix in _ARCHIVE_SUFFIXES: + return "archive" + return "other" + + +def is_artifact_library_file(file: dict) -> bool: + """Return whether a generated file belongs in the document-only library.""" + mime_type = str(file.get("mime_type") or "").lower() + if mime_type.startswith(_MEDIA_MIME_PREFIXES): + return False + return Path(str(file.get("name") or file.get("path") or "")).suffix.lower() not in _MEDIA_SUFFIXES + + +async def _search_artifact_library_threads(thread_store) -> list[dict]: + """Load every normal conversation owned by the current request user.""" + rows: list[dict] = [] + seen: set[str] = set() + offset = 0 + while True: + batch = await thread_store.search( + limit=_ARTIFACT_LIBRARY_THREAD_BATCH_SIZE, + offset=offset, + exclude_system=True, + ) + if not batch: + break + added = 0 + for row in batch: + thread_id = str(row.get("thread_id") or "") + if not thread_id or thread_id in seen: + continue + seen.add(thread_id) + rows.append(row) + added += 1 + if len(batch) < _ARTIFACT_LIBRARY_THREAD_BATCH_SIZE or added == 0: + break + offset += len(batch) + return rows + + +async def _scan_artifact_library(thread_store) -> list[dict]: + """Scan generated outputs for all current-user conversation threads.""" + rows = await _search_artifact_library_threads(thread_store) + paths = get_paths() + semaphore = asyncio.Semaphore(_ARTIFACT_LIBRARY_SCAN_CONCURRENCY) + + async def scan_thread(row: dict) -> list[dict]: + thread_id = str(row.get("thread_id") or "") + if not thread_id: + return [] + async with semaphore: + try: + path_user_id = await aresolve_path_user_id(thread_id) + outputs_dir = paths.sandbox_outputs_dir(thread_id, user_id=path_user_id) + files = await asyncio.to_thread(collect_outputs_listing, outputs_dir) + except Exception: + logger.warning("Failed to scan artifacts for library thread %s", thread_id, exc_info=True) + return [] + + metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {} + agent_id = metadata.get("agent_id") + thread_type = metadata.get("thread_type") + title = str(row.get("display_name") or "新对话") + items: list[dict] = [] + for file in files: + if not is_artifact_library_file(file): + continue + items.append( + { + "thread_id": thread_id, + "thread_title": title, + "agent_id": agent_id if isinstance(agent_id, str) and agent_id else None, + "thread_type": thread_type if isinstance(thread_type, str) and thread_type else None, + **file, + "file_kind": artifact_library_kind(str(file.get("name") or "")), + } + ) + return items + + nested = await asyncio.gather(*(scan_thread(row) for row in rows)) + items = [item for group in nested for item in group] + items.sort( + key=lambda item: ( + str(item.get("modified_at") or ""), + str(item.get("thread_id") or ""), + str(item.get("path") or ""), + ), + reverse=True, + ) + return items + + +async def _cached_artifact_library(thread_store, *, refresh: bool) -> list[dict]: + user_id = str(get_effective_user_id() or "default") + now = time.monotonic() + cached = _artifact_library_cache.get(user_id) + if not refresh and cached and cached[0] > now: + _artifact_library_cache.move_to_end(user_id) + return cached[1] + + items = await _scan_artifact_library(thread_store) + _artifact_library_cache[user_id] = (now + _ARTIFACT_LIBRARY_CACHE_TTL_SECONDS, items) + _artifact_library_cache.move_to_end(user_id) + while len(_artifact_library_cache) > _ARTIFACT_LIBRARY_CACHE_MAX_USERS: + _artifact_library_cache.popitem(last=False) + return items + + +@router.get( + "/artifact-library", + response_model=ArtifactLibraryResponse, + summary="List Generated Files Across Conversations", + description="Aggregate non-media files generated in the current user's normal conversations.", +) +@require_permission("threads", "read") +async def list_artifact_library( + request: Request, + query: str | None = Query(default=None, max_length=200), + file_kind: ArtifactLibraryKind = Query(default="all"), + offset: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + refresh: bool = Query(default=False), +) -> ArtifactLibraryResponse: + """Return a flat, newest-first library of generated conversation files. + + The filesystem remains the source of truth: every owned, non-system thread's + ``outputs`` directory is scanned, including files written without an + explicit ``present_files`` call. Uploads and image/audio/video outputs are + intentionally outside this collection. + """ + from app.gateway.deps import get_thread_store + + items = await _cached_artifact_library(get_thread_store(request), refresh=refresh) + normalized_query = (query or "").strip().casefold() + filtered = [ + item + for item in items + if (file_kind == "all" or item["file_kind"] == file_kind) and (not normalized_query or normalized_query in str(item.get("name") or "").casefold() or normalized_query in str(item.get("thread_title") or "").casefold()) + ] + page = filtered[offset : offset + limit] + return ArtifactLibraryResponse( + total=len(filtered), + offset=offset, + limit=limit, + items=[ArtifactLibraryItem.model_validate(item) for item in page], + ) @router.get( diff --git a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py index 488c26b5..452138f3 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py +++ b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py @@ -55,6 +55,7 @@ from deerflow.config.agents_config import load_agent_config from deerflow.config.llmwiki_config import LlmWikiRuntimeOverride, normalize_service_url from deerflow.config.system_settings import load_system_settings, save_system_settings from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError +from deerflow.integrations.weknora.local_index.retrieval import is_fully_vectorized from deerflow.integrations.weknora.local_index.search import WikiIndexError from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime from deerflow.persistence.llmwiki import LlmWikiStore @@ -496,15 +497,7 @@ def _should_follow_weknora_binary_redirect(path: str) -> bool: """Keep protected preview/image bytes inside the DeerFlow proxy.""" segments = _path_segments(path) - return ( - len(segments) == 5 - and segments[:3] == ["api", "v1", "knowledge"] - and segments[4] == "preview" - ) or ( - len(segments) == 5 - and segments[:3] == ["api", "v1", "knowledge-bases"] - and segments[4] == "files" - ) + return (len(segments) == 5 and segments[:3] == ["api", "v1", "knowledge"] and segments[4] == "preview") or (len(segments) == 5 and segments[:3] == ["api", "v1", "knowledge-bases"] and segments[4] == "files") def _forward_request_headers(request: Request) -> dict[str, str]: @@ -1833,6 +1826,9 @@ def _mapping_view(row: dict[str, Any], remote: dict[str, Any] | None, *, actor_u "knowledge_count": int(source.get("knowledge_count") or 0), "chunk_count": int(source.get("chunk_count") or 0), "processing_count": int(source.get("processing_count") or 0), + "share_count": int(source.get("share_count") or 0), + "is_processing": bool(source.get("is_processing") or int(source.get("processing_count") or 0) > 0), + "is_temporary": bool(source.get("is_temporary", False)), "embedding_model_id": source.get("embedding_model_id"), "vector_store_id": source.get("vector_store_id"), "vector_store_name": source.get("vector_store_name"), @@ -1840,16 +1836,23 @@ def _mapping_view(row: dict[str, Any], remote: dict[str, Any] | None, *, actor_u "vector_store_engine_type": source.get("vector_store_engine_type"), "vector_store_status": source.get("vector_store_status"), "chunking_config": source.get("chunking_config") if isinstance(source.get("chunking_config"), dict) else {}, + "image_processing_config": (source.get("image_processing_config") if isinstance(source.get("image_processing_config"), dict) else {}), "indexing_strategy": source.get("indexing_strategy") if isinstance(source.get("indexing_strategy"), dict) else {}, "capabilities": source.get("capabilities") if isinstance(source.get("capabilities"), dict) else {}, "extract_config": source.get("extract_config") if isinstance(source.get("extract_config"), dict) else {}, + "faq_config": source.get("faq_config") if isinstance(source.get("faq_config"), dict) else {}, "wiki_config": source.get("wiki_config") if isinstance(source.get("wiki_config"), dict) else {}, "question_generation_config": (source.get("question_generation_config") if isinstance(source.get("question_generation_config"), dict) else {}), + "auto_tag_config": source.get("auto_tag_config") if isinstance(source.get("auto_tag_config"), dict) else {}, + "vlm_config": source.get("vlm_config") if isinstance(source.get("vlm_config"), dict) else {}, + "asr_config": source.get("asr_config") if isinstance(source.get("asr_config"), dict) else {}, "summary_model_id": source.get("summary_model_id"), "storage_provider": (source.get("storage_provider_config", {}).get("provider") if isinstance(source.get("storage_provider_config"), dict) else None), "created_at": row.get("created_at"), "updated_at": row.get("updated_at"), "published_at": row.get("published_at"), + "remote_created_at": source.get("created_at"), + "remote_updated_at": source.get("updated_at"), } @@ -1877,11 +1880,7 @@ async def _public_mapping(request: Request, mapping_id: str) -> dict[str, Any]: write=False, is_admin=False, ) - if ( - row is None - or row.get("publication_status") != "published" - or _is_conversation_deposit_mapping(row) - ): + if row is None or row.get("publication_status") != "published" or _is_conversation_deposit_mapping(row): raise HTTPException(status_code=404, detail="Published knowledge base not found") return row @@ -2537,24 +2536,260 @@ async def test_llmwiki_connection(request: Request, body: LlmWikiConnectionTest) return {"success": True, "provider": "weknora", "health": health} +_PUBLIC_WIKI_SUMMARY_CONCURRENCY = 8 +_PUBLIC_WIKI_PAGE_SIZE = 500 +_PUBLIC_WIKI_PAGE_LIMIT = 100 +_GENERATED_PUBLIC_WIKI_INDEX_SLUG = "__index__" + + +def _is_public_wiki_index_page(page: dict[str, Any]) -> bool: + page_type = str(page.get("page_type") or "").strip().lower() + title = str(page.get("title") or "").strip().lower() + slug_name = str(page.get("slug") or "").strip("/").rsplit("/", 1)[-1].lower() + return page_type == "index" or title in {"索引", "index"} or slug_name in {"index", "home", "readme"} + + +def _public_wiki_default_page(pages: list[dict[str, Any]]) -> dict[str, Any] | None: + return ( + next((page for page in pages if _is_public_wiki_index_page(page)), None) + or next((page for page in pages if str(page.get("page_type") or "").lower() == "overview"), None) + or next( + (page for page in pages if not str(page.get("parent_slug") or "").strip() and int(page.get("depth") or 0) == 0), + None, + ) + or (pages[0] if pages else None) + ) + + +def _count_public_wiki_values(pages: list[dict[str, Any]], key: str, fallback: str) -> dict[str, int]: + counts: dict[str, int] = {} + for page in pages: + value = str(page.get(key) or fallback).strip().lower() or fallback + counts[value] = counts.get(value, 0) + 1 + return counts + + +def _public_wiki_time_bound(pages: list[dict[str, Any]], key: str, *, latest: bool) -> str | None: + values = [str(page.get(key) or "").strip() for page in pages] + values = [value for value in values if value] + if not values: + return None + return max(values) if latest else min(values) + + +async def _all_public_wiki_page_views( + client: WeKnoraClient, + remote_id: str, +) -> tuple[list[dict[str, Any]], int, bool]: + pages_by_slug: dict[str, dict[str, Any]] = {} + remote_total = 0 + complete = False + for page_number in range(1, _PUBLIC_WIKI_PAGE_LIMIT + 1): + result = await client.list_wiki_pages( + remote_id, + page=page_number, + page_size=_PUBLIC_WIKI_PAGE_SIZE, + query="", + ) + raw_pages = result.get("pages") if isinstance(result, dict) else [] + page_items = raw_pages if isinstance(raw_pages, list) else [] + for page_item in page_items: + if not isinstance(page_item, dict): + continue + page = _wiki_page_view(page_item) + if page is None: + continue + slug = str(page.get("slug") or "").strip() + pages_by_slug[slug or f"__page_{len(pages_by_slug)}"] = page + reported_total = int(result.get("total") or 0) + remote_total = max(remote_total, reported_total, len(pages_by_slug)) + total_pages = max(1, int(result.get("total_pages") or 1)) + if not page_items or page_number >= total_pages or (reported_total > 0 and len(pages_by_slug) >= remote_total): + complete = True + break + return list(pages_by_slug.values()), max(remote_total, len(pages_by_slug)), complete + + +def _public_wiki_summary( + mapping_id: str, + pages: list[dict[str, Any]], + *, + total: int, + complete: bool, +) -> dict[str, Any]: + explicit_index = next((page for page in pages if _is_public_wiki_index_page(page)), None) + default_page = _public_wiki_default_page(pages) + if explicit_index is not None: + index_mode = "explicit" + index_slug: str | None = str(explicit_index.get("slug") or "") or None + elif pages: + index_mode = "generated" + index_slug = _GENERATED_PUBLIC_WIKI_INDEX_SLUG + else: + index_mode = "none" + index_slug = None + default_slug = index_slug or (str(default_page.get("slug") or "") if default_page else None) + route = f"/embed/knowledge/{quote(mapping_id, safe='')}" + if default_slug: + route = f"{route}?{urlencode({'wikiSlug': default_slug})}" + return { + "status": "ready" if pages else "empty", + "page_count": total, + "loaded_page_count": len(pages), + "stats_complete": complete, + "page_type_counts": _count_public_wiki_values(pages, "page_type", "page"), + "page_status_counts": _count_public_wiki_values(pages, "status", "unknown"), + "index_mode": index_mode, + "has_explicit_index": explicit_index is not None, + "index_slug": index_slug, + "default_slug": default_slug, + "created_at": _public_wiki_time_bound(pages, "created_at", latest=False), + "updated_at": _public_wiki_time_bound(pages, "updated_at", latest=True), + "embed_route": route, + } + + +def _unavailable_public_wiki_summary(mapping_id: str) -> dict[str, Any]: + return { + "status": "unavailable", + "page_count": 0, + "loaded_page_count": 0, + "stats_complete": False, + "page_type_counts": {}, + "page_status_counts": {}, + "index_mode": "unknown", + "has_explicit_index": False, + "index_slug": None, + "default_slug": None, + "created_at": None, + "updated_at": None, + "embed_route": f"/embed/knowledge/{quote(mapping_id, safe='')}", + } + + +def _public_local_index_summary( + request: Request, + mapping: dict[str, Any], + state: dict[str, Any] | None, +) -> dict[str, Any]: + app_state = request.app.state + local_config = getattr( + getattr(getattr(app_state, "config", None), "llmwiki", None), + "local_wiki_index", + None, + ) + enabled = bool(getattr(local_config, "enabled", False)) + current = dict(state or {}) + state_name = str(current.get("state") or "not_synced") + if state_name == "idle" and int(current.get("index_revision") or 0) == 0 and not current.get("last_completed_at") and not current.get("last_success_at"): + state_name = "not_synced" + embedding = getattr(app_state, "llmwiki_embedding", None) + fingerprint = embedding.fingerprint if embedding is not None else None + fully_vectorized = bool(mapping.get("wiki_index_enabled", True)) and is_fully_vectorized( + current, + fingerprint, + ) + return { + "enabled": enabled, + "wiki_index_enabled": bool(mapping.get("wiki_index_enabled", True)), + "state": state_name, + "fully_vectorized": fully_vectorized, + "retrieval_mode": "vector" if fully_vectorized else "wiki_api", + "remote_page_count": int(current.get("remote_page_count") or 0), + "local_page_count": int(current.get("local_page_count") or 0), + "ready_page_count": int(current.get("ready_page_count") or 0), + "failed_page_count": int(current.get("failed_page_count") or 0), + "vector_count": int(current.get("vector_count") or 0), + "last_started_at": current.get("last_started_at"), + "last_completed_at": current.get("last_completed_at"), + "last_success_at": current.get("last_success_at"), + "updated_at": current.get("updated_at"), + } + + @router.get("/api/public/llmwiki/knowledge-bases") async def list_public_knowledge_bases(request: Request) -> dict[str, Any]: - """List the minimal identity of every knowledge base published as a public Wiki.""" + """List live, published Wiki libraries with safe metadata and index health.""" - rows = await _store(request).list_visible( - "__public_wiki__", - scope="public", - is_admin=False, - ) - knowledge_bases = [ - { - "id": str(row["id"]), - "name": str(row.get("name") or ""), - } - for row in rows - if row.get("publication_status") == "published" - and not _is_conversation_deposit_mapping(row) + rows = [ + row + for row in await _store(request).list_visible( + "__public_wiki__", + scope="public", + is_admin=False, + ) + if row.get("publication_status") == "published" and not _is_conversation_deposit_mapping(row) ] + client = _client_or_503() + try: + remote_items = await client.list_knowledge_bases() + except WeKnoraError as exc: + _raise_upstream(exc) + remote_by_id = {str(item.get("id")): item for item in remote_items if isinstance(item, dict) and item.get("id")} + # A successful upstream list is authoritative: stale DeerFlow mappings are + # omitted so discovery never advertises a Wiki that its detail route cannot open. + rows = [row for row in rows if str(row.get("weknora_id") or "") in remote_by_id] + mapping_ids = [str(row["id"]) for row in rows] + index_states: dict[str, dict[str, Any]] = {} + index_store = getattr(request.app.state, "llmwiki_index_store", None) + if index_store is not None and mapping_ids: + try: + index_states = {str(state.get("knowledge_base_mapping_id") or ""): state for state in await index_store.list_index_status(mapping_ids) if isinstance(state, dict)} + except Exception: + logger.warning("Failed to read public Wiki local-index summaries", exc_info=True) + + semaphore = asyncio.Semaphore(_PUBLIC_WIKI_SUMMARY_CONCURRENCY) + + async def build_summary(row: dict[str, Any]) -> dict[str, Any]: + mapping_id = str(row["id"]) + remote_id = str(row["weknora_id"]) + remote = remote_by_id[remote_id] + async with semaphore: + try: + remote = {**remote, **await client.get_knowledge_base(remote_id)} + except WeKnoraError as exc: + if exc.status_code == 404: + return {} + logger.warning( + "Failed to load public Wiki detail metadata: mapping_id=%s", + mapping_id, + exc_info=True, + ) + try: + pages, page_total, complete = await _all_public_wiki_page_views(client, remote_id) + wiki = _public_wiki_summary( + mapping_id, + pages, + total=page_total, + complete=complete, + ) + except WeKnoraError as exc: + logger.warning( + "Failed to load public Wiki page summary: mapping_id=%s", + mapping_id, + exc_info=True, + ) + wiki = ( + _public_wiki_summary(mapping_id, [], total=0, complete=True) + if exc.status_code == 404 + else _unavailable_public_wiki_summary(mapping_id) + ) + return { + **_mapping_view( + row, + remote, + actor_user_id="__public_wiki__", + is_admin=False, + ), + "wiki": wiki, + "local_index": _public_local_index_summary( + request, + row, + index_states.get(mapping_id), + ), + } + + knowledge_bases = [summary for summary in await asyncio.gather(*(build_summary(row) for row in rows)) if summary] return {"knowledge_bases": knowledge_bases, "total": len(knowledge_bases)} @@ -2596,13 +2831,7 @@ async def list_public_wiki_pages( except WeKnoraError as exc: _raise_upstream(exc) 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} @@ -2650,11 +2879,7 @@ def _public_wiki_index_item(value: Any) -> dict[str, Any] | None: "slug": slug, "title": str(value.get("title") or slug), "summary": str(value.get("summary") or ""), - "category_path": ( - [str(item) for item in value.get("category_path", []) if str(item).strip()] - if isinstance(value.get("category_path"), list) - else [] - ), + "category_path": ([str(item) for item in value.get("category_path", []) if str(item).strip()] if isinstance(value.get("category_path"), list) else []), "wiki_path": str(value.get("wiki_path") or ""), "depth": int(value.get("depth") or 0), } @@ -2680,20 +2905,12 @@ async def _all_public_wiki_index(client: WeKnoraClient, remote_id: str) -> dict[ version = max(version, int(payload.get("version") or 0)) raw_groups = payload.get("groups") if isinstance(payload.get("groups"), list) else [] group = next( - ( - value - for value in raw_groups - if isinstance(value, dict) and value.get("type") == page_type - ), + (value for value in raw_groups if isinstance(value, dict) and value.get("type") == page_type), {}, ) total = max(total, int(group.get("total") or 0)) raw_items = group.get("items") if isinstance(group.get("items"), list) else [] - items.extend( - item - for value in raw_items - if (item := _public_wiki_index_item(value)) is not None - ) + items.extend(item for value in raw_items if (item := _public_wiki_index_item(value)) is not None) next_cursor = str(group.get("next_cursor") or "") if not next_cursor or next_cursor in seen_cursors: break @@ -3201,12 +3418,7 @@ async def search_llmwiki(request: Request, body: SearchRequest) -> dict[str, Any except WikiIndexError as exc: raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": str(exc)}) from None mapping_by_id = {str(row["id"]): row for row in rows} - result["results"] = [ - _sanitize_conversation_deposit_value(item) - if _is_conversation_deposit_mapping(mapping_by_id.get(str(item.get("knowledge_base_id") or ""), {})) - else item - for item in result.get("results") or [] - ] + result["results"] = [_sanitize_conversation_deposit_value(item) if _is_conversation_deposit_mapping(mapping_by_id.get(str(item.get("knowledge_base_id") or ""), {})) else item for item in result.get("results") or []] return result diff --git a/offline-backend-20260512/backend/tests/test_artifact_library.py b/offline-backend-20260512/backend/tests/test_artifact_library.py new file mode 100644 index 00000000..98665552 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_artifact_library.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from app.gateway import deps +from app.gateway.routers import artifacts + + +class _FakeThreadStore: + def __init__(self, rows: list[dict]) -> None: + self.rows = rows + self.calls: list[dict] = [] + + async def search(self, **kwargs) -> list[dict]: + self.calls.append(kwargs) + offset = int(kwargs.get("offset", 0)) + limit = int(kwargs.get("limit", 100)) + return self.rows[offset : offset + limit] + + +@pytest.mark.asyncio +async def test_artifact_library_aggregates_generated_non_media_files(tmp_path: Path, monkeypatch): + thread_one = tmp_path / "thread-one" / "outputs" + thread_two = tmp_path / "thread-two" / "outputs" + thread_one.mkdir(parents=True) + thread_two.mkdir(parents=True) + + report = thread_one / "研判报告.docx" + table = thread_one / "数据汇总.xlsx" + image = thread_one / "配图.png" + audio = thread_two / "录音.mp3" + notes = thread_two / "结论.md" + archive = thread_two / "证据.zip" + for path, content in ( + (report, b"doc"), + (table, b"sheet"), + (image, b"image"), + (audio, b"audio"), + (notes, b"# notes"), + (archive, b"zip"), + ): + path.write_bytes(content) + + os.utime(report, (100, 100)) + os.utime(table, (200, 200)) + os.utime(notes, (300, 300)) + os.utime(archive, (400, 400)) + + rows = [ + { + "thread_id": "thread-one", + "display_name": "战场环境综合研判", + "metadata": {"agent_id": "analysis-agent"}, + }, + { + "thread_id": "thread-two", + "display_name": "生成发展报告", + "metadata": {}, + }, + ] + store = _FakeThreadStore(rows) + + class _Paths: + def sandbox_outputs_dir(self, thread_id: str, *, user_id: str) -> Path: + assert user_id == "owner" + return tmp_path / thread_id / "outputs" + + async def _resolve_owner(_thread_id: str) -> str: + return "owner" + + monkeypatch.setattr(artifacts, "get_paths", lambda: _Paths()) + monkeypatch.setattr(artifacts, "aresolve_path_user_id", _resolve_owner) + + items = await artifacts._scan_artifact_library(store) + + assert [item["name"] for item in items] == ["证据.zip", "结论.md", "数据汇总.xlsx", "研判报告.docx"] + assert [item["file_kind"] for item in items] == ["archive", "text_data", "spreadsheet", "document"] + assert items[-1]["thread_title"] == "战场环境综合研判" + assert items[-1]["agent_id"] == "analysis-agent" + assert all(item["name"] not in {"配图.png", "录音.mp3"} for item in items) + assert store.calls[0]["exclude_system"] is True + + +@pytest.mark.asyncio +async def test_artifact_library_endpoint_filters_searches_and_pages(tmp_path: Path, monkeypatch): + output_dir = tmp_path / "thread-one" / "outputs" + output_dir.mkdir(parents=True) + (output_dir / "任务报告.md").write_text("report", encoding="utf-8") + (output_dir / "任务数据.csv").write_text("a,b", encoding="utf-8") + + store = _FakeThreadStore( + [ + { + "thread_id": "thread-one", + "display_name": "任务研判对话", + "metadata": {}, + } + ] + ) + + class _Paths: + def sandbox_outputs_dir(self, thread_id: str, *, user_id: str) -> Path: + return tmp_path / thread_id / "outputs" + + async def _resolve_owner(_thread_id: str) -> str: + return "owner" + + monkeypatch.setattr(artifacts, "get_paths", lambda: _Paths()) + monkeypatch.setattr(artifacts, "aresolve_path_user_id", _resolve_owner) + monkeypatch.setattr(artifacts, "get_effective_user_id", lambda: "owner") + monkeypatch.setattr(deps, "get_thread_store", lambda _request: store) + artifacts._artifact_library_cache.clear() + + raw_endpoint = artifacts.list_artifact_library.__wrapped__ + response = await raw_endpoint( + request=SimpleNamespace(), + query="研判对话", + file_kind="text_data", + offset=0, + limit=1, + refresh=True, + ) + + assert response.total == 1 + assert response.offset == 0 + assert response.limit == 1 + assert [item.name for item in response.items] == ["任务报告.md"] + + spreadsheet_response = await raw_endpoint( + request=SimpleNamespace(), + query="任务数据", + file_kind="spreadsheet", + offset=0, + limit=50, + refresh=False, + ) + assert spreadsheet_response.total == 1 + assert spreadsheet_response.items[0].name == "任务数据.csv" diff --git a/offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py b/offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py index 658e6043..e1ec9345 100644 --- a/offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py +++ b/offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py @@ -68,7 +68,7 @@ def _published_row() -> dict[str, object]: @pytest.mark.asyncio -async def test_public_knowledge_base_list_exposes_only_published_wiki_identity() -> None: +async def test_public_knowledge_base_list_exposes_live_safe_metadata_and_generated_index(monkeypatch) -> None: store = PublicListStore( [ { @@ -83,21 +83,116 @@ async def test_public_knowledge_base_list_exposes_only_published_wiki_identity() "owner_user_id": "system", "name": "对话沉淀", }, + { + **_published_row(), + "id": "stale-kb", + "weknora_id": "remote-missing", + }, ] ) + class Client: + async def list_knowledge_bases(self): + return [ + { + "id": "remote-one", + "name": "远端公开知识库", + "description": "远端公开说明", + "type": "wiki", + "knowledge_count": 12, + "chunk_count": 34, + "processing_count": 1, + "share_count": 3, + "created_at": "2026-07-01T08:00:00+08:00", + "updated_at": "2026-08-01T08:00:00+08:00", + "tenant_id": 10001, + "creator_id": "secret-creator", + "storage_config": {"secret_key": "must-not-leak"}, + } + ] + + async def get_knowledge_base(self, knowledge_base_id: str): + assert knowledge_base_id == "remote-one" + return { + "id": knowledge_base_id, + "vector_store_name": "System default", + "vector_store_source": "env", + "vector_store_engine_type": "elasticsearch", + "vector_store_status": "available", + "wiki_config": {"enabled": True}, + } + + async def list_wiki_pages(self, knowledge_base_id: str, **kwargs): + assert knowledge_base_id == "remote-one" + assert kwargs == {"page": 1, "page_size": 500, "query": ""} + return { + "pages": [ + { + "id": "page-one", + "slug": "concept/start", + "title": "开始使用", + "page_type": "concept", + "status": "completed", + "created_at": "2026-07-02T08:00:00+08:00", + "updated_at": "2026-08-02T08:00:00+08:00", + }, + { + "id": "page-two", + "slug": "summary/overview", + "title": "知识概览", + "page_type": "summary", + "status": "completed", + "created_at": "2026-07-03T08:00:00+08:00", + "updated_at": "2026-08-03T08:00:00+08:00", + }, + ], + "total": 2, + "page": 1, + "page_size": 500, + "total_pages": 1, + } + + monkeypatch.setattr(llmwiki, "_client_or_503", lambda: Client()) + payload = await llmwiki.list_public_knowledge_bases(_request(store)) assert store.calls == [("__public_wiki__", "public", False)] - assert payload == { - "knowledge_bases": [ - { - "id": "1063edee-332a-43b4-bbd9-73bf0a708b94", - "name": "公开知识库", - } - ], - "total": 1, + assert payload["total"] == 1 + item = payload["knowledge_bases"][0] + assert item["id"] == "1063edee-332a-43b4-bbd9-73bf0a708b94" + assert item["name"] == "远端公开知识库" + assert item["description"] == "远端公开说明" + assert item["type"] == "wiki" + assert item["knowledge_count"] == 12 + assert item["chunk_count"] == 34 + assert item["processing_count"] == 1 + assert item["share_count"] == 3 + assert item["is_processing"] is True + assert item["remote_created_at"] == "2026-07-01T08:00:00+08:00" + assert item["remote_updated_at"] == "2026-08-01T08:00:00+08:00" + assert item["vector_store_engine_type"] == "elasticsearch" + assert item["wiki"] == { + "status": "ready", + "page_count": 2, + "loaded_page_count": 2, + "stats_complete": True, + "page_type_counts": {"concept": 1, "summary": 1}, + "page_status_counts": {"completed": 2}, + "index_mode": "generated", + "has_explicit_index": False, + "index_slug": "__index__", + "default_slug": "__index__", + "created_at": "2026-07-02T08:00:00+08:00", + "updated_at": "2026-08-03T08:00:00+08:00", + "embed_route": "/embed/knowledge/1063edee-332a-43b4-bbd9-73bf0a708b94?wikiSlug=__index__", } + assert item["local_index"]["state"] == "not_synced" + assert item["local_index"]["fully_vectorized"] is False + assert item["owner_user_id"] is None + assert "weknora_id" not in item + assert "tenant_id" not in item + assert "creator_id" not in item + assert "storage_config" not in item @pytest.mark.asyncio @@ -178,6 +273,35 @@ async def test_public_wiki_page_list_is_normalized(monkeypatch) -> None: assert payload["pages"][0]["aliases"] == [] +def test_public_wiki_summary_prefers_an_explicit_index_page() -> None: + payload = llmwiki._public_wiki_summary( + "mapping-one", + [ + { + "slug": "guide/start", + "title": "开始使用", + "page_type": "article", + "status": "completed", + }, + { + "slug": "home", + "title": "知识首页", + "page_type": "index", + "status": "completed", + }, + ], + total=2, + complete=True, + ) + + assert payload["status"] == "ready" + assert payload["index_mode"] == "explicit" + assert payload["has_explicit_index"] is True + assert payload["index_slug"] == "home" + assert payload["default_slug"] == "home" + assert payload["embed_route"] == "/embed/knowledge/mapping-one?wikiSlug=home" + + @pytest.mark.asyncio async def test_public_wiki_file_is_authorized_and_streamed(monkeypatch) -> None: store = PublicStore(_published_row()) From fbd8dec48fb10e42fd6b25da58eef2f31b2d27ec Mon Sep 17 00:00:00 2001 From: Administrator Date: Wed, 2 Sep 2026 00:49:13 +0800 Subject: [PATCH 3/4] feat: add skill and assistant knowledge bases --- ...skill-knowledge-and-assistant-knowledge.md | 7 + .../workspace/knowledge-product-nav.tsx | 55 ++ .../settings/skill-settings-page.tsx | 30 +- .../workspace/skills/SkillKnowledgePanel.tsx | 226 ++++++ frontend-web/src/core/system-settings/api.ts | 25 + frontend-web/src/pages/AdminSkillsPage.tsx | 17 + .../src/pages/AssistantKnowledgePage.tsx | 74 ++ .../src/pages/LlmWikiKnowledgePage.tsx | 10 +- frontend-web/src/pages/WorkspaceRoutes.tsx | 9 + .../api/assistant-knowledge.ts | 177 ++++ .../api/skill-knowledge.ts | 277 +++++++ .../llmwiki/WeKnoraKnowledgeDetailPage.tsx | 22 +- offline-backend-20260512/backend/CLAUDE.md | 24 + offline-backend-20260512/backend/README.md | 27 + .../backend/app/gateway/app.py | 22 +- .../backend/app/gateway/deps.py | 41 +- .../gateway/routers/assistant_knowledge.py | 338 ++++++++ .../app/gateway/routers/skill_knowledge.py | 334 ++++++++ .../app/gateway/routers/system_settings.py | 23 + .../app/gateway/skill_knowledge_auto_sync.py | 155 ++++ .../gateway/skill_knowledge_job_dispatcher.py | 96 +++ .../gateway/skill_knowledge_job_executor.py | 403 ++++++++++ offline-backend-20260512/backend/config.yaml | 8 + .../deerflow/assistant_knowledge/__init__.py | 5 + .../deerflow/assistant_knowledge/importer.py | 22 + .../harness/deerflow/config/llmwiki_config.py | 18 + .../deerflow/config/system_settings.py | 17 + .../assistant_knowledge/__init__.py | 4 + .../persistence/assistant_knowledge/model.py | 143 ++++ .../persistence/assistant_knowledge/store.py | 601 ++++++++++++++ ...260901_01_skill_and_assistant_knowledge.py | 73 ++ .../deerflow/persistence/models/__init__.py | 42 + .../persistence/skill_knowledge/__init__.py | 15 + .../persistence/skill_knowledge/model.py | 203 +++++ .../persistence/skill_knowledge/store.py | 758 ++++++++++++++++++ .../deerflow/skill_knowledge/__init__.py | 10 + .../deerflow/skill_knowledge/extractor.py | 152 ++++ .../deerflow/skill_knowledge/graph_writer.py | 115 +++ .../harness/deerflow/skill_knowledge/merge.py | 31 + .../deerflow/skill_knowledge/scanner.py | 351 ++++++++ .../harness/deerflow/utils/file_conversion.py | 18 + .../test_assistant_knowledge_repository.py | 96 +++ .../test_skill_knowledge_graph_writer.py | 62 ++ .../tests/test_skill_knowledge_repository.py | 95 +++ .../tests/test_skill_knowledge_scanner.py | 52 ++ 45 files changed, 5262 insertions(+), 21 deletions(-) create mode 100644 frontend-web/docs/skill-knowledge-and-assistant-knowledge.md create mode 100644 frontend-web/src/components/workspace/knowledge-product-nav.tsx create mode 100644 frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx create mode 100644 frontend-web/src/pages/AssistantKnowledgePage.tsx create mode 100644 frontend-web/src/strategy-components/api/assistant-knowledge.ts create mode 100644 frontend-web/src/strategy-components/api/skill-knowledge.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/assistant_knowledge.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/skill_knowledge.py create mode 100644 offline-backend-20260512/backend/app/gateway/skill_knowledge_auto_sync.py create mode 100644 offline-backend-20260512/backend/app/gateway/skill_knowledge_job_dispatcher.py create mode 100644 offline-backend-20260512/backend/app/gateway/skill_knowledge_job_executor.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/assistant_knowledge/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/assistant_knowledge/importer.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/assistant_knowledge/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/assistant_knowledge/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/assistant_knowledge/store.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260901_01_skill_and_assistant_knowledge.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/skill_knowledge/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/skill_knowledge/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/skill_knowledge/store.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/skill_knowledge/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/skill_knowledge/extractor.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/skill_knowledge/graph_writer.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/skill_knowledge/merge.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/skill_knowledge/scanner.py create mode 100644 offline-backend-20260512/backend/tests/test_assistant_knowledge_repository.py create mode 100644 offline-backend-20260512/backend/tests/test_skill_knowledge_graph_writer.py create mode 100644 offline-backend-20260512/backend/tests/test_skill_knowledge_repository.py create mode 100644 offline-backend-20260512/backend/tests/test_skill_knowledge_scanner.py diff --git a/frontend-web/docs/skill-knowledge-and-assistant-knowledge.md b/frontend-web/docs/skill-knowledge-and-assistant-knowledge.md new file mode 100644 index 00000000..8d929308 --- /dev/null +++ b/frontend-web/docs/skill-knowledge-and-assistant-knowledge.md @@ -0,0 +1,7 @@ +# 技能知识归纳与“知识梳理”助手知识库 + +管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。 + +扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。 + +知识库页面新增「普通知识库 / 助手知识库」并行入口。管理员可将系统默认入口设为 v1(普通 WeKnora)或 v2(全局“知识梳理”)。助手库无需 WeKnora 即可初始化,所有用户可只读查看和搜索 Wiki;管理员可上传本地文件、从普通库导入、重新导入或删除来源、查看版本并回滚。来源删除只移除对应贡献,共享实体与关系继续保留。 diff --git a/frontend-web/src/components/workspace/knowledge-product-nav.tsx b/frontend-web/src/components/workspace/knowledge-product-nav.tsx new file mode 100644 index 00000000..b6417d86 --- /dev/null +++ b/frontend-web/src/components/workspace/knowledge-product-nav.tsx @@ -0,0 +1,55 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { BookOpen, Database } from "lucide-react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { getStoredAuth } from "@/core/auth"; +import { + loadKnowledgeBaseProductVersion, + type KnowledgeBaseProductVersion, + updateKnowledgeBaseProductVersion, +} from "@/core/system-settings/api"; + +export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const; + +export function useKnowledgeBaseProductVersion() { + return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 }); +} + +export function KnowledgeProductNav() { + const location = useLocation(); + const navigate = useNavigate(); + const client = useQueryClient(); + const version = useKnowledgeBaseProductVersion(); + const isAdmin = getStoredAuth()?.system_role === "admin"; + const save = useMutation({ + mutationFn: updateKnowledgeBaseProductVersion, + onSuccess: (value) => { + client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value); + toast.success(`默认知识库体验已切换为 ${value}`); + }, + }); + const assistant = location.pathname.includes("/knowledge/assistant"); + + return ( +
+ + + {isAdmin && ( +
+ 默认产品版本 + +
+ )} +
+ ); +} diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index d63df2c6..2f359044 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -92,7 +92,8 @@ import { useSkillCompressionSettings, useViewMode, } from "@/core/system-settings/hooks"; -import type { Skill } from "@/core/skills/type"; +import type { Skill } from "@/core/skills/type"; +import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge"; import { formatDateTime } from "@/core/utils/datetime"; import { env } from "@/env"; import { desensitize } from "@/lib/desensitize"; @@ -599,8 +600,9 @@ export function SkillRow({ skill, currentUserId, isAdmin, - layout = "row", - onPublisherClick, + layout = "row", + onPublisherClick, + knowledgeBindings = [], }: { skill: Skill; currentUserId: string | null; @@ -609,7 +611,9 @@ export function SkillRow({ layout?: "row" | "card"; /** Click the 发布者 line to filter the list to that publisher. Receives the * owner_user_id + its desensitized display label. */ - onPublisherClick?: (publisher: { id: string; label: string }) => void; + onPublisherClick?: (publisher: { id: string; label: string }) => void; + /** Admin list can inject its single batched binding query here (never N+1). */ + knowledgeBindings?: SkillKnowledgeBinding[]; }) { const isCard = layout === "card"; const { t } = useI18n(); @@ -849,7 +853,7 @@ export function SkillRow({ {t.settings.skills.badgePinned} )} - {skill.always_on && ( + {skill.always_on && ( 常驻 - )} + )} + {knowledgeBindings.slice(0, 3).map((binding) => ( + + 知识库:{binding.target_name || binding.target_id} + {binding.status === "stale" ? "(待更新)" : ""} + + ))} + {knowledgeBindings.length > 3 && ( + +{knowledgeBindings.length - 3} + )}
); diff --git a/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx b/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx new file mode 100644 index 00000000..77fe43d7 --- /dev/null +++ b/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx @@ -0,0 +1,226 @@ +import { CheckCircle2, Database, Loader2, RefreshCw, Sparkles } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useAllSkillsAdmin } from "@/core/skills/hooks"; +import { + useAssistantKnowledgeActions, + useAssistantKnowledgeBase, +} from "@/strategy-components/api/assistant-knowledge"; +import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki"; +import { + type SkillKnowledgeTarget, + useSkillKnowledgeActions, + useSkillKnowledgeAutoSyncSettings, + useSkillKnowledgeBindings, + useSkillKnowledgeJobs, + useSkillKnowledgeReviews, +} from "@/strategy-components/api/skill-knowledge"; + +const PHASE_LABELS: Record = { + scanning: "扫描知识文件", + parsing: "解析资料", + extracting: "抽取实体关系", + awaiting_review: "等待审核", + merging: "合并实体关系", + writing_wiki: "写入 Wiki", + activating: "激活新版本", + completed: "完成", +}; + +export function SkillKnowledgePanel() { + const { skills, isLoading } = useAllSkillsAdmin(true); + const liveSkills = useMemo(() => skills.filter((skill) => (skill.state ?? "active") === "active"), [skills]); + const [selectedSkills, setSelectedSkills] = useState([]); + const [selectedTargetKeys, setSelectedTargetKeys] = useState([]); + const [reviewMode, setReviewMode] = useState<"required" | "auto_high_confidence" | "off">("auto_high_confidence"); + const runtime = useLlmWikiRuntime(); + const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora"); + const assistantBase = useAssistantKnowledgeBase(); + const assistantActions = useAssistantKnowledgeActions(); + const bindings = useSkillKnowledgeBindings(); + const jobs = useSkillKnowledgeJobs(); + const reviews = useSkillKnowledgeReviews(); + const actions = useSkillKnowledgeActions(); + const autoSettings = useSkillKnowledgeAutoSyncSettings(); + const [scheduleCron, setScheduleCron] = useState("0 */6 * * *"); + const [debounceSeconds, setDebounceSeconds] = useState(120); + + useEffect(() => { + if (!autoSettings.data) return; + setScheduleCron(autoSettings.data.schedule_cron); + setDebounceSeconds(autoSettings.data.debounce_seconds); + }, [autoSettings.data]); + + const targets = useMemo(() => { + const rows: SkillKnowledgeTarget[] = (knowledgeBases.data ?? []).map((kb) => ({ + target_type: "weknora", + target_mode: kb.can_write ? kb.type === "faq" ? "faq" : kb.type === "wiki" ? "wiki" : "document" : "readonly_sidecar", + target_id: kb.id, + target_name: kb.name, + remote_business_key: `weknora:kb:${kb.id}`, + readonly: !kb.can_write, + })); + if (assistantBase.data) { + rows.unshift({ + target_type: "assistant", + target_mode: "assistant", + target_id: assistantBase.data.id, + target_name: assistantBase.data.name, + remote_business_key: `assistant:base:${assistantBase.data.id}`, + }); + } + return rows; + }, [assistantBase.data, knowledgeBases.data]); + + const toggle = (values: string[], value: string, setter: (next: string[]) => void) => + setter(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]); + + const submit = async () => { + const selectedTargets = targets.filter((target) => + selectedTargetKeys.includes(`${target.target_type}:${target.target_id}`), + ); + if (!selectedSkills.length || !selectedTargets.length) { + toast.error("请至少选择一个技能和一个目标知识库"); + return; + } + try { + const job = await actions.createJob.mutateAsync({ + skill_names: selectedSkills, + targets: selectedTargets, + review_mode: reviewMode, + }); + toast.success(`已创建 ${job.total_count} 个归纳项`); + setSelectedSkills([]); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } + }; + + return ( +
+
+
+

批量归纳

+

按“技能 × 目标知识库”生成独立后台任务;代码与脚本不会被读取。

+
+ +
+
+
+ 1. 选择技能({selectedSkills.length}) + +
+
+ {isLoading ? : liveSkills.map((skill) => ( + + ))} +
+
+
+
+ 2. 选择目标({selectedTargetKeys.length}) + {!assistantBase.data && ( + + )} +
+
+ {targets.map((target) => { + const key = `${target.target_type}:${target.target_id}`; + return ( + + ); + })} + {!targets.length &&

未配置普通知识库;可先创建全局“知识梳理”助手库。

} +
+
+
+ 审核策略 + +
+ +
+
+
+ +
+
+
+

归纳任务

后台执行、失败可单项重试

+ +
+ +
+ {(jobs.data ?? []).map((job) => { + const done = job.success_count + job.partial_failed_count + job.failed_count; + const progress = job.total_count ? Math.round((done / job.total_count) * 100) : 0; + return ( +
+
{job.status}{done}/{job.total_count} 项{new Date(job.created_at).toLocaleString()}
+ + {job.items?.map((item) => ( +
+ {item.skill_name} + {PHASE_LABELS[item.phase] || item.phase} + {item.progress}% + {item.status === "failed" && } +
+ ))} +
+ ); + })} + {!jobs.data?.length &&

暂无归纳任务

} +
+
+
+ {(reviews.data?.length ?? 0) > 0 && ( +
+

待审核({reviews.data!.length})

+ {reviews.data!.map((review) =>
{review.object_type}{String(review.original.name || review.original.predicate || review.object_key)}
)} +
+ )} +
+
+ 自动同步 + {autoSettings.data && <> + + + + setScheduleCron(event.target.value)} aria-label="自动同步 cron" /> + setDebounceSeconds(Number(event.target.value))} aria-label="防抖秒数" /> + + } +
+
当前有效绑定:{bindings.data?.length ?? 0};同名同类型实体按来源贡献合并。
+
{bindings.data?.map((binding) =>
{binding.skill_name}→ {binding.target_name}{binding.status}{binding.remote_business_key}
)}
+
+
+
+ ); +} diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index bf59e850..d24f9f7e 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -8,6 +8,31 @@ export interface PromptPrefixSettings { prompt_prefix: string; } +export type KnowledgeBaseProductVersion = "v1" | "v2"; + +export async function loadKnowledgeBaseProductVersion(): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`, + ); + if (!response.ok) throw new Error(`Failed to load knowledge base product version: ${response.status}`); + return ((await response.json()) as { version: KnowledgeBaseProductVersion }).version; +} + +export async function updateKnowledgeBaseProductVersion( + version: KnowledgeBaseProductVersion, +): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ version }), + }, + ); + if (!response.ok) throw new Error(`Failed to update knowledge base product version: ${response.status}`); + return ((await response.json()) as { version: KnowledgeBaseProductVersion }).version; +} + export interface PluginDomainAgentMappingDto { id: string; domain: string; diff --git a/frontend-web/src/pages/AdminSkillsPage.tsx b/frontend-web/src/pages/AdminSkillsPage.tsx index 2dba69ed..4d3e0407 100644 --- a/frontend-web/src/pages/AdminSkillsPage.tsx +++ b/frontend-web/src/pages/AdminSkillsPage.tsx @@ -3,6 +3,7 @@ import { ChevronLeftIcon, ChevronRightIcon, + DatabaseZap, Replace, SearchIcon, SparklesIcon, @@ -18,9 +19,11 @@ import { WorkspaceHeader, } from "@/components/workspace/workspace-container"; import { SkillRow } from "@/components/workspace/settings/skill-settings-page"; +import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel"; import { getStoredAuth } from "@/core/auth"; import { useAllSkillsAdmin } from "@/core/skills/hooks"; import { desensitize } from "@/lib/desensitize"; +import { useSkillKnowledgeBindings } from "@/strategy-components/api/skill-knowledge"; import { SkillAddressesPanel } from "./SkillAddressesPage"; const PAGE_SIZE = 10; @@ -29,6 +32,7 @@ export default function AdminSkillsPage() { const [search, setSearch] = useState(""); const [page, setPage] = useState(1); const { skills, isLoading, error } = useAllSkillsAdmin(true); + const knowledgeBindings = useSkillKnowledgeBindings(); const storedAuth = useMemo(() => getStoredAuth(), []); const currentUserId = storedAuth?.user_id ?? null; @@ -71,6 +75,10 @@ export default function AdminSkillsPage() { 技能地址管理 + + + 技能知识归纳 + @@ -126,6 +134,9 @@ export default function AdminSkillsPage() { skill={skill} currentUserId={currentUserId} isAdmin + knowledgeBindings={(knowledgeBindings.data ?? []).filter( + (binding) => binding.skill_name === skill.name, + )} /> ))} @@ -164,6 +175,12 @@ export default function AdminSkillsPage() { > + + + diff --git a/frontend-web/src/pages/AssistantKnowledgePage.tsx b/frontend-web/src/pages/AssistantKnowledgePage.tsx new file mode 100644 index 00000000..a0009085 --- /dev/null +++ b/frontend-web/src/pages/AssistantKnowledgePage.tsx @@ -0,0 +1,74 @@ +import { Database, FileUp, History, Loader2, RefreshCw, Search, ShieldCheck, Trash2 } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; +import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav"; +import { getStoredAuth } from "@/core/auth"; +import { + listAssistantWikiRevisions, + type AssistantWikiPage, + type AssistantWikiRevision, + useAssistantImportJobs, + useAssistantKnowledgeActions, + useAssistantKnowledgeBase, + useAssistantKnowledgeSources, + useAssistantWikiPages, +} from "@/strategy-components/api/assistant-knowledge"; + +export default function AssistantKnowledgePage() { + const isAdmin = getStoredAuth()?.system_role === "admin"; + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + const [revisions, setRevisions] = useState([]); + const fileRef = useRef(null); + const base = useAssistantKnowledgeBase(); + const pages = useAssistantWikiPages(query); + const sources = useAssistantKnowledgeSources(); + const jobs = useAssistantImportJobs(); + const actions = useAssistantKnowledgeActions(); + const activePage = selected ?? pages.data?.[0] ?? null; + + const counts = useMemo(() => ({ pages: pages.data?.length ?? 0, sources: sources.data?.length ?? 0, jobs: jobs.data?.length ?? 0 }), [pages.data, sources.data, jobs.data]); + + if (base.isLoading) return
; + if (!base.data) { + return

创建“知识梳理”助手知识库

无需配置普通知识库服务。创建后所有用户可查看 Wiki 与搜索,只有管理员可导入和维护。

{isAdmin ? :

请联系管理员初始化。

}
; + } + + const uploadFiles = async (files: FileList | null) => { + if (!files?.length) return; + try { + await actions.upload.mutateAsync(Array.from(files)); + toast.success("文件已安全扫描并导入"); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } finally { + if (fileRef.current) fileRef.current.value = ""; + } + }; + + const openHistory = async () => { + if (!activePage) return; + setRevisions(await listAssistantWikiRevisions(activePage.slug)); + }; + + return ( +
+ +

{base.data.name}

全局助手知识库 · 普通用户只读

实体对齐与版本管理
{isAdmin && <> void uploadFiles(event.target.files)} />}
+ +
Wiki({counts.pages})来源({counts.sources})导入记录({counts.jobs})版本({revisions.length})
setQuery(event.target.value)} placeholder="搜索标题、实体别名和正文" className="pl-8" />
+
{pages.data?.map((page) => )}
{activePage ? <>

{activePage.title}

{activePage.slug}

:

暂无 Wiki 页面。

}
+
{sources.data?.map((source) =>
{source.source_name}{source.source_type}

业务键:{source.source_key}

{isAdmin &&
}
)}
+
{jobs.data?.map((job) =>
{job.status}{job.trigger}{Object.entries(job.counts || {}).map(([key, value]) => `${key} ${value}`).join(" · ")}{new Date(job.created_at).toLocaleString()}
)}
+
{revisions.map((revision) =>
v{revision.revision_no}{revision.change_type}{revision.summary}{new Date(revision.created_at).toLocaleString()}{isAdmin && activePage && revision.id !== activePage.current_revision_id && }
)}{!revisions.length &&

请先在 Wiki 页选择页面并点击“页面版本”。

}
+
+
+ ); +} diff --git a/frontend-web/src/pages/LlmWikiKnowledgePage.tsx b/frontend-web/src/pages/LlmWikiKnowledgePage.tsx index 2e3d745d..c15203e8 100644 --- a/frontend-web/src/pages/LlmWikiKnowledgePage.tsx +++ b/frontend-web/src/pages/LlmWikiKnowledgePage.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Navigate, useParams, useSearchParams } from "react-router-dom"; import { Button } from "@/components/ui/button"; +import { KnowledgeProductNav, useKnowledgeBaseProductVersion } from "@/components/workspace/knowledge-product-nav"; import { Dialog, DialogContent, @@ -70,6 +71,7 @@ export default function LlmWikiKnowledgePage({ const { noteId = "" } = useParams(); const [searchParams] = useSearchParams(); const runtime = useLlmWikiRuntime(); + const productVersion = useKnowledgeBaseProductVersion(); const wikiSlug = searchParams.get("wikiSlug")?.trim() || ""; if (mode === "detail" && noteId && wikiSlug) { @@ -80,11 +82,15 @@ export default function LlmWikiKnowledgePage({ return ; } + if (mode === "list" && productVersion.data === "v2") { + return ; + } + if (runtime.isError || runtime.data?.provider !== "weknora") { if (mode === "graph") { return ; } - return ; + return mode === "list" ?
: ; } if (mode === "graph") { @@ -95,5 +101,5 @@ export default function LlmWikiKnowledgePage({ return ; } - return ; + return
; } diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 92bab8fc..d44866eb 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -40,6 +40,7 @@ import AdminSkillsPage from './AdminSkillsPage'; import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage'; import ScheduledTasksPage from './ScheduledTasksPage'; import LlmWikiKnowledgePage from './LlmWikiKnowledgePage'; +import AssistantKnowledgePage from './AssistantKnowledgePage'; import WeKnoraWikiManagementPage from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiManagementPage'; import WeKnoraWikiPageView from '@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView'; import MarkdownDocxEditorPage from './MarkdownDocxEditorPage'; @@ -689,6 +690,14 @@ export default function WorkspaceRoutes({ } /> + + + + } + /> `${getBackendBaseURL()}/api/assistant-knowledge`; + +export interface AssistantKnowledgeBase { + id: string; + name: string; + status: string; + created_at: string; + updated_at: string; +} + +export interface AssistantWikiPage { + id: string; + slug: string; + title: string; + page_type: string; + content_markdown: string; + summary: string; + current_revision_id?: string | null; + updated_at: string; +} + +export interface AssistantWikiRevision { + id: string; + page_id: string; + revision_no: number; + summary: string; + content_markdown: string; + change_type: string; + created_by?: string | null; + created_at: string; +} + +export interface AssistantKnowledgeSource { + id: string; + source_type: "weknora" | "skill" | "local_file"; + source_name: string; + source_key: string; + status: string; + updated_at: string; +} + +export interface AssistantImportJob { + id: string; + source_id: string; + trigger: string; + status: string; + phase: string; + counts: Record; + created_at: string; +} + +async function readJson(response: Response): Promise { + if (!response.ok) { + const data = await response.json().catch(() => null); + const detail = data && typeof data === "object" && "detail" in data + ? (data as { detail: unknown }).detail + : `请求失败 (${response.status})`; + throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); + } + return (await response.json()) as T; +} + +export async function getAssistantKnowledgeBase() { + const data = await readJson<{ base: AssistantKnowledgeBase | null }>(await apiFetch(`${BASE()}/base`)); + return data.base; +} + +export async function initializeAssistantKnowledgeBase(name = "知识梳理") { + const data = await readJson<{ base: AssistantKnowledgeBase }>( + await apiFetch(`${BASE()}/base/initialize`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }), + ); + return data.base; +} + +export async function listAssistantWikiPages(q = "") { + const params = new URLSearchParams(); + if (q.trim()) params.set("q", q.trim()); + return (await readJson<{ pages: AssistantWikiPage[] }>(await apiFetch(`${BASE()}/wiki-pages?${params}`))).pages; +} + +export async function listAssistantKnowledgeSources() { + return (await readJson<{ sources: AssistantKnowledgeSource[] }>(await apiFetch(`${BASE()}/sources`))).sources; +} + +export async function listAssistantImportJobs() { + return (await readJson<{ jobs: AssistantImportJob[] }>(await apiFetch(`${BASE()}/import-jobs`))).jobs; +} + +export async function listAssistantWikiRevisions(slug: string) { + return (await readJson<{ revisions: AssistantWikiRevision[] }>( + await apiFetch(`${BASE()}/wiki-pages/${encodeURIComponent(slug)}/revisions`), + )).revisions; +} + +export async function rollbackAssistantWikiRevision(slug: string, revisionId: string) { + return readJson( + await apiFetch( + `${BASE()}/wiki-pages/${encodeURIComponent(slug)}/revisions/${encodeURIComponent(revisionId)}/rollback`, + { method: "POST" }, + ), + ); +} + +export async function uploadAssistantKnowledgeFiles(files: File[]) { + const form = new FormData(); + files.forEach((file) => form.append("files", file)); + return readJson<{ batch_id: string; jobs: AssistantImportJob[] }>( + await apiFetch(`${BASE()}/imports/files`, { method: "POST", body: form }), + ); +} + +export async function exportLlmWikiToAssistant(mappingId: string) { + return readJson( + await apiFetch( + `${getBackendBaseURL()}/api/llmwiki/knowledge-bases/${encodeURIComponent(mappingId)}/export-to-assistant`, + { method: "POST" }, + ), + ); +} + +export async function reimportAssistantKnowledgeSource(sourceId: string) { + return readJson( + await apiFetch(`${BASE()}/sources/${encodeURIComponent(sourceId)}/reimport`, { + method: "POST", + }), + ); +} + +export async function deleteAssistantKnowledgeSource(sourceId: string) { + const response = await apiFetch(`${BASE()}/sources/${encodeURIComponent(sourceId)}`, { + method: "DELETE", + }); + if (!response.ok) await readJson(response); +} + +const KEY = "assistant-knowledge"; + +export function useAssistantKnowledgeBase() { + return useQuery({ queryKey: [KEY, "base"], queryFn: getAssistantKnowledgeBase }); +} + +export function useAssistantWikiPages(query = "") { + return useQuery({ queryKey: [KEY, "pages", query], queryFn: () => listAssistantWikiPages(query) }); +} + +export function useAssistantKnowledgeSources() { + return useQuery({ queryKey: [KEY, "sources"], queryFn: listAssistantKnowledgeSources }); +} + +export function useAssistantImportJobs() { + return useQuery({ queryKey: [KEY, "jobs"], queryFn: listAssistantImportJobs }); +} + +export function useAssistantKnowledgeActions() { + const client = useQueryClient(); + const invalidate = () => client.invalidateQueries({ queryKey: [KEY] }); + return { + initialize: useMutation({ mutationFn: initializeAssistantKnowledgeBase, onSuccess: invalidate }), + upload: useMutation({ mutationFn: uploadAssistantKnowledgeFiles, onSuccess: invalidate }), + reimportSource: useMutation({ mutationFn: reimportAssistantKnowledgeSource, onSuccess: invalidate }), + deleteSource: useMutation({ mutationFn: deleteAssistantKnowledgeSource, onSuccess: invalidate }), + rollback: useMutation({ + mutationFn: ({ slug, revisionId }: { slug: string; revisionId: string }) => + rollbackAssistantWikiRevision(slug, revisionId), + onSuccess: invalidate, + }), + }; +} diff --git a/frontend-web/src/strategy-components/api/skill-knowledge.ts b/frontend-web/src/strategy-components/api/skill-knowledge.ts new file mode 100644 index 00000000..cc8f7ccc --- /dev/null +++ b/frontend-web/src/strategy-components/api/skill-knowledge.ts @@ -0,0 +1,277 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; + +const BASE = () => `${getBackendBaseURL()}/api/skill-knowledge`; + +export type SkillKnowledgeTargetType = "weknora" | "assistant"; +export type SkillKnowledgeTargetMode = + | "wiki" + | "document" + | "faq" + | "conversation_archive" + | "readonly_sidecar" + | "assistant"; + +export interface SkillKnowledgeTarget { + target_type: SkillKnowledgeTargetType; + target_mode: SkillKnowledgeTargetMode; + target_id: string; + target_name: string; + remote_business_key?: string | null; + readonly?: boolean; +} + +export interface SkillKnowledgeBinding { + id: string; + skill_name: string; + target_type: SkillKnowledgeTargetType; + target_mode: SkillKnowledgeTargetMode; + target_id: string; + target_name: string; + remote_business_key?: string | null; + remote_refs?: Array<{ + provider: string; + remote_kind: string; + remote_id: string; + slug?: string; + }>; + status: string; + active: boolean; + auto_sync_enabled: boolean; + synced_digest?: string | null; + last_synced_at?: string | null; + last_error_message?: string | null; +} + +export interface SkillKnowledgeSyncItem { + id: string; + job_id: string; + binding_id: string; + skill_name: string; + target_type: SkillKnowledgeTargetType; + target_mode: SkillKnowledgeTargetMode; + target_id: string; + status: string; + phase: string; + progress: number; + attempts: number; + last_error_message?: string | null; +} + +export interface SkillKnowledgeJob { + id: string; + status: string; + total_count: number; + success_count: number; + partial_failed_count: number; + failed_count: number; + review_mode: "required" | "auto_high_confidence" | "off"; + created_at: string; + completed_at?: string | null; + items?: SkillKnowledgeSyncItem[]; +} + +export interface SkillKnowledgeReview { + id: string; + job_id: string; + item_id: string; + binding_id: string; + object_type: "entity" | "relation" | "wiki" | "alignment"; + object_key: string; + original: Record; + edited?: Record | null; + status: string; + reason?: string | null; +} + +export interface SkillKnowledgeAutoSyncSettings { + watch_enabled: boolean; + schedule_enabled: boolean; + schedule_cron: string; + debounce_seconds: number; + write_remote_enabled: boolean; + review_mode: "required" | "auto_high_confidence" | "off"; +} + +async function readJson(response: Response): Promise { + if (!response.ok) { + const data = await response.json().catch(() => null); + const detail = + data && typeof data === "object" && "detail" in data + ? JSON.stringify((data as { detail: unknown }).detail) + : `请求失败 (${response.status})`; + throw new Error(detail.replace(/^"|"$/g, "")); + } + return (await response.json()) as T; +} + +export async function listSkillKnowledgeBindings( + skillNames?: string[], +): Promise { + const params = new URLSearchParams(); + for (const name of skillNames ?? []) params.append("skill_names", name); + const response = await apiFetch(`${BASE()}/bindings?${params.toString()}`); + return (await readJson<{ bindings: SkillKnowledgeBinding[] }>(response)).bindings; +} + +export async function createSkillKnowledgeJob(input: { + skill_names: string[]; + targets: SkillKnowledgeTarget[]; + force?: boolean; + review_mode?: "required" | "auto_high_confidence" | "off"; +}): Promise { + return readJson( + await apiFetch(`${BASE()}/sync-jobs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }), + ); +} + +export async function listSkillKnowledgeJobs(): Promise { + const response = await apiFetch(`${BASE()}/sync-jobs`); + return (await readJson<{ jobs: SkillKnowledgeJob[] }>(response)).jobs; +} + +export async function getSkillKnowledgeJob(id: string): Promise { + return readJson(await apiFetch(`${BASE()}/sync-jobs/${encodeURIComponent(id)}`)); +} + +export async function listSkillKnowledgeReviews(): Promise { + const response = await apiFetch(`${BASE()}/reviews?status=pending`); + return (await readJson<{ reviews: SkillKnowledgeReview[] }>(response)).reviews; +} + +export async function reviewSkillKnowledge(input: { + review_ids: string[]; + action: "approve" | "reject"; + edits?: Record>; + reason?: string; +}) { + return readJson<{ updated: number; resumed_item_ids: string[] }>( + await apiFetch(`${BASE()}/reviews/actions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }), + ); +} + +export async function retrySkillKnowledgeItem(itemId: string) { + return readJson( + await apiFetch(`${BASE()}/sync-items/${encodeURIComponent(itemId)}/retry`, { + method: "POST", + }), + ); +} + +export async function detachSkillKnowledgeBinding( + bindingId: string, + deleteContributions: boolean, +) { + return readJson( + await apiFetch(`${BASE()}/bindings/${encodeURIComponent(bindingId)}/detach`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ delete_contributions: deleteContributions }), + }), + ); +} + +export async function resyncSkillKnowledgeBinding(bindingId: string) { + return readJson( + await apiFetch(`${BASE()}/bindings/${encodeURIComponent(bindingId)}/resync`, { + method: "POST", + }), + ); +} + +export async function setSkillKnowledgeBindingAutoSync( + bindingId: string, + enabled: boolean, +) { + return readJson( + await apiFetch( + `${BASE()}/bindings/${encodeURIComponent(bindingId)}/${enabled ? "enable" : "disable"}-auto-sync`, + { method: "POST" }, + ), + ); +} + +export async function getSkillKnowledgeAutoSyncSettings() { + return readJson( + await apiFetch(`${BASE()}/auto-sync-settings`), + ); +} + +export async function updateSkillKnowledgeAutoSyncSettings( + input: Partial, +) { + return readJson( + await apiFetch(`${BASE()}/auto-sync-settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }), + ); +} + +const KEY = "skill-knowledge"; + +export function useSkillKnowledgeBindings(skillNames?: string[]) { + return useQuery({ + queryKey: [KEY, "bindings", ...(skillNames ?? [])], + queryFn: () => listSkillKnowledgeBindings(skillNames), + refetchOnWindowFocus: false, + }); +} + +export function useSkillKnowledgeJobs() { + return useQuery({ + queryKey: [KEY, "jobs"], + queryFn: listSkillKnowledgeJobs, + refetchInterval: (query) => + (query.state.data ?? []).some((job) => ["queued", "running"].includes(job.status)) + ? 1500 + : false, + }); +} + +export function useSkillKnowledgeReviews() { + return useQuery({ queryKey: [KEY, "reviews"], queryFn: listSkillKnowledgeReviews }); +} + +export function useSkillKnowledgeAutoSyncSettings() { + return useQuery({ + queryKey: [KEY, "auto-sync-settings"], + queryFn: getSkillKnowledgeAutoSyncSettings, + }); +} + +export function useSkillKnowledgeActions() { + const client = useQueryClient(); + const invalidate = () => client.invalidateQueries({ queryKey: [KEY] }); + return { + createJob: useMutation({ mutationFn: createSkillKnowledgeJob, onSuccess: invalidate }), + review: useMutation({ mutationFn: reviewSkillKnowledge, onSuccess: invalidate }), + retry: useMutation({ mutationFn: retrySkillKnowledgeItem, onSuccess: invalidate }), + resync: useMutation({ mutationFn: resyncSkillKnowledgeBinding, onSuccess: invalidate }), + setAutoSync: useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + setSkillKnowledgeBindingAutoSync(id, enabled), + onSuccess: invalidate, + }), + updateAutoSyncSettings: useMutation({ + mutationFn: updateSkillKnowledgeAutoSyncSettings, + onSuccess: invalidate, + }), + detach: useMutation({ + mutationFn: ({ id, deleteContributions }: { id: string; deleteContributions: boolean }) => + detachSkillKnowledgeBinding(id, deleteContributions), + onSuccess: invalidate, + }), + }; +} diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx index 136f79c9..593e2a4a 100644 --- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx +++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, Edit3, FileText, Loader2, RefreshCw, Trash2 } from "lucide-react"; +import { AlertCircle, Database, Edit3, FileText, Loader2, RefreshCw, Trash2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor"; import { getBackendBaseURL } from "@/core/config"; +import { getStoredAuth } from "@/core/auth"; import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links"; import { createLlmWikiWeKnoraEmbedSession, @@ -23,6 +24,7 @@ import { useLlmWikiWikiPage, useLlmWikiRuntime, } from "@/strategy-components/api/llmwiki"; +import { exportLlmWikiToAssistant } from "@/strategy-components/api/assistant-knowledge"; const LIST_PATH = "/page/workspace/knowledge"; @@ -104,6 +106,7 @@ export function WeKnoraKnowledgeDetailPage() { const [frameSrc, setFrameSrc] = useState(""); const [frameVersion, setFrameVersion] = useState(0); const [frameError, setFrameError] = useState(null); + const [assistantImporting, setAssistantImporting] = useState(false); const runtime = useLlmWikiRuntime(); const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId); @@ -255,6 +258,23 @@ export function WeKnoraKnowledgeDetailPage() { )}
+ {getStoredAuth()?.system_role === "admin" && ( + + )} - ); - })} -
- )) +
+ {group.key === "personal" ? : }{group.label} +
+ )} + {group.rows.map((item) => { + const rowContent = ( + <> + +
+
{item.name}
+
+ {item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识 +
+
+ {item.publication_status === "published" && ( + 公共 + )} + + ); + return variant === "popover" ? ( + toggle(item.id)} + onSelect={(event) => event.preventDefault()} + className={cn( + "my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7", + selected.has(item.id) && "bg-primary/8 focus:bg-primary/12", + )} + > + {rowContent} + + ) : ( + + ); + })} + + ))} + {filteredAssistant.length > 0 && ( +
+ {(groups.length > 0) && ( + variant === "popover" + ? + :
+ )} + {variant === "popover" ? ( + + 助手知识库 + + ) : ( +
+ 助手知识库 +
+ )} + {filteredAssistant.map((item) => { + const rowContent = ( + <> + +
+
{item.name}
+
+ Wiki · {item.page_count ?? 0} 页 · {item.entity_count ?? 0} 实体 +
+
+ + {item.is_global ? "总库" : "助手"} + + + ); + return variant === "popover" ? ( + toggleAssistant(item.id)} + onSelect={(event) => event.preventDefault()} + className={cn( + "my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7", + selectedAssistant.has(item.id) && "bg-primary/8 focus:bg-primary/12", + )} + > + {rowContent} + + ) : ( + + ); + })} +
+ )} + )}
- {readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`} + {readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`} {readOnly ? ( 对话开始后不可更改 ) : (
- -
@@ -232,7 +363,9 @@ function SelectionContent({ export function LlmWikiKnowledgeSelector({ value, + assistantValue = [], onChange, + onAssistantChange = () => undefined, source = "all", agentId, autoSelectAll = false, @@ -243,7 +376,9 @@ export function LlmWikiKnowledgeSelector({ variant = "popover", }: { value: string[]; + assistantValue?: string[]; onChange: (value: string[]) => void; + onAssistantChange?: (value: string[]) => void; source?: SelectorSource; agentId?: string | null; autoSelectAll?: boolean; @@ -257,28 +392,39 @@ export function LlmWikiKnowledgeSelector({ const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const runtime = useLlmWikiRuntime(); + const weknoraEnabled = runtime.data?.provider === "weknora"; const all = useLlmWikiKnowledgeBases( "all", - runtime.data?.provider === "weknora", + weknoraEnabled, ); const agent = useAgentLlmWikiKnowledgeBases( agentId, - runtime.data?.provider === "weknora" && source === "agent" && !!agentId, + weknoraEnabled && source === "agent" && !!agentId, ); - const rows = all.data ?? []; + const assistantBases = useAssistantKnowledgeBases(); + const rows = weknoraEnabled ? (all.data ?? []) : []; + const assistantRows = assistantBases.data ?? []; const defaultIds = useMemo(() => { if (source !== "agent") { return rows.map((item) => item.id); } return (agent.data ?? []).map((item) => item.id); }, [agent.data, rows, source]); - const loading = all.isLoading || (source === "agent" && !!agentId && agent.isLoading); - const error = all.error ?? (source === "agent" ? agent.error : null); + const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading); + const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error; const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]); + const assistantAvailableIds = useMemo( + () => new Set(assistantRows.map((item) => item.id)), + [assistantRows], + ); const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`; const initialized = useRef(null); useEffect(() => { + if (!runtime.isLoading && !weknoraEnabled) { + if (value.length > 0) onChange([]); + return; + } if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return; initialized.current = initializationKey; const valid = value.filter((id) => availableIds.has(id)); @@ -288,19 +434,30 @@ export function LlmWikiKnowledgeSelector({ if (next.length !== value.length || next.some((id, index) => id !== value[index])) { onChange(next); } - }, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, source, value]); + }, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]); + + useEffect(() => { + if (!assistantBases.data) return; + const valid = assistantValue.filter((id) => assistantAvailableIds.has(id)); + if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) { + onAssistantChange(valid); + } + }, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]); useEffect(() => { if (!open) setSearch(""); }, [open]); - if (runtime.isError || runtime.data?.provider !== "weknora") return null; + if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null; const content = (
- {value.length} 个 + {value.length + assistantValue.length} 个 {content} @@ -333,10 +490,15 @@ export function LlmWikiKnowledgeSelector({ const selectedNames = value .map((id) => rows.find((item) => item.id === id)?.name) .filter((name): name is string => !!name); - const selectedLabel = selectedNames.length > 0 - ? selectedNames.join("、") - : value.length > 0 - ? `已选 ${value.length} 个知识空间` + const selectedAssistantNames = assistantValue + .map((id) => assistantRows.find((item) => item.id === id)?.name) + .filter((name): name is string => !!name); + const allSelectedNames = [...selectedNames, ...selectedAssistantNames]; + const selectedCount = value.length + assistantValue.length; + const selectedLabel = allSelectedNames.length > 0 + ? allSelectedNames.join("、") + : selectedCount > 0 + ? `已选 ${selectedCount} 个知识空间` : label; return ( @@ -348,20 +510,20 @@ export function LlmWikiKnowledgeSelector({ size="sm" className={cn( "h-8 max-w-52 gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground", - value.length > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary", + selectedCount > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary", className, )} disabled={disabled || loading} - title={value.length > 0 ? selectedLabel : undefined} + title={selectedCount > 0 ? selectedLabel : undefined} > {loading ? : } {selectedLabel} - {value.length > 0 && ( + {selectedCount > 0 && ( - {value.length} + {selectedCount} )} - {value.length > 0 && value.length === rows.length && } + {selectedCount > 0 && selectedCount === rows.length + assistantRows.length && } diff --git a/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx b/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx index 77fe43d7..c93a520c 100644 --- a/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx +++ b/frontend-web/src/components/workspace/skills/SkillKnowledgePanel.tsx @@ -1,4 +1,4 @@ -import { CheckCircle2, Database, Loader2, RefreshCw, Sparkles } from "lucide-react"; +import { Database, Loader2, RefreshCw, Search, Sparkles } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -10,18 +10,12 @@ import { Progress } from "@/components/ui/progress"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useAllSkillsAdmin } from "@/core/skills/hooks"; -import { - useAssistantKnowledgeActions, - useAssistantKnowledgeBase, -} from "@/strategy-components/api/assistant-knowledge"; import { useLlmWikiKnowledgeBases, useLlmWikiRuntime } from "@/strategy-components/api/llmwiki"; import { type SkillKnowledgeTarget, useSkillKnowledgeActions, - useSkillKnowledgeAutoSyncSettings, useSkillKnowledgeBindings, useSkillKnowledgeJobs, - useSkillKnowledgeReviews, } from "@/strategy-components/api/skill-knowledge"; const PHASE_LABELS: Record = { @@ -39,53 +33,82 @@ export function SkillKnowledgePanel() { const { skills, isLoading } = useAllSkillsAdmin(true); const liveSkills = useMemo(() => skills.filter((skill) => (skill.state ?? "active") === "active"), [skills]); const [selectedSkills, setSelectedSkills] = useState([]); - const [selectedTargetKeys, setSelectedTargetKeys] = useState([]); - const [reviewMode, setReviewMode] = useState<"required" | "auto_high_confidence" | "off">("auto_high_confidence"); + const [skillQuery, setSkillQuery] = useState(""); + const [selectedTargetKey, setSelectedTargetKey] = useState(null); + const [reviewMode, setReviewMode] = useState<"auto_high_confidence" | "off">("auto_high_confidence"); const runtime = useLlmWikiRuntime(); const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora"); - const assistantBase = useAssistantKnowledgeBase(); - const assistantActions = useAssistantKnowledgeActions(); const bindings = useSkillKnowledgeBindings(); const jobs = useSkillKnowledgeJobs(); - const reviews = useSkillKnowledgeReviews(); const actions = useSkillKnowledgeActions(); - const autoSettings = useSkillKnowledgeAutoSyncSettings(); - const [scheduleCron, setScheduleCron] = useState("0 */6 * * *"); - const [debounceSeconds, setDebounceSeconds] = useState(120); - - useEffect(() => { - if (!autoSettings.data) return; - setScheduleCron(autoSettings.data.schedule_cron); - setDebounceSeconds(autoSettings.data.debounce_seconds); - }, [autoSettings.data]); const targets = useMemo(() => { - const rows: SkillKnowledgeTarget[] = (knowledgeBases.data ?? []).map((kb) => ({ - target_type: "weknora", - target_mode: kb.can_write ? kb.type === "faq" ? "faq" : kb.type === "wiki" ? "wiki" : "document" : "readonly_sidecar", - target_id: kb.id, - target_name: kb.name, - remote_business_key: `weknora:kb:${kb.id}`, - readonly: !kb.can_write, - })); - if (assistantBase.data) { - rows.unshift({ - target_type: "assistant", - target_mode: "assistant", - target_id: assistantBase.data.id, - target_name: assistantBase.data.name, - remote_business_key: `assistant:base:${assistantBase.data.id}`, - }); + return (knowledgeBases.data ?? []) + .filter((kb) => { + const strategy = kb.indexing_strategy ?? {}; + const capabilities = kb.capabilities ?? {}; + const wikiEnabled = + kb.type === "wiki" || + kb.wiki_index_enabled === true || + strategy.wiki_enabled === true || + capabilities.wiki_enabled === true || + (kb.wiki_config && Object.keys(kb.wiki_config).length > 0); + return kb.can_write && kb.remote_status !== "missing" && !kb.is_conversation_deposit && kb.type !== "faq" && wikiEnabled; + }) + .map((kb) => ({ + target_type: "weknora", + target_mode: "wiki", + target_id: kb.id, + target_name: kb.name, + remote_business_key: `weknora:kb:${kb.id}`, + })); + }, [knowledgeBases.data]); + + const filteredSkills = useMemo(() => { + const needle = skillQuery.trim().toLowerCase(); + if (!needle) return liveSkills; + return liveSkills.filter((skill) => { + const tagText = (skill.tags ?? []).map((tag) => tag.name).join(" "); + const haystack = [ + skill.name, + skill.name_zh, + skill.description, + skill.category, + tagText, + ] + .filter(Boolean) + .join("\n") + .toLowerCase(); + return haystack.includes(needle); + }); + }, [liveSkills, skillQuery]); + + const filteredSkillNames = useMemo(() => filteredSkills.map((skill) => skill.name), [filteredSkills]); + const allFilteredSelected = filteredSkillNames.length > 0 && filteredSkillNames.every((name) => selectedSkills.includes(name)); + + const toggleFilteredSkills = () => { + if (!filteredSkillNames.length) return; + setSelectedSkills((current) => { + if (allFilteredSelected) { + const filtered = new Set(filteredSkillNames); + return current.filter((name) => !filtered.has(name)); + } + return [...current, ...filteredSkillNames.filter((name) => !current.includes(name))]; + }); + }; + + useEffect(() => { + if (selectedTargetKey && !targets.some((target) => `${target.target_type}:${target.target_id}` === selectedTargetKey)) { + setSelectedTargetKey(null); } - return rows; - }, [assistantBase.data, knowledgeBases.data]); + }, [selectedTargetKey, targets]); const toggle = (values: string[], value: string, setter: (next: string[]) => void) => setter(values.includes(value) ? values.filter((item) => item !== value) : [...values, value]); const submit = async () => { const selectedTargets = targets.filter((target) => - selectedTargetKeys.includes(`${target.target_type}:${target.target_id}`), + selectedTargetKey === `${target.target_type}:${target.target_id}`, ); if (!selectedSkills.length || !selectedTargets.length) { toast.error("请至少选择一个技能和一个目标知识库"); @@ -108,46 +131,76 @@ export function SkillKnowledgePanel() {
-

批量归纳

-

按“技能 × 目标知识库”生成独立后台任务;代码与脚本不会被读取。

+

技能到知识库管理

+

一次选择多个技能归纳到一个 WeKnora Wiki 知识库;代码与脚本不会被读取。

1. 选择技能({selectedSkills.length}) - + +
+
+ + setSkillQuery(event.target.value)} + placeholder="按技能中文名、英文名、描述、分类或标签搜索" + className="pl-8" + />
- {isLoading ? : liveSkills.map((skill) => ( + {isLoading ? : filteredSkills.map((skill) => ( ))} + {!isLoading && !filteredSkills.length && ( +

没有匹配的技能,请换个关键词。

+ )}
+

+ 当前显示 {filteredSkills.length}/{liveSkills.length} 个技能。 +

- 2. 选择目标({selectedTargetKeys.length}) - {!assistantBase.data && ( - - )} + 2. 选择目标知识库({selectedTargetKey ? 1 : 0})
{targets.map((target) => { const key = `${target.target_type}:${target.target_id}`; return ( ); })} - {!targets.length &&

未配置普通知识库;可先创建全局“知识梳理”助手库。

} + {(runtime.isLoading || knowledgeBases.isLoading) && } + {!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && ( +

+ 读取 WeKnora 普通知识库失败: + {knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)} +

+ )} + {!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && ( +

+ {runtime.data?.provider !== "weknora" + ? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。" + : "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"} +

+ )}
@@ -156,14 +209,13 @@ export function SkillKnowledgePanel() { 高置信自动通过 - 全部逐条审核 不阻塞写入
@@ -199,26 +251,9 @@ export function SkillKnowledgePanel() {
- {(reviews.data?.length ?? 0) > 0 && ( -
-

待审核({reviews.data!.length})

- {reviews.data!.map((review) =>
{review.object_type}{String(review.original.name || review.original.predicate || review.object_key)}
)} -
- )}
-
- 自动同步 - {autoSettings.data && <> - - - - setScheduleCron(event.target.value)} aria-label="自动同步 cron" /> - setDebounceSeconds(Number(event.target.value))} aria-label="防抖秒数" /> - - } -
当前有效绑定:{bindings.data?.length ?? 0};同名同类型实体按来源贡献合并。
-
{bindings.data?.map((binding) =>
{binding.skill_name}→ {binding.target_name}{binding.status}{binding.remote_business_key}
)}
+
{bindings.data?.map((binding) =>
{binding.skill_name}→ {binding.target_name}{binding.status}{binding.remote_business_key}
)}
diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index d3a26b04..f9065b45 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -14,7 +14,7 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; -import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; +import { BookOpen, BotIcon, BrainIcon, FileSearch, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; import { KnowledgeRagToggle } from "./knowledge-rag-toggle"; import { SidebarCollapseButton } from "./workspace-header"; @@ -65,6 +65,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [ path: "/page/workspace/chats", match: (pathname) => pathname === "/page/workspace/chats", }, + { + id: "workspace-library", + label: "资料库", + icon: Library, + path: "/page/workspace/library", + match: (pathname) => pathname === "/page/workspace/library", + }, { id: "workspace-agents", label: "智能体", diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 05e15990..433753da 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -229,6 +229,14 @@ export function WorkspaceNavMenu() { 技能管理 )} + {isAdmin && ( + navigate("/page/workspace/admin/skills?tab=knowledge")} + > + + 技能到知识库管理 + + )} {isAdmin && ( navigate("/page/workspace/admin/models")} diff --git a/frontend-web/src/core/artifacts/download.ts b/frontend-web/src/core/artifacts/download.ts index e3f6b6b8..a051628a 100644 --- a/frontend-web/src/core/artifacts/download.ts +++ b/frontend-web/src/core/artifacts/download.ts @@ -1,5 +1,6 @@ -import { apiFetch } from "../api"; -import { getFileName } from "../utils/files"; +import { apiFetch } from "../api"; +import { downloadMarkdownAsDocx } from "../scheduled-tasks/markdown-docx"; +import { getFileName } from "../utils/files"; import { urlOfArtifact } from "./utils"; @@ -39,7 +40,7 @@ async function errorMessageFromResponse(res: Response): Promise { return res.statusText || "Request failed"; } -export async function downloadArtifactFile({ +export async function downloadArtifactFile({ filepath, threadId, isMock, @@ -73,8 +74,26 @@ export async function downloadArtifactFile({ document.body.appendChild(a); a.click(); a.remove(); - window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); -} + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); +} + +export async function downloadArtifactMarkdownAsWord({ + filepath, + threadId, +}: { + filepath: string; + threadId: string; +}): Promise { + const url = urlOfArtifact({ filepath, threadId, download: false }); + const res = await apiFetch(url); + if (!res.ok) { + throw new Error(await errorMessageFromResponse(res)); + } + + const fileName = getFileName(filepath); + const title = fileName.replace(/\.(md|markdown)$/i, ""); + await downloadMarkdownAsDocx(await res.text(), fileName, title); +} export async function openArtifactInNewTab({ filepath, diff --git a/frontend-web/src/core/artifacts/library.ts b/frontend-web/src/core/artifacts/library.ts index a088791a..ade3d60c 100644 --- a/frontend-web/src/core/artifacts/library.ts +++ b/frontend-web/src/core/artifacts/library.ts @@ -9,11 +9,15 @@ export type ArtifactLibraryKind = | "archive" | "other"; +export type ArtifactLibrarySource = "all" | "general_qa" | "agent"; + export interface ArtifactLibraryItem { threadId: string; threadTitle: string; agentId: string | null; + agentName: string | null; threadType: string | null; + sourceType: Exclude; path: string; name: string; sizeBytes: number; @@ -32,6 +36,7 @@ export interface ArtifactLibraryResponse { export async function fetchArtifactLibrary({ query, fileKind, + sourceType, offset, limit, refresh = false, @@ -39,6 +44,7 @@ export async function fetchArtifactLibrary({ }: { query?: string; fileKind: ArtifactLibraryKind; + sourceType: ArtifactLibrarySource; offset: number; limit: number; refresh?: boolean; @@ -46,6 +52,7 @@ export async function fetchArtifactLibrary({ }): Promise { const search = new URLSearchParams({ file_kind: fileKind, + source_type: sourceType, offset: String(offset), limit: String(limit), }); @@ -72,7 +79,9 @@ export async function fetchArtifactLibrary({ thread_id: string; thread_title: string; agent_id?: string | null; + agent_name?: string | null; thread_type?: string | null; + source_type?: Exclude; path: string; name: string; size_bytes: number; @@ -90,7 +99,9 @@ export async function fetchArtifactLibrary({ threadId: item.thread_id, threadTitle: item.thread_title, agentId: item.agent_id ?? null, + agentName: item.agent_name ?? item.agent_id ?? null, threadType: item.thread_type ?? null, + sourceType: item.source_type ?? (item.agent_id ? "agent" : "general_qa"), path: item.path, name: item.name, sizeBytes: item.size_bytes, diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index f83f0cdd..51d2c633 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -1293,15 +1293,21 @@ export function useThreadStream({ ...context, ...extraContext, }; - const selectedLlmWikiIds = Array.isArray( - mergedContext.llmwiki_knowledge_base_ids, - ) - ? mergedContext.llmwiki_knowledge_base_ids - : []; - const ragModeEnabled = - localSettings.display.ragModeEnabled || - isThreadRagModeLocked(threadId) || - selectedLlmWikiIds.length > 0; + const selectedLlmWikiIds = Array.isArray( + mergedContext.llmwiki_knowledge_base_ids, + ) + ? mergedContext.llmwiki_knowledge_base_ids + : []; + const selectedAssistantKnowledgeIds = Array.isArray( + mergedContext.assistant_knowledge_base_ids, + ) + ? mergedContext.assistant_knowledge_base_ids + : []; + const ragModeEnabled = + localSettings.display.ragModeEnabled || + isThreadRagModeLocked(threadId) || + selectedLlmWikiIds.length > 0 || + selectedAssistantKnowledgeIds.length > 0; if (ragModeEnabled) { lockThreadRagMode(threadId); } diff --git a/frontend-web/src/core/threads/types.ts b/frontend-web/src/core/threads/types.ts index 9639a63e..c1b606b7 100644 --- a/frontend-web/src/core/threads/types.ts +++ b/frontend-web/src/core/threads/types.ts @@ -27,6 +27,8 @@ export interface AgentThreadContext extends Record { knowledge_rag_enabled?: boolean; /** DeerFlow LLMWiki mapping ids selected for this conversation. */ llmwiki_knowledge_base_ids?: string[]; + /** Assistant knowledge-base ids selected for this conversation. */ + assistant_knowledge_base_ids?: string[]; /** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */ execution_profile?: "single_round_rag" | "multi_round_rag"; agent_id?: string; diff --git a/frontend-web/src/pages/AdminSkillsPage.tsx b/frontend-web/src/pages/AdminSkillsPage.tsx index 4d3e0407..6ac3ffc4 100644 --- a/frontend-web/src/pages/AdminSkillsPage.tsx +++ b/frontend-web/src/pages/AdminSkillsPage.tsx @@ -9,6 +9,7 @@ import { SparklesIcon, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -29,10 +30,22 @@ import { SkillAddressesPanel } from "./SkillAddressesPage"; const PAGE_SIZE = 10; export default function AdminSkillsPage() { + const [searchParams, setSearchParams] = useSearchParams(); const [search, setSearch] = useState(""); const [page, setPage] = useState(1); const { skills, isLoading, error } = useAllSkillsAdmin(true); const knowledgeBindings = useSkillKnowledgeBindings(); + const activeTab = useMemo(() => { + const tab = searchParams.get("tab"); + return tab === "addresses" || tab === "knowledge" ? tab : "skills"; + }, [searchParams]); + + const changeTab = (value: string) => { + const next = new URLSearchParams(searchParams); + if (value === "skills") next.delete("tab"); + else next.set("tab", value); + setSearchParams(next, { replace: true }); + }; const storedAuth = useMemo(() => getStoredAuth(), []); const currentUserId = storedAuth?.user_id ?? null; @@ -60,7 +73,8 @@ export default function AdminSkillsPage() {
@@ -77,7 +91,7 @@ export default function AdminSkillsPage() { - 技能知识归纳 + 技能到知识库管理
diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index f57b6b52..30326e06 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -92,6 +92,26 @@ function readPendingKnowledgeSelection(agentId: string | undefined): string[] | } } +function readPendingAssistantKnowledgeSelection(agentId: string | undefined): string[] | null { + if (typeof window === "undefined" || !agentId) return null; + try { + const raw = sessionStorage.getItem("pendingAgentMessage"); + if (!raw) return null; + const parsed = JSON.parse(raw) as { + agentId?: string; + assistantKnowledgeBaseIds?: unknown; + }; + if (parsed.agentId !== agentId || !Array.isArray(parsed.assistantKnowledgeBaseIds)) { + return null; + } + return parsed.assistantKnowledgeBaseIds + .map((item) => String(item).trim()) + .filter(Boolean); + } catch { + return null; + } +} + function stringifyForHiddenContext(value: unknown): string { if (typeof value === "string") return value; try { @@ -270,11 +290,17 @@ export default function AgentChatPage({ const pendingKnowledgeSelection = useRef( readPendingKnowledgeSelection(agent_id), ); + const pendingAssistantKnowledgeSelection = useRef( + readPendingAssistantKnowledgeSelection(agent_id), + ); const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState( () => pendingKnowledgeSelection.current ?? [], ); + const [selectedAssistantKnowledgeBaseIds, setSelectedAssistantKnowledgeBaseIds] = useState( + () => pendingAssistantKnowledgeSelection.current ?? [], + ); const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState( - () => pendingKnowledgeSelection.current !== null, + () => pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null, ); const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false); const knowledgeSelectionThread = useRef(null); @@ -285,6 +311,10 @@ export default function AgentChatPage({ setSelectedKnowledgeBaseIds(ids); setKnowledgeSelectionReady(true); }, []); + const changeAssistantKnowledgeSelection = useCallback((ids: string[]) => { + setSelectedAssistantKnowledgeBaseIds(ids); + setKnowledgeSelectionReady(true); + }, []); useEffect(() => { activeThreadIdRef.current = threadId; @@ -294,17 +324,23 @@ export default function AgentChatPage({ if (knowledgeSelectionAgent.current === agent_id) return; knowledgeSelectionAgent.current = agent_id; const pending = readPendingKnowledgeSelection(agent_id); + const pendingAssistant = readPendingAssistantKnowledgeSelection(agent_id); pendingKnowledgeSelection.current = pending; + pendingAssistantKnowledgeSelection.current = pendingAssistant; knowledgeSelectionThread.current = null; setSelectedKnowledgeBaseIds(pending ?? []); - setKnowledgeSelectionReady(pending !== null); + setSelectedAssistantKnowledgeBaseIds(pendingAssistant ?? []); + setKnowledgeSelectionReady(pending !== null || pendingAssistant !== null); }, [agent_id]); useEffect(() => { if (isNewThread) { if (knowledgeSelectionThread.current !== "__new__") { setSelectedKnowledgeBaseIds(pendingKnowledgeSelection.current ?? []); - setKnowledgeSelectionReady(pendingKnowledgeSelection.current !== null); + setSelectedAssistantKnowledgeBaseIds(pendingAssistantKnowledgeSelection.current ?? []); + setKnowledgeSelectionReady( + pendingKnowledgeSelection.current !== null || pendingAssistantKnowledgeSelection.current !== null, + ); } knowledgeSelectionThread.current = "__new__"; setKnowledgeSelectionLoading(false); @@ -319,18 +355,29 @@ export default function AgentChatPage({ if (knowledgeSelectionThread.current !== threadId) return; const metadata = record.metadata as Record | undefined; const raw = metadata?.llmwiki_knowledge_base_ids; + const assistantRaw = metadata?.assistant_knowledge_base_ids; + const llmwikiIds = Array.isArray(raw) + ? raw.map((item) => String(item).trim()).filter(Boolean) + : []; + const assistantIds = Array.isArray(assistantRaw) + ? assistantRaw.map((item) => String(item).trim()).filter(Boolean) + : []; if (Array.isArray(raw)) { - setSelectedKnowledgeBaseIds( - raw.map((item) => String(item).trim()).filter(Boolean), - ); - setKnowledgeSelectionReady(true); + setSelectedKnowledgeBaseIds(llmwikiIds); } else { setSelectedKnowledgeBaseIds([]); - setKnowledgeSelectionReady(false); } + if (Array.isArray(assistantRaw)) { + setSelectedAssistantKnowledgeBaseIds(assistantIds); + } else { + setSelectedAssistantKnowledgeBaseIds([]); + } + setKnowledgeSelectionReady(Array.isArray(raw) || Array.isArray(assistantRaw)); }) .catch(() => { if (knowledgeSelectionThread.current === threadId) { + setSelectedKnowledgeBaseIds([]); + setSelectedAssistantKnowledgeBaseIds([]); setKnowledgeSelectionReady(false); } }) @@ -353,10 +400,13 @@ export default function AgentChatPage({ () => ({ ...(taskId ? taskThreadMetadata(taskContext) : {}), ...(knowledgeSelectionReady - ? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds } + ? { + llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds, + assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds, + } : {}), }), - [knowledgeSelectionReady, selectedKnowledgeBaseIds, taskContext, taskId], + [knowledgeSelectionReady, selectedAssistantKnowledgeBaseIds, selectedKnowledgeBaseIds, taskContext, taskId], ); const [thread, sendMessage] = useThreadStream({ threadId: isNewThread ? undefined : threadId, @@ -365,7 +415,10 @@ export default function AgentChatPage({ ...settings.context, agent_id, ...(knowledgeSelectionReady - ? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds } + ? { + llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds, + assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds, + } : {}), }, // 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤; @@ -713,9 +766,13 @@ export default function AgentChatPage({ source="agent" agentId={agent_id} value={selectedKnowledgeBaseIds} + assistantValue={selectedAssistantKnowledgeBaseIds} onChange={changeKnowledgeSelection} + onAssistantChange={changeAssistantKnowledgeSelection} autoSelectAll={ - isNewThread && pendingKnowledgeSelection.current === null + isNewThread && + pendingKnowledgeSelection.current === null && + pendingAssistantKnowledgeSelection.current === null } disabled={isDemoMode || knowledgeSelectionLoading} label="知识空间" @@ -781,7 +838,9 @@ export default function AgentChatPage({ source="agent" agentId={agent_id} value={selectedKnowledgeBaseIds} + assistantValue={selectedAssistantKnowledgeBaseIds} onChange={changeKnowledgeSelection} + onAssistantChange={changeAssistantKnowledgeSelection} disabled={isDemoMode || knowledgeSelectionLoading} readOnly label="知识空间" diff --git a/frontend-web/src/pages/ArtifactLibraryPage.tsx b/frontend-web/src/pages/ArtifactLibraryPage.tsx index 576003e6..2424fe5d 100644 --- a/frontend-web/src/pages/ArtifactLibraryPage.tsx +++ b/frontend-web/src/pages/ArtifactLibraryPage.tsx @@ -3,12 +3,15 @@ import { useQuery } from "@tanstack/react-query"; import { ArrowDownWideNarrow, + Bot, ChevronLeft, ChevronRight, Download, + FileText, FileX2, Library, Loader2, + MessagesSquare, MessageSquareShare, RefreshCw, Search, @@ -20,17 +23,28 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { WorkspaceBody, WorkspaceContainer, WorkspaceHeader, } from "@/components/workspace/workspace-container"; -import { downloadArtifactFile } from "@/core/artifacts/download"; +import { + downloadArtifactFile, + downloadArtifactMarkdownAsWord, +} from "@/core/artifacts/download"; import { fetchArtifactLibrary, type ArtifactLibraryItem, type ArtifactLibraryKind, + type ArtifactLibrarySource, } from "@/core/artifacts/library"; import { useI18n } from "@/core/i18n/hooks"; import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils"; @@ -53,6 +67,28 @@ const FILTERS: Array<{ value: ArtifactLibraryKind; label: string }> = [ { value: "other", label: "其他" }, ]; +const SOURCE_FILTERS: Array<{ + value: ArtifactLibrarySource; + label: string; +}> = [ + { value: "all", label: "全部来源" }, + { value: "general_qa", label: "通用问答" }, + { value: "agent", label: "智能体" }, +]; + +type DownloadFormat = "original" | "word"; + +function isMarkdownArtifact(name: string): boolean { + return /\.(md|markdown)$/i.test(name); +} + +function artifactDownloadKey( + item: ArtifactLibraryItem, + format: DownloadFormat, +): string { + return `${item.threadId}:${item.path}:${format}`; +} + function formatBytes(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; const units = ["B", "KB", "MB", "GB"]; @@ -83,12 +119,19 @@ function FileRow({ onDownload, }: { item: ArtifactLibraryItem; - downloading: boolean; - onDownload: (item: ArtifactLibraryItem) => void; + downloading: DownloadFormat | null; + onDownload: (item: ArtifactLibraryItem, format: DownloadFormat) => void; }) { const sourcePath = sourceConversationPath(item); const displayName = desensitize(item.name); const sourceTitle = desensitize(item.threadTitle || "新对话"); + const sourceLabel = item.sourceType === "agent" ? "智能体" : "通用问答"; + const sourceName = + item.sourceType === "agent" + ? desensitize(item.agentName || item.agentId || "未知智能体") + : sourceLabel; + const SourceIcon = item.sourceType === "agent" ? Bot : MessagesSquare; + const busy = downloading !== null; return ( @@ -106,7 +149,8 @@ function FileRow({ {displayName} - {getFileExtensionDisplayName(item.name)} · 来自「{sourceTitle}」 + {getFileExtensionDisplayName(item.name)} · {sourceName} · 来自「 + {sourceTitle}」 {getFileExtensionDisplayName(item.name)} 文件 @@ -115,13 +159,24 @@ function FileRow({ - - {sourceTitle} - +
+ + + + + + {sourceName} + + + {item.sourceType === "agent" ? "智能体 · " : ""} + {sourceTitle} + + +
{formatDateTime(item.modifiedAt)} @@ -136,17 +191,36 @@ function FileRow({ size="icon" variant="ghost" className="size-8" - disabled={downloading} - onClick={() => onDownload(item)} - title={`下载 ${displayName}`} - aria-label={`下载 ${displayName}`} + disabled={busy} + onClick={() => onDownload(item, "original")} + title={`下载原文件:${displayName}`} + aria-label={`下载原文件:${displayName}`} > - {downloading ? ( + {downloading === "original" ? ( ) : ( )} + {isMarkdownArtifact(item.name) && ( + + )} + ))} + +
@@ -337,11 +456,11 @@ export default function ArtifactLibraryPage() { - - - - - + + + + @@ -352,12 +471,19 @@ export default function ArtifactLibraryPage() { ) : ( items.map((item) => { const key = `${item.threadId}:${item.path}`; + const activeDownload = downloadingKey?.startsWith(`${key}:`) + ? downloadingKey.endsWith(":word") + ? "word" + : "original" + : null; return ( void handleDownload(target)} + downloading={activeDownload} + onDownload={(target, format) => + void handleDownload(target, format) + } /> ); }) @@ -388,7 +514,7 @@ export default function ArtifactLibraryPage() {

- {debouncedSearch || fileKind !== "all" + {debouncedSearch || fileKind !== "all" || sourceType !== "all" ? "没有找到匹配的文件" : "还没有生成文件"}

diff --git a/frontend-web/src/pages/AssistantKnowledgePage.tsx b/frontend-web/src/pages/AssistantKnowledgePage.tsx index a0009085..444eac15 100644 --- a/frontend-web/src/pages/AssistantKnowledgePage.tsx +++ b/frontend-web/src/pages/AssistantKnowledgePage.tsx @@ -1,74 +1,448 @@ -import { Database, FileUp, History, Loader2, RefreshCw, Search, ShieldCheck, Trash2 } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { BookOpen, Database, Download, Loader2, Plus, RefreshCw, Search, ShieldCheck, Trash2, Upload } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; +import { Textarea } from "@/components/ui/textarea"; import { KnowledgeProductNav } from "@/components/workspace/knowledge-product-nav"; +import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { getStoredAuth } from "@/core/auth"; import { - listAssistantWikiRevisions, + type AssistantKnowledgeBase, type AssistantWikiPage, - type AssistantWikiRevision, useAssistantImportJobs, useAssistantKnowledgeActions, - useAssistantKnowledgeBase, + useAssistantKnowledgeBases, useAssistantKnowledgeSources, useAssistantWikiPages, } from "@/strategy-components/api/assistant-knowledge"; +function baseSubtitle(base: AssistantKnowledgeBase) { + if (base.is_global) return "全库沉淀总库 · 所有 WeKnora 导入数据都会同步进入这里并做实体对齐"; + return base.description || "自建助手知识库 · 可上传普通知识库导出的 Wiki 向量包"; +} + +function EmptyInitializeCard({ + isAdmin, + onInitialize, + pending, +}: { + isAdmin: boolean; + onInitialize: () => void; + pending: boolean; +}) { + return ( +
+
+ +

初始化助手知识库

+

+ 未配置 WeKnora 时会默认展示助手知识库。首次使用需要创建“知识梳理总库”,后续从 WeKnora 导入的数据都会沉淀到这里。 +

+ {isAdmin ? ( + + ) : ( +

请联系管理员初始化。

+ )} +
+
+ ); +} + export default function AssistantKnowledgePage() { const isAdmin = getStoredAuth()?.system_role === "admin"; const [query, setQuery] = useState(""); + const [selectedBaseId, setSelectedBaseId] = useState(null); const [selected, setSelected] = useState(null); - const [revisions, setRevisions] = useState([]); - const fileRef = useRef(null); - const base = useAssistantKnowledgeBase(); - const pages = useAssistantWikiPages(query); - const sources = useAssistantKnowledgeSources(); - const jobs = useAssistantImportJobs(); + const [createOpen, setCreateOpen] = useState(false); + const [createDraft, setCreateDraft] = useState({ name: "", description: "" }); + const packageInputRef = useRef(null); + const bases = useAssistantKnowledgeBases(); const actions = useAssistantKnowledgeActions(); + + useEffect(() => { + const values = bases.data ?? []; + if (!values.length) { + setSelectedBaseId(null); + return; + } + if (selectedBaseId && values.some((base) => base.id === selectedBaseId)) return; + setSelectedBaseId((values.find((base) => base.is_global) ?? values[0]).id); + }, [bases.data, selectedBaseId]); + + useEffect(() => { + setSelected(null); + }, [selectedBaseId]); + + const selectedBase = useMemo( + () => (bases.data ?? []).find((base) => base.id === selectedBaseId) ?? null, + [bases.data, selectedBaseId], + ); + const pages = useAssistantWikiPages(query, selectedBaseId); + const sources = useAssistantKnowledgeSources(selectedBaseId); + const jobs = useAssistantImportJobs(selectedBaseId); const activePage = selected ?? pages.data?.[0] ?? null; + const counts = useMemo( + () => ({ + pages: pages.data?.length ?? selectedBase?.page_count ?? 0, + sources: sources.data?.length ?? selectedBase?.source_count ?? 0, + jobs: jobs.data?.length ?? 0, + }), + [jobs.data, pages.data, selectedBase?.page_count, selectedBase?.source_count, sources.data], + ); - const counts = useMemo(() => ({ pages: pages.data?.length ?? 0, sources: sources.data?.length ?? 0, jobs: jobs.data?.length ?? 0 }), [pages.data, sources.data, jobs.data]); + const initializeGlobal = () => { + actions.initialize.mutate("知识梳理总库", { + onSuccess: (base) => { + setSelectedBaseId(base.id); + toast.success("知识梳理总库已创建"); + }, + onError: (error) => toast.error(error.message), + }); + }; - if (base.isLoading) return
; - if (!base.data) { - return

创建“知识梳理”助手知识库

无需配置普通知识库服务。创建后所有用户可查看 Wiki 与搜索,只有管理员可导入和维护。

{isAdmin ? :

请联系管理员初始化。

}
; - } - - const uploadFiles = async (files: FileList | null) => { - if (!files?.length) return; + const createBase = async () => { + const name = createDraft.name.trim(); + if (!name) { + toast.error("请输入助手知识库名称"); + return; + } try { - await actions.upload.mutateAsync(Array.from(files)); - toast.success("文件已安全扫描并导入"); + const base = await actions.createBase.mutateAsync({ + name, + description: createDraft.description.trim(), + }); + setSelectedBaseId(base.id); + setCreateDraft({ name: "", description: "" }); + setCreateOpen(false); + toast.success("助手知识库已创建"); } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); - } finally { - if (fileRef.current) fileRef.current.value = ""; } }; - const openHistory = async () => { - if (!activePage) return; - setRevisions(await listAssistantWikiRevisions(activePage.slug)); + const uploadPackage = async (file: File | null | undefined) => { + if (!file) return; + try { + await actions.uploadWeKnoraPackage.mutateAsync({ + file, + assistantBaseId: selectedBaseId, + }); + toast.success("导出包已上传,后台正在异步导入"); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } finally { + if (packageInputRef.current) packageInputRef.current.value = ""; + } }; + if (bases.isLoading) { + return ( +
+ +
+ +
+
+ ); + } + + if (!bases.data?.length) { + return ( +
+ + +
+ ); + } + return (
-

{base.data.name}

全局助手知识库 · 普通用户只读

实体对齐与版本管理
{isAdmin && <> void uploadFiles(event.target.files)} />}
- -
Wiki({counts.pages})来源({counts.sources})导入记录({counts.jobs})版本({revisions.length})
setQuery(event.target.value)} placeholder="搜索标题、实体别名和正文" className="pl-8" />
-
{pages.data?.map((page) => )}
{activePage ? <>

{activePage.title}

{activePage.slug}

:

暂无 Wiki 页面。

}
-
{sources.data?.map((source) =>
{source.source_name}{source.source_type}

业务键:{source.source_key}

{isAdmin &&
}
)}
-
{jobs.data?.map((job) =>
{job.status}{job.trigger}{Object.entries(job.counts || {}).map(([key, value]) => `${key} ${value}`).join(" · ")}{new Date(job.created_at).toLocaleString()}
)}
-
{revisions.map((revision) =>
v{revision.revision_no}{revision.change_type}{revision.summary}{new Date(revision.created_at).toLocaleString()}{isAdmin && activePage && revision.id !== activePage.current_revision_id && }
)}{!revisions.length &&

请先在 Wiki 页选择页面并点击“页面版本”。

}
-
+
+ + +
+
+
+

{selectedBase?.name}

+

+ {selectedBase ? baseSubtitle(selectedBase) : "请选择助手知识库"} +

+
+ + + 实体对齐 + + {isAdmin && ( +
+ void uploadPackage(event.target.files?.[0])} + /> + +
+ )} +
+ + +
+ + Wiki({counts.pages}) + 来源({counts.sources}) + 导入记录({counts.jobs}) + +
+ + setQuery(event.target.value)} + placeholder="搜索标题、实体别名和正文" + className="pl-8" + /> +
+
+ + + +
+ {pages.isLoading && } + {pages.data?.map((page) => ( + + ))} + {!pages.isLoading && !pages.data?.length && ( +
+ 暂无 Wiki 页面。请先到普通知识库详情页下载向量化数据包,再在当前助手知识库上传。 +
+ )} +
+
+ +
+ {activePage ? ( + <> +

+ + {activePage.title} +

+

{activePage.slug}

+ + + ) : ( +

暂无 Wiki 页面。

+ )} +
+
+
+ + +
+ {sources.data?.map((source) => ( +
+
+ + {source.source_name} + {source.source_type} +
+

业务键:{source.source_key}

+ {isAdmin && ( +
+ + +
+ )} +
+ ))} + {!sources.data?.length &&

暂无来源。

} +
+
+ + +
+ {jobs.data?.map((job) => ( +
+
+ {job.status} + {job.trigger} + 阶段:{job.phase} + {new Date(job.created_at).toLocaleString()} + {isAdmin && job.package_download_url && ( + + )} +
+
+ {Object.entries(job.counts || {}).map(([key, value]) => `${key} ${value}`).join(" · ") || "暂无统计"} +
+ {job.last_error_message && ( +
+ {job.last_error_message} +
+ )} +
+ ))} + {!jobs.data?.length &&

暂无导入记录。

} +
+
+
+
+
+ + + + + 新建助手知识库 + + 自建助手知识库用于承接从 WeKnora 普通知识库下载的 Wiki 向量包;导入时同一份数据也会沉淀到“知识梳理总库”。 + + +
+
+ + setCreateDraft((prev) => ({ ...prev, name: event.target.value }))} + placeholder="例如:政策资料库" + /> +
+
+ +
文件名来源对话生成时间大小 + 文件名来源生成时间大小 操作