From d8cbe920b2eccbab98cdd9286938d54b2c02a03e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 05:27:56 +0000 Subject: [PATCH 1/3] Align ima knowledge mount with the official Agent Interface. Replace the URL-only cloud pointer with Client ID / API Key auth from ima.qq.com/agent-interface, then let users pick knowledge bases and documents. Persist the selection and use it in chat retrieval and distill. Co-authored-by: XYAI Labs --- .../src/api/modules/knowledgeBases.test.ts | 21 + dashboard/src/api/modules/knowledgeBases.ts | 162 ++++- dashboard/src/locales/en.json | 43 +- dashboard/src/locales/zh.json | 43 +- .../CloudMountPanel.module.less | 68 +++ .../KnowledgeBases/CloudMountPanel.test.tsx | 130 ++++ .../pages/KnowledgeBases/CloudMountPanel.tsx | 562 ++++++++++++++++-- src/octop/api/routers/knowledge_bases.py | 197 +++++- src/octop/i18n/en.json | 6 +- src/octop/i18n/zh.json | 6 +- .../gateway/adapters/tencent_ima.py | 40 +- src/octop/infra/errors.py | 4 + src/octop/infra/knowledge/ima.py | 400 +++++++++++++ src/octop/infra/knowledge/local_mount.py | 109 +++- src/octop/infra/knowledge/retrieve.py | 134 ++++- src/octop/modules/org_os/empower.py | 16 +- tests/unit/api/test_knowledge_bases.py | 99 +++ tests/unit/connectors/test_tencent_ima.py | 29 + tests/unit/knowledge/test_knowledge_ima.py | 157 +++++ tests/unit/knowledge/test_retrieve.py | 66 ++ 20 files changed, 2162 insertions(+), 130 deletions(-) create mode 100644 dashboard/src/pages/KnowledgeBases/CloudMountPanel.module.less create mode 100644 dashboard/src/pages/KnowledgeBases/CloudMountPanel.test.tsx create mode 100644 src/octop/infra/knowledge/ima.py create mode 100644 tests/unit/knowledge/test_knowledge_ima.py diff --git a/dashboard/src/api/modules/knowledgeBases.test.ts b/dashboard/src/api/modules/knowledgeBases.test.ts index 3c9eff6a..ab394091 100644 --- a/dashboard/src/api/modules/knowledgeBases.test.ts +++ b/dashboard/src/api/modules/knowledgeBases.test.ts @@ -86,6 +86,27 @@ describe("knowledgeBasesApi", () => { ); }); + it("uses official ima Agent Interface endpoints", () => { + knowledgeBasesApi.imaStatus(); + knowledgeBasesApi.imaConnect({ client_id: "c", api_key: "k" }); + knowledgeBasesApi.imaListBases({ query: "work" }); + knowledgeBasesApi.imaListDocuments("kb-ima", { folder_id: "fd1" }); + + expect(request).toHaveBeenNthCalledWith(1, "/knowledge-bases/ima/status"); + expect(request).toHaveBeenNthCalledWith(2, "/knowledge-bases/ima/connect", { + method: "POST", + body: JSON.stringify({ client_id: "c", api_key: "k" }), + }); + expect(request).toHaveBeenNthCalledWith( + 3, + "/knowledge-bases/ima/bases?query=work", + ); + expect(request).toHaveBeenNthCalledWith( + 4, + "/knowledge-bases/ima/bases/kb-ima/documents?folder_id=fd1", + ); + }); + it("forwards the upload progress handler", () => { const file = new File(["document"], "notes.md", { type: "text/markdown" }); const onProgress = vi.fn(); diff --git a/dashboard/src/api/modules/knowledgeBases.ts b/dashboard/src/api/modules/knowledgeBases.ts index b9e690d1..9778969f 100644 --- a/dashboard/src/api/modules/knowledgeBases.ts +++ b/dashboard/src/api/modules/knowledgeBases.ts @@ -99,6 +99,73 @@ export interface KnowledgeOcrOptions { }[]; } +export interface ImaConnectorSummary { + instance_id: string; + display_name: string; + client_id_preview: string; +} + +export interface ImaStatus { + connected: boolean; + instance_id: string; + client_id_preview: string; + auth_url: string; + instances: ImaConnectorSummary[]; +} + +export interface ImaKnowledgeBase { + id: string; + name: string; + cover_url?: string; + description?: string; +} + +export interface ImaKnowledgeDoc { + media_id: string; + title: string; + parent_folder_id?: string; +} + +export interface ImaKnowledgeFolder { + folder_id: string; + name: string; + parent_folder_id?: string; + file_number?: number; + folder_number?: number; + is_folder: true; +} + +export interface ImaSelectedBase { + id: string; + name: string; +} + +export interface ImaSelectedDoc { + knowledge_base_id: string; + knowledge_base_name?: string; + media_id: string; + title: string; +} + +export interface KnowledgeMountInfo { + mounted: boolean; + readonly?: boolean; + kind?: "local" | "cloud"; + source_path?: string; + distill_path?: string; + cloud_url?: string; + cloud_provider?: string; + connector_instance_id?: string; + selected_bases?: ImaSelectedBase[]; + selected_docs?: ImaSelectedDoc[]; + entries: Array<{ + path: string; + name: string; + is_dir: boolean; + size: number; + }>; +} + export interface KnowledgeOnnxDownloadState { status: "idle" | "downloading" | "loading" | "done" | "failed"; progress: number; @@ -315,22 +382,69 @@ export const knowledgeBasesApi = { }, ), + imaStatus: () => request("/knowledge-bases/ima/status"), + + imaConnect: (body: { + client_id?: string; + api_key?: string; + instance_id?: string; + }) => + request("/knowledge-bases/ima/connect", { + method: "POST", + body: JSON.stringify(body), + }), + + imaListBases: (opts?: { + query?: string; + cursor?: string; + limit?: number; + instance_id?: string; + }) => { + const params = new URLSearchParams(); + if (opts?.query) params.set("query", opts.query); + if (opts?.cursor) params.set("cursor", opts.cursor); + if (opts?.limit) params.set("limit", String(opts.limit)); + if (opts?.instance_id) params.set("instance_id", opts.instance_id); + const qs = params.toString(); + return request<{ + items: ImaKnowledgeBase[]; + next_cursor: string; + is_end: boolean; + }>(qs ? `/knowledge-bases/ima/bases?${qs}` : "/knowledge-bases/ima/bases"); + }, + + imaListDocuments: ( + imaKbId: string, + opts?: { + folder_id?: string; + cursor?: string; + limit?: number; + instance_id?: string; + }, + ) => { + const params = new URLSearchParams(); + if (opts?.folder_id) params.set("folder_id", opts.folder_id); + if (opts?.cursor) params.set("cursor", opts.cursor); + if (opts?.limit) params.set("limit", String(opts.limit)); + if (opts?.instance_id) params.set("instance_id", opts.instance_id); + const qs = params.toString(); + return request<{ + items: ImaKnowledgeDoc[]; + folders: ImaKnowledgeFolder[]; + current_path: ImaKnowledgeFolder[]; + next_cursor: string; + is_end: boolean; + knowledge_base_id: string; + folder_id: string; + }>( + qs + ? `/knowledge-bases/ima/bases/${encodeURIComponent(imaKbId)}/documents?${qs}` + : `/knowledge-bases/ima/bases/${encodeURIComponent(imaKbId)}/documents`, + ); + }, + getMount: (id: string) => - request<{ - mounted: boolean; - readonly?: boolean; - kind?: "local" | "cloud"; - source_path?: string; - distill_path?: string; - cloud_url?: string; - cloud_provider?: string; - entries: Array<{ - path: string; - name: string; - is_dir: boolean; - size: number; - }>; - }>(`/knowledge-bases/${id}/mount`), + request(`/knowledge-bases/${id}/mount`), setMount: ( id: string, @@ -340,19 +454,12 @@ export const knowledgeBasesApi = { kind?: "local" | "cloud"; cloud_url?: string; cloud_provider?: string; + connector_instance_id?: string; + selected_bases?: ImaSelectedBase[]; + selected_docs?: ImaSelectedDoc[]; }, ) => - request<{ - mounted: boolean; - source_path: string; - distill_path: string; - entries: Array<{ - path: string; - name: string; - is_dir: boolean; - size: number; - }>; - }>(`/knowledge-bases/${id}/mount`, { + request(`/knowledge-bases/${id}/mount`, { method: "PUT", body: JSON.stringify({ source_path, @@ -360,6 +467,9 @@ export const knowledgeBasesApi = { kind: extra?.kind || "local", cloud_url: extra?.cloud_url || "", cloud_provider: extra?.cloud_provider || "", + connector_instance_id: extra?.connector_instance_id || "", + selected_bases: extra?.selected_bases || [], + selected_docs: extra?.selected_docs || [], }), }), diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 8108f7b4..b0dd66a5 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -309,6 +309,8 @@ "KNOWLEDGE_NAME_TAKEN": "You already have a knowledge base with this name.", "KNOWLEDGE_NAME_INVALID": "Invalid name.", "KNOWLEDGE_MOUNT_INVALID": "The knowledge-base folder path is invalid or the directory does not exist.", + "KNOWLEDGE_IMA_AUTH": "IMA Agent Interface authentication failed. Check the Client ID and API Key from https://ima.qq.com/agent-interface.", + "KNOWLEDGE_IMA_UNAVAILABLE": "IMA knowledge API request failed: {{reason}}", "AVATAR_INVALID": "Avatar must be a PNG, JPEG, WebP, or GIF image.", "AVATAR_TOO_LARGE": "Avatar exceeds the 5 MB size limit.", "INVITE_INVALID": "This invite code is invalid.", @@ -460,7 +462,7 @@ "settingsLead": "Advanced switch. It is on by default on the desktop. Turning it off hides knowledge bases from colleagues.", "settingsOpen": "Knowledge base feature", "homeTitle": "Mount your sources first", - "homeDesc": "Pick a folder on this computer, or attach a cloud knowledge base such as ima. The original folder stays read-only. Parse results go to a new folder you choose. Colleagues can look them up in chat.", + "homeDesc": "Pick a folder on this computer, or connect ima with the official Agent Interface and choose which knowledge bases and documents to attach. The original folder stays read-only. Parse results go to a new folder you choose. Colleagues can look them up in chat.", "homeAdvancedHint": "Vector, image, and scanned-document OCR settings are for later cleaning, labeling, and distillation.", "defaultLocalName": "Local files", "defaultCloudName": "Cloud knowledge", @@ -513,17 +515,46 @@ "distillDest": "Choose a new folder for parse results (not the mounted folder)", "distillDone": "Copied {{count}} files to {{path}}", "distillFailed": "Could not copy parse results", - "cloudMountTitle": "Cloud knowledge mount", - "cloudMountHint": "Attach a cloud knowledge base such as ima. Files are not parsed locally. Distillation writes a pointer into a new folder you choose, for later assistant strengthening.", + "cloudMountTitle": "ima knowledge mount", + "cloudMountHint": "Connect with the official IMA Agent Interface (Client ID + API Key), then choose which knowledge bases and documents to attach. Files stay on ima; distillation writes a pointer into a folder you choose.", "cloudUrl": "Cloud knowledge URL", "cloudUrlRequired": "Enter a cloud knowledge URL", "cloudProvider": "Provider (ima…)", - "cloudMountAction": "Mount cloud KB", - "cloudMountSaved": "Cloud knowledge attached (no local parse)", - "cloudMountFailed": "Could not attach the cloud knowledge base", + "cloudMountAction": "Save ima selection", + "cloudMountSaved": "ima knowledge bases and documents attached", + "cloudMountFailed": "Could not attach the ima knowledge base", "cloudAttachAction": "Attach for distillation", "cloudDistillDest": "Choose a new folder for the cloud pointer (not a local parse)", "cloudDistillDone": "Wrote a cloud pointer to {{path}}", + "imaAuthTitle": "Official IMA Agent Interface", + "imaAuthHint": "Open the IMA setup page, generate a Client ID and API Key (the key is shown once), then paste them here. FreeOS uses the official ima-openapi headers only.", + "imaAuthLink": "Open ima.qq.com/agent-interface", + "imaClientId": "Client ID", + "imaApiKey": "API Key", + "imaClientIdRequired": "Enter the IMA Client ID", + "imaApiKeyRequired": "Enter the IMA API Key", + "imaConnectAction": "Connect ima", + "imaReconnectAction": "Use this connection", + "imaConnected": "Connected to ima ({{preview}})", + "imaChangeCredentials": "Change credentials", + "imaExistingConnection": "Existing IMA connection", + "imaExistingPlaceholder": "Select a saved IMA connection", + "imaSearchBases": "Search ima knowledge bases", + "imaSelectBases": "Select knowledge bases", + "imaSelectDocs": "Select documents", + "imaBasesEmpty": "No ima knowledge bases yet. Create one in ima, then refresh.", + "imaDocsEmpty": "No documents in this folder.", + "imaLoadBasesFailed": "Could not load ima knowledge bases", + "imaLoadDocsFailed": "Could not load ima documents", + "imaSelectionRequired": "Select at least one knowledge base or document", + "imaSelectionSummary": "{{bases}} knowledge bases, {{docs}} documents selected", + "imaWholeBaseHint": "No documents selected in this knowledge base — the whole base will be used.", + "imaRefresh": "Refresh", + "imaLoadMore": "Load more", + "imaFolder": "Folder", + "imaRootFolder": "Root", + "imaSelectAllDocs": "Select all on this page", + "imaClearDocs": "Clear documents", "backToList": "Back to list", "createdBy": "Created by {{name}}", "baseId": "Knowledge base ID", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index f2cf451a..28018c1a 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -309,6 +309,8 @@ "KNOWLEDGE_NAME_TAKEN": "您已有同名知识库。", "KNOWLEDGE_NAME_INVALID": "名称无效。", "KNOWLEDGE_MOUNT_INVALID": "知识库文件夹路径无效,或目录不存在。", + "KNOWLEDGE_IMA_AUTH": "IMA Agent Interface 认证失败。请检查 https://ima.qq.com/agent-interface 上的 Client ID 与 API Key。", + "KNOWLEDGE_IMA_UNAVAILABLE": "IMA 知识库接口请求失败:{{reason}}", "AVATAR_INVALID": "头像须为 PNG、JPEG、WebP 或 GIF 图片。", "AVATAR_TOO_LARGE": "头像超过 5 MB 大小限制。", "INVITE_INVALID": "邀请码无效。", @@ -460,7 +462,7 @@ "settingsLead": "高级开关。桌面默认已打开。关掉后,同事都无法再使用知识库。", "settingsOpen": "知识库功能", "homeTitle": "先把资料挂进来", - "homeDesc": "选电脑上的文件夹,或挂接 ima 等云知识库。原来的文件夹只读,不会被改动;解析结果会存到你另选的新文件夹。同事可以在对话里查阅。", + "homeDesc": "选电脑上的文件夹,或通过官方 IMA Agent Interface 连接 ima,再自选要接入的知识库和文档。原来的文件夹只读,不会被改动;解析结果会存到你另选的新文件夹。同事可以在对话里查阅。", "homeAdvancedHint": "向量模型、图片和扫描件 OCR 留给后续清洗、标注、蒸馏。", "defaultLocalName": "本地资料", "defaultCloudName": "云知识库", @@ -513,17 +515,46 @@ "distillDest": "请选择保存解析结果的新文件夹(不能是已挂载的目录)", "distillDone": "已复制 {{count}} 个文件到 {{path}}", "distillFailed": "无法复制解析结果", - "cloudMountTitle": "云知识库挂接", - "cloudMountHint": "挂接 ima 等云知识库。不会在本地解析原文。蒸馏时只在你另选的新文件夹写入指针,供以后强化智能助手。", + "cloudMountTitle": "ima 知识库挂接", + "cloudMountHint": "通过官方 IMA Agent Interface(Client ID + API Key)连接,再自选要接入的知识库和文档。原文仍在 ima;蒸馏只在你另选的文件夹写入指针。", "cloudUrl": "云知识库地址", "cloudUrlRequired": "请输入云知识库地址", "cloudProvider": "来源(ima…)", - "cloudMountAction": "挂载云知识库", - "cloudMountSaved": "已挂载云知识库(不做本地解析)", - "cloudMountFailed": "无法挂载云知识库", + "cloudMountAction": "保存 ima 选择", + "cloudMountSaved": "已挂接所选 ima 知识库和文档", + "cloudMountFailed": "无法挂接 ima 知识库", "cloudAttachAction": "挂到蒸馏目录", "cloudDistillDest": "请选择保存云知识库指针的新文件夹(不会本地解析原文)", "cloudDistillDone": "已将云知识库指针写入 {{path}}", + "imaAuthTitle": "官方 IMA Agent Interface", + "imaAuthHint": "打开 IMA 配置页生成 Client ID 与 API Key(密钥只展示一次),粘贴到这里。FreeOS 只使用官方 ima-openapi 请求头。", + "imaAuthLink": "打开 ima.qq.com/agent-interface", + "imaClientId": "Client ID", + "imaApiKey": "API Key", + "imaClientIdRequired": "请输入 IMA Client ID", + "imaApiKeyRequired": "请输入 IMA API Key", + "imaConnectAction": "连接 ima", + "imaReconnectAction": "使用此连接", + "imaConnected": "已连接 ima({{preview}})", + "imaChangeCredentials": "更换凭证", + "imaExistingConnection": "已有 IMA 连接", + "imaExistingPlaceholder": "选择已保存的 IMA 连接", + "imaSearchBases": "搜索 ima 知识库", + "imaSelectBases": "选择知识库", + "imaSelectDocs": "选择文档", + "imaBasesEmpty": "还没有 ima 知识库。请先在 ima 中创建,然后刷新。", + "imaDocsEmpty": "此文件夹没有文档。", + "imaLoadBasesFailed": "无法加载 ima 知识库", + "imaLoadDocsFailed": "无法加载 ima 文档", + "imaSelectionRequired": "请至少选择一个知识库或文档", + "imaSelectionSummary": "已选 {{bases}} 个知识库、{{docs}} 个文档", + "imaWholeBaseHint": "该知识库未勾选文档,将接入整个知识库。", + "imaRefresh": "刷新", + "imaLoadMore": "加载更多", + "imaFolder": "文件夹", + "imaRootFolder": "根目录", + "imaSelectAllDocs": "全选本页文档", + "imaClearDocs": "清除文档选择", "backToList": "返回列表", "createdBy": "创建者:{{name}}", "baseId": "知识库 ID", diff --git a/dashboard/src/pages/KnowledgeBases/CloudMountPanel.module.less b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.module.less new file mode 100644 index 00000000..dcef39ec --- /dev/null +++ b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.module.less @@ -0,0 +1,68 @@ +.panel { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.authBox { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.baseList, +.docBox { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 220px; + overflow: auto; + padding: 8px; + border: 1px solid var(--fn-border-secondary); + border-radius: var(--fn-radius-md); + background: var(--fn-bg-primary); +} + +.baseRow, +.docRow, +.folderRow { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 4px 2px; + text-align: left; + background: none; + border: 0; + color: inherit; +} + +.baseRowActive { + background: color-mix(in srgb, var(--fn-color-brand) 8%, transparent); + border-radius: 6px; +} + +.crumbs { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 4px; + + button { + padding: 0; + border: 0; + background: none; + color: var(--fn-text-secondary); + cursor: pointer; + } +} diff --git a/dashboard/src/pages/KnowledgeBases/CloudMountPanel.test.tsx b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.test.tsx new file mode 100644 index 00000000..acb68ee6 --- /dev/null +++ b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.test.tsx @@ -0,0 +1,130 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const imaStatus = vi.fn(); +const imaConnect = vi.fn(); +const imaListBases = vi.fn(); +const imaListDocuments = vi.fn(); +const setMount = vi.fn(); +const getMount = vi.fn(); + +vi.mock("../../api/modules/knowledgeBases", () => ({ + knowledgeBasesApi: { + imaStatus: (...args: unknown[]) => imaStatus(...args), + imaConnect: (...args: unknown[]) => imaConnect(...args), + imaListBases: (...args: unknown[]) => imaListBases(...args), + imaListDocuments: (...args: unknown[]) => imaListDocuments(...args), + getMount: (...args: unknown[]) => getMount(...args), + setMount: (...args: unknown[]) => setMount(...args), + distillMount: vi.fn(), + }, +})); + +vi.mock("./pickKnowledgeFolder", () => ({ + pickKnowledgeFolder: vi.fn(), + canPickKnowledgeFolder: () => false, +})); + +import { CloudMountPanel } from "./CloudMountPanel"; + +describe("", () => { + beforeEach(() => { + imaStatus.mockReset(); + imaConnect.mockReset(); + imaListBases.mockReset(); + imaListDocuments.mockReset(); + setMount.mockReset(); + getMount.mockReset(); + imaStatus.mockResolvedValue({ + connected: false, + instance_id: "", + client_id_preview: "", + auth_url: "https://ima.qq.com/agent-interface", + instances: [], + }); + getMount.mockResolvedValue({ mounted: false, entries: [] }); + imaListBases.mockResolvedValue({ + items: [{ id: "kb-ima", name: "工作库" }], + next_cursor: "", + is_end: true, + }); + imaListDocuments.mockResolvedValue({ + items: [{ media_id: "doc-1", title: "周报" }], + folders: [], + current_path: [], + next_cursor: "", + is_end: true, + knowledge_base_id: "kb-ima", + folder_id: "", + }); + imaConnect.mockResolvedValue({ + connected: true, + instance_id: "inst-1", + client_id_preview: "clid…1234", + auth_url: "https://ima.qq.com/agent-interface", + instances: [ + { + instance_id: "inst-1", + display_name: "腾讯 IMA", + client_id_preview: "clid…1234", + }, + ], + }); + setMount.mockResolvedValue({ mounted: true, entries: [] }); + }); + + it("connects through the official Agent Interface then saves selected KB and doc", async () => { + const user = userEvent.setup(); + const ensureKb = vi.fn().mockResolvedValue("kb-local"); + + render(); + + expect( + screen.getByRole("link", { name: /knowledgeBases.imaAuthLink/ }), + ).toHaveAttribute("href", "https://ima.qq.com/agent-interface"); + + await user.type( + screen.getByPlaceholderText("knowledgeBases.imaClientId"), + "client-id", + ); + await user.type( + screen.getByPlaceholderText("knowledgeBases.imaApiKey"), + "api-key", + ); + await user.click( + screen.getByRole("button", { name: "knowledgeBases.imaConnectAction" }), + ); + + expect(imaConnect).toHaveBeenCalledWith({ + client_id: "client-id", + api_key: "api-key", + instance_id: undefined, + }); + + const kbBox = await screen.findByRole("checkbox", { name: "工作库" }); + await user.click(kbBox); + const docBox = await screen.findByRole("checkbox", { name: "周报" }); + await user.click(docBox); + await user.click( + screen.getByRole("button", { name: "knowledgeBases.cloudMountAction" }), + ); + + expect(ensureKb).toHaveBeenCalled(); + expect(setMount).toHaveBeenCalledWith("kb-local", "", "", { + kind: "cloud", + cloud_provider: "ima", + cloud_url: "https://ima.qq.com/agent-interface", + connector_instance_id: "inst-1", + selected_bases: [{ id: "kb-ima", name: "工作库" }], + selected_docs: [ + { + knowledge_base_id: "kb-ima", + knowledge_base_name: "工作库", + media_id: "doc-1", + title: "周报", + }, + ], + }); + }); +}); diff --git a/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx index 75b89513..2f37e0b1 100644 --- a/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx +++ b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx @@ -1,12 +1,33 @@ -import { useEffect, useState } from "react"; -import { Button, Input, Space, Typography } from "antd"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Alert, + Button, + Checkbox, + Empty, + Input, + Select, + Space, + Typography, +} from "antd"; +import { ExternalLink, Folder, RefreshCw } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { knowledgeBasesApi } from "../../api/modules/knowledgeBases"; +import { + knowledgeBasesApi, + type ImaConnectorSummary, + type ImaKnowledgeBase, + type ImaKnowledgeDoc, + type ImaKnowledgeFolder, + type ImaSelectedBase, + type ImaSelectedDoc, +} from "../../api/modules/knowledgeBases"; import { message } from "../../utils/antdMessage"; import { canPickKnowledgeFolder, pickKnowledgeFolder, } from "./pickKnowledgeFolder"; +import styles from "./CloudMountPanel.module.less"; + +const IMA_AUTH_URL = "https://ima.qq.com/agent-interface"; interface CloudMountPanelProps { kbId?: string; @@ -22,25 +43,150 @@ export function CloudMountPanel({ prominent = false, }: CloudMountPanelProps) { const { t } = useTranslation(); - const [url, setUrl] = useState(""); - const [provider, setProvider] = useState("ima"); - const [mounted, setMounted] = useState(false); const [busy, setBusy] = useState(false); + const [mounted, setMounted] = useState(false); + const [showAuthForm, setShowAuthForm] = useState(true); + const [clientId, setClientId] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [instanceId, setInstanceId] = useState(""); + const [clientPreview, setClientPreview] = useState(""); + const [instances, setInstances] = useState([]); + const [bases, setBases] = useState([]); + const [basesEnd, setBasesEnd] = useState(true); + const [basesCursor, setBasesCursor] = useState(""); + const [baseQuery, setBaseQuery] = useState(""); + const [basesError, setBasesError] = useState(""); + const [activeKbId, setActiveKbId] = useState(""); + const [docs, setDocs] = useState([]); + const [folders, setFolders] = useState([]); + const [folderPath, setFolderPath] = useState([]); + const [docsError, setDocsError] = useState(""); + const [selectedBases, setSelectedBases] = useState([]); + const [selectedDocs, setSelectedDocs] = useState([]); const canPick = canPickKnowledgeFolder(); - const load = async (id: string) => { - const next = await knowledgeBasesApi.getMount(id); - if (next.kind === "cloud") { - setMounted(next.mounted); - setUrl(next.cloud_url || ""); - setProvider(next.cloud_provider || "ima"); - } + const selectedBaseIds = useMemo( + () => new Set(selectedBases.map((item) => item.id)), + [selectedBases], + ); + const selectedDocKeys = useMemo( + () => + new Set( + selectedDocs.map((item) => `${item.knowledge_base_id}:${item.media_id}`), + ), + [selectedDocs], + ); + + const applyStatus = (status: { + connected: boolean; + instance_id: string; + client_id_preview: string; + instances: ImaConnectorSummary[]; + }) => { + setInstances(status.instances || []); + setInstanceId(status.instance_id || ""); + setClientPreview(status.client_id_preview || ""); + setShowAuthForm(!status.connected); }; + const loadBases = useCallback( + async (id: string, query = "", cursor = "", append = false) => { + setBasesError(""); + const result = await knowledgeBasesApi.imaListBases({ + query, + cursor, + instance_id: id, + }); + setBases((prev) => (append ? [...prev, ...result.items] : result.items)); + setBasesCursor(result.next_cursor || ""); + setBasesEnd(result.is_end); + if (!append && result.items.length > 0) { + setActiveKbId((current) => current || result.items[0].id); + } + if (!append && result.items.length === 0) { + setActiveKbId(""); + setDocs([]); + setFolders([]); + } + }, + [], + ); + + const loadDocs = useCallback( + async (id: string, imaKbId: string, folderId = "") => { + setDocsError(""); + try { + const result = await knowledgeBasesApi.imaListDocuments(imaKbId, { + folder_id: folderId, + instance_id: id, + }); + setDocs(result.items || []); + setFolders(result.folders || []); + setFolderPath(result.current_path || []); + } catch (err) { + setDocs([]); + setFolders([]); + setDocsError( + err instanceof Error + ? err.message + : t("knowledgeBases.imaLoadDocsFailed"), + ); + } + }, + [t], + ); + useEffect(() => { - if (!kbId) return; - void load(kbId).catch(() => undefined); - }, [kbId]); + let cancelled = false; + void (async () => { + try { + const [status, mount] = await Promise.all([ + knowledgeBasesApi.imaStatus(), + kbId + ? knowledgeBasesApi.getMount(kbId).catch(() => null) + : Promise.resolve(null), + ]); + if (cancelled) return; + let nextInstance = status.instance_id; + applyStatus(status); + if (mount?.kind === "cloud") { + setMounted(mount.mounted); + if (mount.connector_instance_id) { + nextInstance = mount.connector_instance_id; + setInstanceId(mount.connector_instance_id); + } + setSelectedBases(mount.selected_bases || []); + setSelectedDocs(mount.selected_docs || []); + if (mount.selected_bases?.[0]?.id) { + setActiveKbId(mount.selected_bases[0].id); + } + } + if (nextInstance) { + setShowAuthForm(false); + try { + await loadBases(nextInstance); + } catch (err) { + if (cancelled) return; + setBasesError( + err instanceof Error + ? err.message + : t("knowledgeBases.imaLoadBasesFailed"), + ); + } + } + } catch { + return; + } + })(); + return () => { + cancelled = true; + }; + }, [kbId, loadBases]); + + useEffect(() => { + if (!instanceId || !activeKbId || showAuthForm) return; + void loadDocs(instanceId, activeKbId); + }, [instanceId, activeKbId, showAuthForm, loadDocs]); const resolveKb = async () => { if (kbId) return kbId; @@ -50,9 +196,104 @@ export function CloudMountPanel({ return ensureKb(); }; + const connect = async () => { + const freshId = clientId.trim(); + const freshKey = apiKey.trim(); + if (!instanceId && !freshId) { + message.error(t("knowledgeBases.imaClientIdRequired")); + return; + } + if (freshId && !freshKey) { + message.error(t("knowledgeBases.imaApiKeyRequired")); + return; + } + setBusy(true); + try { + const status = await knowledgeBasesApi.imaConnect({ + client_id: freshId || undefined, + api_key: freshKey || undefined, + instance_id: instanceId || undefined, + }); + applyStatus(status); + setApiKey(""); + await loadBases(status.instance_id, baseQuery); + message.success(t("knowledgeBases.imaConnected", { preview: status.client_id_preview })); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("knowledgeBases.cloudMountFailed"), + ); + } finally { + setBusy(false); + } + }; + + const toggleBase = (base: ImaKnowledgeBase, checked: boolean) => { + setSelectedBases((prev) => { + if (checked) { + if (prev.some((item) => item.id === base.id)) return prev; + return [...prev, { id: base.id, name: base.name }]; + } + return prev.filter((item) => item.id !== base.id); + }); + if (!checked) { + setSelectedDocs((prev) => + prev.filter((item) => item.knowledge_base_id !== base.id), + ); + } else { + setActiveKbId(base.id); + } + }; + + const toggleDoc = (doc: ImaKnowledgeDoc, checked: boolean) => { + if (!activeKbId) return; + const baseName = + selectedBases.find((item) => item.id === activeKbId)?.name || + bases.find((item) => item.id === activeKbId)?.name || + activeKbId; + if (checked && !selectedBaseIds.has(activeKbId)) { + setSelectedBases((prev) => [...prev, { id: activeKbId, name: baseName }]); + } + setSelectedDocs((prev) => { + const key = `${activeKbId}:${doc.media_id}`; + if (checked) { + if (prev.some((item) => `${item.knowledge_base_id}:${item.media_id}` === key)) { + return prev; + } + return [ + ...prev, + { + knowledge_base_id: activeKbId, + knowledge_base_name: baseName, + media_id: doc.media_id, + title: doc.title, + }, + ]; + } + return prev.filter( + (item) => `${item.knowledge_base_id}:${item.media_id}` !== key, + ); + }); + }; + + const selectAllDocs = () => { + for (const doc of docs) { + toggleDoc(doc, true); + } + }; + + const clearActiveDocs = () => { + setSelectedDocs((prev) => + prev.filter((item) => item.knowledge_base_id !== activeKbId), + ); + }; + const mount = async () => { - if (!url.trim()) { - message.error(t("knowledgeBases.cloudUrlRequired")); + if (selectedBases.length === 0 && selectedDocs.length === 0) { + message.error(t("knowledgeBases.imaSelectionRequired")); + return; + } + if (!instanceId) { + message.error(t("knowledgeBases.imaClientIdRequired")); return; } setBusy(true); @@ -60,11 +301,14 @@ export function CloudMountPanel({ const id = await resolveKb(); await knowledgeBasesApi.setMount(id, "", "", { kind: "cloud", - cloud_url: url.trim(), - cloud_provider: provider.trim() || "ima", + cloud_provider: "ima", + cloud_url: IMA_AUTH_URL, + connector_instance_id: instanceId, + selected_bases: selectedBases, + selected_docs: selectedDocs, }); message.success(t("knowledgeBases.cloudMountSaved")); - await load(id); + setMounted(true); onMounted?.(); } catch (err) { message.error( @@ -99,36 +343,268 @@ export function CloudMountPanel({ } }; + const activeBaseName = + bases.find((item) => item.id === activeKbId)?.name || + selectedBases.find((item) => item.id === activeKbId)?.name || + ""; + const activeHasDocs = selectedDocs.some( + (item) => item.knowledge_base_id === activeKbId, + ); + return ( -
+
{t("knowledgeBases.cloudMountTitle")} {t("knowledgeBases.cloudMountHint")} - - setProvider(event.target.value)} - placeholder={t("knowledgeBases.cloudProvider")} - style={{ width: 120 }} - /> - setUrl(event.target.value)} - placeholder={t("knowledgeBases.cloudUrl")} - style={{ minWidth: 240 }} - /> - - {mounted && ( - +
+ ) : ( + + + {t("knowledgeBases.imaConnected", { + preview: clientPreview || instanceId, + })} + + - )} - + + )} + + {!showAuthForm && instanceId ? ( + <> +
+ setBaseQuery(event.target.value)} + onSearch={(value) => { + void loadBases(instanceId, value).catch((err: unknown) => { + setBasesError( + err instanceof Error + ? err.message + : t("knowledgeBases.imaLoadBasesFailed"), + ); + }); + }} + /> + +
+ {basesError ? ( + + ) : null} + + {t("knowledgeBases.imaSelectBases")} + + {bases.length === 0 ? ( + + ) : ( +
+ {bases.map((base) => ( + + ))} + {!basesEnd ? ( + + ) : null} +
+ )} + + {activeKbId ? ( +
+
+ + {t("knowledgeBases.imaSelectDocs")} + {activeBaseName ? ` · ${activeBaseName}` : ""} + + + + + +
+
+ + {folderPath.map((folder) => ( + + ))} +
+ {docsError ? ( + + ) : null} + {folders.map((folder) => ( + + ))} + {docs.length === 0 && folders.length === 0 && !docsError ? ( + + ) : ( + docs.map((doc) => ( + + )) + )} + {selectedBaseIds.has(activeKbId) && !activeHasDocs ? ( + + {t("knowledgeBases.imaWholeBaseHint")} + + ) : null} +
+ ) : null} + + + {t("knowledgeBases.imaSelectionSummary", { + bases: selectedBases.length, + docs: selectedDocs.length, + })} + + + + {mounted ? ( + + ) : null} + + + ) : null}
); } diff --git a/src/octop/api/routers/knowledge_bases.py b/src/octop/api/routers/knowledge_bases.py index 3b6216b5..39275d72 100644 --- a/src/octop/api/routers/knowledge_bases.py +++ b/src/octop/api/routers/knowledge_bases.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from contextlib import suppress from dataclasses import asdict from typing import Any @@ -224,9 +225,17 @@ def _map_knowledge_error( "cloud_url", "outside the mounted", "not a directory", + "ima mount requires", ) ): return OctopError.localized(ErrorCode.KNOWLEDGE_MOUNT_INVALID, locale) + if isinstance(exc, ValueError) and text.startswith("ima credentials"): + return OctopError.localized(ErrorCode.KNOWLEDGE_IMA_AUTH, locale) + if isinstance(exc, ValueError) and text.startswith("ima api"): + reason = str(exc).split(":", 1)[-1].strip() or str(exc) + return OctopError.localized( + ErrorCode.KNOWLEDGE_IMA_UNAVAILABLE, locale, details={"reason": reason}, reason=reason + ) if "at most 100" in text: return OctopError.localized(ErrorCode.KNOWLEDGE_DOC_LIMIT, locale) if "document size exceeds" in text: @@ -576,6 +585,165 @@ async def default_open_bases( } +class ImaConnectBody(BaseModel): + client_id: str = Field(default="", description="IMA OpenAPI Client ID") + api_key: str = Field(default="", description="IMA OpenAPI API Key (shown once on IMA)") + instance_id: str = Field(default="", description="Existing tencent-ima connector instance") + + +class ImaSelectedBase(BaseModel): + id: str = Field(min_length=1) + name: str = "" + + +class ImaSelectedDoc(BaseModel): + knowledge_base_id: str = Field(min_length=1) + knowledge_base_name: str = "" + media_id: str = Field(min_length=1) + title: str = "" + + +def _ima_status_payload(server: OctopServer, user_id: int) -> dict[str, Any]: + from octop.infra.knowledge.ima import list_ima_connector_summaries + + assert server.services is not None + instances = list_ima_connector_summaries(server.services, user_id) + first = instances[0] if instances else None + return { + "connected": bool(first), + "instance_id": first["instance_id"] if first else "", + "client_id_preview": first["client_id_preview"] if first else "", + "auth_url": "https://ima.qq.com/agent-interface", + "instances": instances, + } + + +@router.get( + "/ima/status", + summary="IMA Agent Interface connection status for knowledge mount", +) +async def ima_status( + request: Request, + server: OctopServer = Depends(get_server), + user: User = Depends(current_user), +) -> dict[str, Any]: + try: + _require_enabled(server, request) + return _ima_status_payload(server, user.id) + except Exception as exc: + raise _map_knowledge_error( + exc, locale=resolve_request_locale(request), server=server + ) from exc + + +@router.post( + "/ima/connect", + summary="Connect ima with official Agent Interface Client ID and API Key", +) +async def ima_connect( + body: ImaConnectBody, + request: Request, + server: OctopServer = Depends(get_server), + user: User = Depends(require_permission("knowledge_bases")), +) -> dict[str, Any]: + from octop.infra.knowledge.ima import preview_client_id, resolve_ima_credentials + + try: + _require_enabled(server, request) + assert server.services is not None + instance_id, creds = await asyncio.to_thread( + resolve_ima_credentials, + server.services, + user.id, + instance_id=body.instance_id, + client_id=body.client_id, + api_key=body.api_key, + persist=True, + ) + status_payload = _ima_status_payload(server, user.id) + return { + **status_payload, + "connected": True, + "instance_id": instance_id, + "client_id_preview": preview_client_id(creds["client_id"]), + } + except Exception as exc: + raise _map_knowledge_error( + exc, locale=resolve_request_locale(request), server=server + ) from exc + + +@router.get( + "/ima/bases", + summary="List ima knowledge bases via official search_knowledge_base", +) +async def ima_list_bases( + request: Request, + query: str = Query(default=""), + cursor: str = Query(default=""), + limit: int = Query(default=20, ge=1, le=50), + instance_id: str = Query(default=""), + server: OctopServer = Depends(get_server), + user: User = Depends(require_permission("knowledge_bases")), +) -> dict[str, Any]: + from octop.infra.knowledge.ima import list_knowledge_bases, resolve_ima_credentials + + try: + _require_enabled(server, request) + assert server.services is not None + + def _load() -> dict[str, Any]: + _instance_id, creds = resolve_ima_credentials( + server.services, user.id, instance_id=instance_id + ) + return list_knowledge_bases(creds, query=query, cursor=cursor, limit=limit) + + return await asyncio.to_thread(_load) + except Exception as exc: + raise _map_knowledge_error( + exc, locale=resolve_request_locale(request), server=server + ) from exc + + +@router.get( + "/ima/bases/{ima_kb_id}/documents", + summary="List ima documents via official get_knowledge_list", +) +async def ima_list_documents( + ima_kb_id: str, + request: Request, + folder_id: str = Query(default=""), + cursor: str = Query(default=""), + limit: int = Query(default=20, ge=1, le=50), + instance_id: str = Query(default=""), + server: OctopServer = Depends(get_server), + user: User = Depends(require_permission("knowledge_bases")), +) -> dict[str, Any]: + from octop.infra.knowledge.ima import list_knowledge_documents, resolve_ima_credentials + + try: + _require_enabled(server, request) + assert server.services is not None + + def _load() -> dict[str, Any]: + _instance_id, creds = resolve_ima_credentials( + server.services, user.id, instance_id=instance_id + ) + return list_knowledge_documents( + creds, + ima_kb_id, + folder_id=folder_id, + cursor=cursor, + limit=limit, + ) + + return await asyncio.to_thread(_load) + except Exception as exc: + raise _map_knowledge_error( + exc, locale=resolve_request_locale(request), server=server + ) from exc + + @router.get("/{kb_id}", summary="Get a visible knowledge base") async def get_base( kb_id: str, @@ -980,9 +1148,18 @@ class MountBody(BaseModel): ) kind: str = Field(default="local", pattern="^(local|cloud)$") cloud_url: str = Field( - default="", description="Cloud knowledge URL (e.g. ima). No local parse." + default="", description="Optional cloud pointer URL. ima mounts use Agent Interface IDs." ) cloud_provider: str = Field(default="", description="Cloud corpus provider id, e.g. ima") + connector_instance_id: str = Field( + default="", description="tencent-ima connector that holds official Client ID / API Key" + ) + selected_bases: list[ImaSelectedBase] = Field( + default_factory=list, description="IMA knowledge bases chosen after connect" + ) + selected_docs: list[ImaSelectedDoc] = Field( + default_factory=list, description="IMA documents chosen after connect" + ) class DistillBody(BaseModel): @@ -1050,6 +1227,19 @@ async def put_kb_mount( kind=body.kind, cloud_url=body.cloud_url, cloud_provider=body.cloud_provider, + connector_instance_id=body.connector_instance_id, + selected_bases=tuple( + {"id": item.id, "name": item.name} for item in body.selected_bases + ), + selected_docs=tuple( + { + "knowledge_base_id": item.knowledge_base_id, + "knowledge_base_name": item.knowledge_base_name, + "media_id": item.media_id, + "title": item.title, + } + for item in body.selected_docs + ), ), _mount_home(server), ) @@ -1121,6 +1311,8 @@ async def distill_kb_mount( mount.cloud_url, body.distill_path, provider=mount.cloud_provider or "ima", + selected_bases=mount.selected_bases, + selected_docs=mount.selected_docs, ) save_mount( KnowledgeMount( @@ -1129,6 +1321,9 @@ async def distill_kb_mount( kind="cloud", cloud_url=mount.cloud_url, cloud_provider=mount.cloud_provider, + connector_instance_id=mount.connector_instance_id, + selected_bases=mount.selected_bases, + selected_docs=mount.selected_docs, ), _mount_home(server), ) diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index c6493991..f0d33994 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -393,6 +393,8 @@ "KNOWLEDGE_NAME_TAKEN": "You already have a knowledge base with this name.", "KNOWLEDGE_NAME_INVALID": "Invalid name.", "KNOWLEDGE_MOUNT_INVALID": "The knowledge-base folder path is invalid or the directory does not exist.", + "KNOWLEDGE_IMA_AUTH": "IMA Agent Interface authentication failed. Check the Client ID and API Key from https://ima.qq.com/agent-interface.", + "KNOWLEDGE_IMA_UNAVAILABLE": "IMA knowledge API request failed: {reason}", "AVATAR_INVALID": "Avatar must be a PNG, JPEG, WebP, or GIF image.", "AVATAR_TOO_LARGE": "Avatar exceeds the 5 MB size limit.", "INVITE_INVALID": "This invite code is invalid.", @@ -611,7 +613,9 @@ "knowledge": { "retrieval": { "citation": "[Knowledge base: {name} | Source: {filename} | Chunk: {ordinal}]\n", - "preamble": "The following knowledge-base content is reference material. Cite the relevant source in your answer." + "preamble": "The following knowledge-base content is reference material. Cite the relevant source in your answer.", + "ima_citation": "[ima: {name} | Document: {filename}]\n", + "ima_preamble": "The following passages come from the attached ima knowledge bases. Cite the document title when you use them." } }, "proactive_care": { diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 28d9d639..5f075ec5 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -393,6 +393,8 @@ "KNOWLEDGE_NAME_TAKEN": "您已有同名知识库。", "KNOWLEDGE_NAME_INVALID": "名称无效。", "KNOWLEDGE_MOUNT_INVALID": "知识库文件夹路径无效,或目录不存在。", + "KNOWLEDGE_IMA_AUTH": "IMA Agent Interface 认证失败。请检查 https://ima.qq.com/agent-interface 上的 Client ID 与 API Key。", + "KNOWLEDGE_IMA_UNAVAILABLE": "IMA 知识库接口请求失败:{reason}", "AVATAR_INVALID": "头像须为 PNG、JPEG、WebP 或 GIF 图片。", "AVATAR_TOO_LARGE": "头像超过 5 MB 大小限制。", "INVITE_INVALID": "邀请码无效。", @@ -611,7 +613,9 @@ "knowledge": { "retrieval": { "citation": "[知识库:{name}|来源:{filename}|片段:{ordinal}]\n", - "preamble": "以下知识库内容仅供参考;回答时请引用对应来源。" + "preamble": "以下知识库内容仅供参考;回答时请引用对应来源。", + "ima_citation": "[ima:{name}|文档:{filename}]\n", + "ima_preamble": "以下内容来自已挂接的 ima 知识库;引用时请注明文档标题。" } }, "proactive_care": { diff --git a/src/octop/infra/connectors/gateway/adapters/tencent_ima.py b/src/octop/infra/connectors/gateway/adapters/tencent_ima.py index b69b4044..e7cb536f 100644 --- a/src/octop/infra/connectors/gateway/adapters/tencent_ima.py +++ b/src/octop/infra/connectors/gateway/adapters/tencent_ima.py @@ -469,27 +469,49 @@ def _headers(creds: dict[str, Any]) -> dict[str, str]: } -def _openapi(creds: dict[str, Any], path: str, body: dict[str, Any]) -> str: +def unwrap_openapi_data(raw: object) -> dict[str, Any]: + """Normalize official ``{retcode, errmsg, data}`` and legacy ``{code, msg}`` payloads.""" + if not isinstance(raw, dict): + raise ValueError("IMA API returned a non-object payload") + code = raw.get("retcode", raw.get("code")) + if code not in (0, None, "0"): + msg = str(raw.get("errmsg") or raw.get("msg") or raw.get("message") or "IMA API error") + raise ValueError(f"[{code}] {msg}") + data = raw.get("data") + if isinstance(data, dict): + return data + return { + key: value + for key, value in raw.items() + if key not in {"retcode", "code", "errmsg", "msg", "message"} + } + + +def _request_openapi(creds: dict[str, Any], path: str, body: dict[str, Any]) -> dict[str, Any]: url = f"https://ima.qq.com/{path.lstrip('/')}" with httpx.Client(timeout=60.0) as client: r = client.post(url, headers=_headers(creds), json=body) if r.status_code >= 400: raise ValueError(_http_error_message(r)) data = r.json() - if isinstance(data, dict): - code = data.get("code") - if code not in (0, None, "0"): - msg = str(data.get("msg") or data.get("message") or "IMA API error") - raise ValueError(f"[{code}] {msg}") - return json.dumps(data, ensure_ascii=False, indent=2) - return str(data) + unwrap_openapi_data(data) + return data if isinstance(data, dict) else {"value": data} + + +def openapi_data(creds: dict[str, Any], path: str, body: dict[str, Any]) -> dict[str, Any]: + """POST an official IMA OpenAPI path and return the unwrapped ``data`` object.""" + return unwrap_openapi_data(_request_openapi(creds, path, body)) + + +def _openapi(creds: dict[str, Any], path: str, body: dict[str, Any]) -> str: + return json.dumps(_request_openapi(creds, path, body), ensure_ascii=False, indent=2) def _http_error_message(response: httpx.Response) -> str: try: body = response.json() if isinstance(body, dict): - msg = body.get("msg") or body.get("message") + msg = body.get("errmsg") or body.get("msg") or body.get("message") if msg: return str(msg).strip() except Exception: diff --git a/src/octop/infra/errors.py b/src/octop/infra/errors.py index 5563ee83..f11ffcd0 100644 --- a/src/octop/infra/errors.py +++ b/src/octop/infra/errors.py @@ -97,6 +97,8 @@ class ErrorCode(StrEnum): KNOWLEDGE_NAME_TAKEN = "KNOWLEDGE_NAME_TAKEN" KNOWLEDGE_NAME_INVALID = "KNOWLEDGE_NAME_INVALID" KNOWLEDGE_MOUNT_INVALID = "KNOWLEDGE_MOUNT_INVALID" + KNOWLEDGE_IMA_AUTH = "KNOWLEDGE_IMA_AUTH" + KNOWLEDGE_IMA_UNAVAILABLE = "KNOWLEDGE_IMA_UNAVAILABLE" AVATAR_INVALID = "AVATAR_INVALID" AVATAR_TOO_LARGE = "AVATAR_TOO_LARGE" INVITE_INVALID = "INVITE_INVALID" @@ -195,6 +197,8 @@ class ErrorCode(StrEnum): ErrorCode.KNOWLEDGE_NAME_TAKEN: 409, ErrorCode.KNOWLEDGE_NAME_INVALID: 400, ErrorCode.KNOWLEDGE_MOUNT_INVALID: 400, + ErrorCode.KNOWLEDGE_IMA_AUTH: 401, + ErrorCode.KNOWLEDGE_IMA_UNAVAILABLE: 502, ErrorCode.AVATAR_INVALID: 400, ErrorCode.AVATAR_TOO_LARGE: 413, ErrorCode.INVITE_INVALID: 400, diff --git a/src/octop/infra/knowledge/ima.py b/src/octop/infra/knowledge/ima.py new file mode 100644 index 00000000..c11533ef --- /dev/null +++ b/src/octop/infra/knowledge/ima.py @@ -0,0 +1,400 @@ +"""Official IMA Agent Interface helpers for knowledge-base mount. + +Auth and endpoints follow https://ima.qq.com/agent-interface: + +- Headers ``ima-openapi-clientid`` / ``ima-openapi-apikey`` +- POST JSON to ``https://ima.qq.com/openapi/wiki/v1/*`` +""" + +from __future__ import annotations + +from typing import Any + +from octop.infra.connectors.builder import mcp_server_name, validate_create_credentials +from octop.infra.connectors.crypto import decrypt_credentials, encrypt_credentials +from octop.infra.connectors.default_open import build_instance_config_json +from octop.infra.connectors.gateway.adapters.tencent_ima import ( + openapi_data, + probe_credentials, +) +from octop.infra.utils.ulid import new_ulid + +IMA_KIND = "tencent-ima" +IMA_AGENT_INTERFACE_URL = "https://ima.qq.com/agent-interface" +IMA_DISPLAY_NAME = "腾讯 IMA" + + +def preview_client_id(client_id: str) -> str: + text = client_id.strip() + if len(text) <= 8: + return text + return f"{text[:4]}…{text[-4:]}" + + +def list_ima_connector_summaries(services: Any, user_id: int) -> list[dict[str, str]]: + repo = getattr(services, "connector_repo", None) + if repo is None: + return [] + out: list[dict[str, str]] = [] + for row in repo.list_visible(user_id): + if row.kind != IMA_KIND or not row.has_credentials: + continue + if int(row.user_id) != int(user_id) and not row.shared: + continue + out.append( + { + "instance_id": row.instance_id, + "display_name": row.display_name, + "client_id_preview": _preview_from_row(services, row), + } + ) + return out + + +def resolve_ima_credentials( + services: Any, + user_id: int, + *, + instance_id: str = "", + client_id: str = "", + api_key: str = "", + persist: bool = False, +) -> tuple[str, dict[str, str]]: + """Return ``(instance_id, creds)`` using official Client ID + API Key. + + New credentials are probed against ``search_knowledge_base`` before persist. + """ + client_id = client_id.strip() + api_key = api_key.strip() + instance_id = instance_id.strip() + if client_id and api_key: + creds = {"client_id": client_id, "api_key": api_key} + try: + probe_credentials(creds) + except ValueError as exc: + raise ValueError(f"IMA credentials: {exc}") from exc + stored_id = ( + upsert_ima_connector(services, user_id, creds, instance_id=instance_id) + if persist + else instance_id + ) + return stored_id, creds + creds = load_ima_connector_credentials(services, user_id, instance_id=instance_id) + if persist: + try: + probe_credentials(creds) + except ValueError as exc: + raise ValueError(f"IMA credentials: {exc}") from exc + return instance_id or _first_ima_instance_id(services, user_id), creds + + +def load_ima_connector_credentials( + services: Any, + user_id: int, + *, + instance_id: str = "", +) -> dict[str, str]: + repo = getattr(services, "connector_repo", None) + secret = getattr(services, "secret_repo", None) + if repo is None or secret is None: + raise ValueError("IMA credentials: connector store is unavailable") + row_id = instance_id.strip() or _first_ima_instance_id(services, user_id) + if not row_id: + raise ValueError("IMA credentials: Client ID and API Key are required") + row = repo.get(row_id) + if row is None or row.kind != IMA_KIND or not row.credential_blob: + raise ValueError("IMA credentials: connector not found") + if int(row.user_id) != int(user_id) and not row.shared: + raise PermissionError("not your IMA connector") + raw = decrypt_credentials(secret, row.credential_blob) + client_id = str(raw.get("client_id") or "").strip() + api_key = str(raw.get("api_key") or "").strip() + if not client_id or not api_key: + raise ValueError("IMA credentials: Client ID and API Key are required") + return {"client_id": client_id, "api_key": api_key} + + +def upsert_ima_connector( + services: Any, + user_id: int, + creds: dict[str, str], + *, + instance_id: str = "", +) -> str: + repo = services.connector_repo + secret = services.secret_repo + payload = validate_create_credentials(IMA_KIND, dict(creds)) + stored_id = instance_id.strip() + if stored_id: + row = repo.get(stored_id) + if row is None or row.kind != IMA_KIND: + raise ValueError("IMA credentials: connector not found") + if int(row.user_id) != int(user_id): + raise PermissionError("not your IMA connector") + else: + stored_id = _matching_ima_instance_id(services, user_id, str(creds.get("client_id") or "")) + if not stored_id: + stored_id = new_ulid() + repo.create( + instance_id=stored_id, + user_id=user_id, + kind=IMA_KIND, + display_name=_unique_display_name(repo, user_id), + mcp_server_name=mcp_server_name(IMA_KIND, stored_id), + config_json=build_instance_config_json( + kind=IMA_KIND, + description="IMA Agent Interface knowledge mount", + ), + ) + repo.upsert_credentials( + instance_id=stored_id, + blob=encrypt_credentials(secret, payload), + ) + return stored_id + + +def list_knowledge_bases( + creds: dict[str, Any], + *, + query: str = "", + cursor: str = "", + limit: int = 20, +) -> dict[str, Any]: + limit = max(1, min(int(limit or 20), 50)) + try: + data = openapi_data( + creds, + "openapi/wiki/v1/search_knowledge_base", + {"query": query, "cursor": cursor, "limit": limit}, + ) + except ValueError as exc: + raise ValueError(f"IMA API: {exc}") from exc + items, next_cursor, is_end = parse_knowledge_bases(data) + return {"items": items, "next_cursor": next_cursor, "is_end": is_end} + + +def list_knowledge_documents( + creds: dict[str, Any], + knowledge_base_id: str, + *, + folder_id: str = "", + cursor: str = "", + limit: int = 20, +) -> dict[str, Any]: + kb_id = knowledge_base_id.strip() + if not kb_id: + raise ValueError("IMA API: knowledge_base_id is required") + limit = max(1, min(int(limit or 20), 50)) + body: dict[str, Any] = { + "knowledge_base_id": kb_id, + "cursor": cursor, + "limit": limit, + } + folder = folder_id.strip() + if folder: + body["folder_id"] = folder + try: + data = openapi_data(creds, "openapi/wiki/v1/get_knowledge_list", body) + except ValueError as exc: + raise ValueError(f"IMA API: {exc}") from exc + items, folders, current_path, next_cursor, is_end = parse_knowledge_list(data) + return { + "items": items, + "folders": folders, + "current_path": current_path, + "next_cursor": next_cursor, + "is_end": is_end, + "knowledge_base_id": kb_id, + "folder_id": folder, + } + + +def search_selected_knowledge( + creds: dict[str, Any], + query: str, + selected_bases: list[dict[str, str]] | tuple[dict[str, str], ...], + selected_docs: list[dict[str, str]] | tuple[dict[str, str], ...], + *, + limit_per_base: int = 8, +) -> list[dict[str, str]]: + """Search official IMA knowledge for the user's persisted selection.""" + q = query.strip() + if not q: + return [] + names: dict[str, str] = {} + kb_ids: list[str] = [] + allow: dict[str, set[str]] = {} + for base in selected_bases: + kid = str(base.get("id") or "").strip() + if not kid: + continue + kb_ids.append(kid) + names[kid] = str(base.get("name") or kid).strip() or kid + for doc in selected_docs: + kid = str(doc.get("knowledge_base_id") or "").strip() + media_id = str(doc.get("media_id") or "").strip() + if not kid or not media_id: + continue + if kid not in names: + kb_ids.append(kid) + names[kid] = str(doc.get("knowledge_base_name") or kid).strip() or kid + allow.setdefault(kid, set()).add(media_id) + seen: set[str] = set() + passages: list[dict[str, str]] = [] + for kb_id in kb_ids: + if kb_id in seen: + continue + seen.add(kb_id) + try: + data = openapi_data( + creds, + "openapi/wiki/v1/search_knowledge", + {"query": q, "knowledge_base_id": kb_id, "cursor": ""}, + ) + except ValueError: + continue + allowed = allow.get(kb_id) + added = 0 + for item in _as_dicts(data.get("info_list") or data.get("knowledge_list")): + media_id = str(item.get("media_id") or "").strip() + if allowed is not None and media_id not in allowed: + continue + text = str(item.get("highlight_content") or item.get("title") or "").strip() + if not text: + continue + passages.append( + { + "knowledge_base_id": kb_id, + "knowledge_base_name": names.get(kb_id, kb_id), + "media_id": media_id, + "title": str(item.get("title") or media_id).strip() or media_id, + "text": text, + } + ) + added += 1 + if added >= limit_per_base: + break + return passages + + +def parse_knowledge_bases(data: dict[str, Any]) -> tuple[list[dict[str, str]], str, bool]: + raw = ( + data.get("info_list") + or data.get("knowledge_base_list") + or data.get("addable_knowledge_base_list") + or [] + ) + items: list[dict[str, str]] = [] + for item in _as_dicts(raw): + kid = str(item.get("id") or item.get("knowledge_base_id") or "").strip() + if not kid: + continue + items.append( + { + "id": kid, + "name": str(item.get("name") or kid).strip() or kid, + "cover_url": str(item.get("cover_url") or ""), + "description": str(item.get("description") or ""), + } + ) + return items, _next_cursor(data), _is_end(data) + + +def parse_knowledge_list( + data: dict[str, Any], +) -> tuple[list[dict[str, str]], list[dict[str, Any]], list[dict[str, Any]], str, bool]: + docs: list[dict[str, str]] = [] + folders: list[dict[str, Any]] = [] + for item in _as_dicts(data.get("knowledge_list") or data.get("info_list")): + folder_id = str(item.get("folder_id") or "").strip() + media_id = str(item.get("media_id") or "").strip() + if folder_id and not media_id: + folders.append(_folder_item(item, folder_id)) + continue + if media_id: + docs.append( + { + "media_id": media_id, + "title": str(item.get("title") or item.get("name") or media_id).strip() + or media_id, + "parent_folder_id": str(item.get("parent_folder_id") or ""), + } + ) + for item in _as_dicts(data.get("folder_list") or data.get("folders")): + folder_id = str(item.get("folder_id") or item.get("id") or "").strip() + if folder_id: + folders.append(_folder_item(item, folder_id)) + path = [ + _folder_item(item, str(item.get("folder_id") or item.get("id") or "").strip()) + for item in _as_dicts(data.get("current_path")) + if str(item.get("folder_id") or item.get("id") or "").strip() + ] + return docs, folders, path, _next_cursor(data), _is_end(data) + + +def _folder_item(item: dict[str, Any], folder_id: str) -> dict[str, Any]: + return { + "folder_id": folder_id, + "name": str(item.get("name") or folder_id).strip() or folder_id, + "parent_folder_id": str(item.get("parent_folder_id") or ""), + "file_number": int(item.get("file_number") or 0), + "folder_number": int(item.get("folder_number") or 0), + "is_folder": True, + } + + +def _as_dicts(raw: object) -> list[dict[str, Any]]: + if not isinstance(raw, list): + return [] + return [item for item in raw if isinstance(item, dict)] + + +def _next_cursor(data: dict[str, Any]) -> str: + return str(data.get("next_cursor") or "") + + +def _is_end(data: dict[str, Any]) -> bool: + if "is_end" in data: + return bool(data.get("is_end")) + return not bool(data.get("next_cursor")) + + +def _first_ima_instance_id(services: Any, user_id: int) -> str: + summaries = list_ima_connector_summaries(services, user_id) + return summaries[0]["instance_id"] if summaries else "" + + +def _matching_ima_instance_id(services: Any, user_id: int, client_id: str) -> str: + owned = [row for row in services.connector_repo.list_by_user(user_id) if row.kind == IMA_KIND] + if client_id: + secret = services.secret_repo + for row in owned: + if not row.credential_blob: + continue + old = decrypt_credentials(secret, row.credential_blob) + if str(old.get("client_id") or "").strip() == client_id: + return str(row.instance_id) + if len(owned) == 1: + return str(owned[0].instance_id) + return "" + + +def _unique_display_name(repo: Any, user_id: int) -> str: + if not repo.name_exists(user_id, IMA_DISPLAY_NAME): + return IMA_DISPLAY_NAME + for index in range(2, 20): + candidate = f"{IMA_DISPLAY_NAME} {index}" + if not repo.name_exists(user_id, candidate): + return candidate + return f"{IMA_DISPLAY_NAME} {new_ulid()[-4:]}" + + +def _preview_from_row(services: Any, row: Any) -> str: + secret = getattr(services, "secret_repo", None) + if secret is None or not row.credential_blob: + return "" + try: + raw = decrypt_credentials(secret, row.credential_blob) + except Exception: + return "" + return preview_client_id(str(raw.get("client_id") or "")) diff --git a/src/octop/infra/knowledge/local_mount.py b/src/octop/infra/knowledge/local_mount.py index 3a119b91..c63cd72b 100644 --- a/src/octop/infra/knowledge/local_mount.py +++ b/src/octop/infra/knowledge/local_mount.py @@ -30,9 +30,15 @@ class KnowledgeMount: kind: str = "local" cloud_url: str = "" cloud_provider: str = "" + connector_instance_id: str = "" + selected_bases: tuple[dict[str, str], ...] = () + selected_docs: tuple[dict[str, str], ...] = () def to_dict(self) -> dict[str, Any]: - return asdict(self) + payload = asdict(self) + payload["selected_bases"] = [dict(item) for item in self.selected_bases] + payload["selected_docs"] = [dict(item) for item in self.selected_docs] + return payload @dataclass(frozen=True) @@ -65,10 +71,13 @@ def load_mounts(home: Path | None = None) -> dict[str, KnowledgeMount]: continue source = str(value.get("source_path") or "") cloud_url = str(value.get("cloud_url") or "") - kind = str(value.get("kind") or ("cloud" if cloud_url else "local")) + connector_instance_id = str(value.get("connector_instance_id") or "") + kind = str( + value.get("kind") or ("cloud" if cloud_url or connector_instance_id else "local") + ) if kind == "local" and not source: continue - if kind == "cloud" and not cloud_url: + if kind == "cloud" and not cloud_url and not connector_instance_id: continue out[str(key)] = KnowledgeMount( kb_id=str(key), @@ -78,13 +87,25 @@ def load_mounts(home: Path | None = None) -> dict[str, KnowledgeMount]: kind=kind, cloud_url=cloud_url, cloud_provider=str(value.get("cloud_provider") or ""), + connector_instance_id=connector_instance_id, + selected_bases=_normalize_selected_bases(value.get("selected_bases")), + selected_docs=_normalize_selected_docs(value.get("selected_docs")), ) return out def save_cloud_mount(mount: KnowledgeMount, home: Path | None = None) -> KnowledgeMount: + provider = mount.cloud_provider.strip() or "ima" url = mount.cloud_url.strip() - if not url.startswith(("http://", "https://")): + instance_id = mount.connector_instance_id.strip() + selected_bases = _normalize_selected_bases(mount.selected_bases) + selected_docs = _normalize_selected_docs(mount.selected_docs) + if provider == "ima": + if not instance_id and not url.startswith(("http://", "https://")): + raise ValueError("IMA mount requires Agent Interface credentials") + if url and not url.startswith(("http://", "https://")): + raise ValueError("cloud_url must be an http(s) URL") + elif not url.startswith(("http://", "https://")): raise ValueError("cloud_url must be an http(s) URL") distill = mount.distill_path.strip() if distill: @@ -96,7 +117,10 @@ def save_cloud_mount(mount: KnowledgeMount, home: Path | None = None) -> Knowled readonly=True, kind="cloud", cloud_url=url, - cloud_provider=mount.cloud_provider.strip() or "ima", + cloud_provider=provider, + connector_instance_id=instance_id, + selected_bases=selected_bases, + selected_docs=selected_docs, ) rows = load_mounts(home) rows[mount.kb_id] = stored @@ -108,27 +132,90 @@ def save_cloud_mount(mount: KnowledgeMount, home: Path | None = None) -> Knowled def attach_cloud_pointer( - cloud_url: str, distill_path: str, *, provider: str = "ima" + cloud_url: str, + distill_path: str, + *, + provider: str = "ima", + selected_bases: object = (), + selected_docs: object = (), + interface_url: str = "https://ima.qq.com/agent-interface", ) -> dict[str, Any]: """Write a pointer file for corpus distillation. Does not fetch or parse the cloud KB.""" assert_safe_host_path(distill_path) dest = Path(distill_path).expanduser().resolve() dest.mkdir(parents=True, exist_ok=True) pointer = dest / "CLOUD_SOURCE.md" - pointer.write_text( - f"# Cloud knowledge mount\n\nprovider: {provider}\nurl: {cloud_url}\nparse: none\n", - encoding="utf-8", - ) + bases = _normalize_selected_bases(selected_bases) + docs = _normalize_selected_docs(selected_docs) + lines = [ + "# Cloud knowledge mount", + "", + f"provider: {provider}", + f"url: {cloud_url or interface_url}", + f"interface: {interface_url}", + "parse: none", + ] + if bases: + lines.append("selected_bases:") + lines.extend(f"- {item['id']}: {item['name']}" for item in bases) + if docs: + lines.append("selected_docs:") + lines.extend( + f"- {item['knowledge_base_id']}/{item['media_id']}: {item['title']}" for item in docs + ) + pointer.write_text("\n".join(lines) + "\n", encoding="utf-8") return { - "cloud_url": cloud_url, + "cloud_url": cloud_url or interface_url, "cloud_provider": provider, "distill_path": str(dest), "copied": 0, "no_local_parse": True, "readonly_source": True, + "selected_bases": [dict(item) for item in bases], + "selected_docs": [dict(item) for item in docs], } +def _normalize_selected_bases(value: object) -> tuple[dict[str, str], ...]: + if not isinstance(value, (list, tuple)): + return () + out: list[dict[str, str]] = [] + seen: set[str] = set() + for item in value: + if not isinstance(item, dict): + continue + kid = str(item.get("id") or item.get("knowledge_base_id") or "").strip() + if not kid or kid in seen: + continue + seen.add(kid) + out.append({"id": kid, "name": str(item.get("name") or kid).strip() or kid}) + return tuple(out) + + +def _normalize_selected_docs(value: object) -> tuple[dict[str, str], ...]: + if not isinstance(value, (list, tuple)): + return () + out: list[dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + for item in value: + if not isinstance(item, dict): + continue + kb_id = str(item.get("knowledge_base_id") or "").strip() + media_id = str(item.get("media_id") or "").strip() + if not kb_id or not media_id or (kb_id, media_id) in seen: + continue + seen.add((kb_id, media_id)) + out.append( + { + "knowledge_base_id": kb_id, + "knowledge_base_name": str(item.get("knowledge_base_name") or "").strip(), + "media_id": media_id, + "title": str(item.get("title") or media_id).strip() or media_id, + } + ) + return tuple(out) + + def save_mount(mount: KnowledgeMount, home: Path | None = None) -> KnowledgeMount: if mount.kind == "cloud" or mount.cloud_url.strip(): return save_cloud_mount(mount, home) diff --git a/src/octop/infra/knowledge/retrieve.py b/src/octop/infra/knowledge/retrieve.py index 216c3cce..3127f328 100644 --- a/src/octop/infra/knowledge/retrieve.py +++ b/src/octop/infra/knowledge/retrieve.py @@ -67,11 +67,7 @@ def _retrieve_context_sync( locale: str, visible_bases: Sequence[Any] | None, ) -> str: - assert_knowledge_usable(services.settings_repo.get, getattr(services, "provider_repo", None)) - query_vectors = embed_knowledge_texts(services, [(query or "").strip()]) - if not query_vectors: - return "" - + selected_ids = _unique_ids(knowledge_base_ids) visible = visible_bases if visible is None: visible = ( @@ -80,25 +76,46 @@ def _retrieve_context_sync( else services.knowledge_repo.list_visible(user_id) ) visible_by_id = {base.id: base for base in visible} - selected_ids = _unique_ids(knowledge_base_ids) - - ranked: list[tuple[Any, Hit, Any]] = [] - for kb_id in selected_ids: - base = visible_by_id.get(kb_id) - if base is None: - continue - ready_documents = { - document.id: document - for document in services.knowledge_repo.list_documents(kb_id) - if document.status == "ready" and not document.is_dir - } - for hit in KnowledgeIndex(kb_id).search(query_vectors[0], k=k): - document = ready_documents.get(hit.doc_id) - if document is not None: - ranked.append((base, hit, document)) - - ranked.sort(key=lambda item: item[1].score, reverse=True) - return _format_context(ranked[:k], char_budget=char_budget, locale=locale) + local_body = "" + try: + assert_knowledge_usable( + services.settings_repo.get, getattr(services, "provider_repo", None) + ) + query_vectors = embed_knowledge_texts(services, [(query or "").strip()]) + if query_vectors: + ranked: list[tuple[Any, Hit, Any]] = [] + for kb_id in selected_ids: + base = visible_by_id.get(kb_id) + if base is None: + continue + ready_documents = { + document.id: document + for document in services.knowledge_repo.list_documents(kb_id) + if document.status == "ready" and not document.is_dir + } + for hit in KnowledgeIndex(kb_id).search(query_vectors[0], k=k): + document = ready_documents.get(hit.doc_id) + if document is not None: + ranked.append((base, hit, document)) + ranked.sort(key=lambda item: item[1].score, reverse=True) + local_body = _format_context(ranked[:k], char_budget=char_budget, locale=locale) + except Exception: + logger.warning("local knowledge retrieval skipped for user=%s", user_id, exc_info=True) + local_body = "" + + remaining = max(0, char_budget - len(local_body)) + ima_body = _format_ima_context( + services, + user_id=user_id, + query=(query or "").strip(), + selected_ids=selected_ids, + visible_by_id=visible_by_id, + char_budget=remaining, + locale=locale, + ) + if local_body and ima_body: + return f"{local_body}\n\n{ima_body}" + return local_body or ima_body def _unique_ids(ids: Sequence[str]) -> list[str]: @@ -130,3 +147,72 @@ def _format_context( return "" body = tr("knowledge.retrieval.preamble", locale) + "\n\n" + "\n\n".join(sections) return append_citations_marker(body, citations_from_ranked(ranked)) + + +def _format_ima_context( + services: Any, + *, + user_id: int, + query: str, + selected_ids: Sequence[str], + visible_by_id: dict[str, Any], + char_budget: int, + locale: str, +) -> str: + if not query or char_budget <= 0: + return "" + from octop.infra.knowledge.ima import ( + load_ima_connector_credentials, + search_selected_knowledge, + ) + from octop.infra.knowledge.local_mount import load_mounts + + if getattr(services, "connector_repo", None) is None: + return "" + try: + mounts = load_mounts() + except Exception: + return "" + passages: list[dict[str, str]] = [] + for kb_id in selected_ids: + if visible_by_id.get(kb_id) is None: + continue + mount = mounts.get(kb_id) + if ( + mount is None + or mount.kind != "cloud" + or (mount.cloud_provider or "ima") != "ima" + or not (mount.selected_bases or mount.selected_docs) + ): + continue + try: + creds = load_ima_connector_credentials( + services, user_id, instance_id=mount.connector_instance_id + ) + passages.extend( + search_selected_knowledge(creds, query, mount.selected_bases, mount.selected_docs) + ) + except Exception: + logger.warning("ima knowledge retrieval skipped for kb=%s", kb_id, exc_info=True) + if not passages: + return "" + sections: list[str] = [] + remaining = char_budget + for item in passages: + citation = tr( + "knowledge.retrieval.ima_citation", + locale, + name=item["knowledge_base_name"], + filename=item["title"], + ) + if remaining <= len(citation): + break + text = item["text"].strip()[: remaining - len(citation)] + if not text: + continue + section = f"{citation}{text}" + sections.append(section) + remaining -= len(section) + if not sections: + return "" + return tr("knowledge.retrieval.ima_preamble", locale) + "\n\n" + "\n\n".join(sections) diff --git a/src/octop/modules/org_os/empower.py b/src/octop/modules/org_os/empower.py index 68533729..e15c26f2 100644 --- a/src/octop/modules/org_os/empower.py +++ b/src/octop/modules/org_os/empower.py @@ -147,8 +147,20 @@ def produce_from_corpus( mounts = load_mounts(service.home) mount = mounts.get(kb_id) if kb_id else None cloud = ima_url or (mount.cloud_url if mount is not None else "") - if cloud: - pointer = attach_cloud_pointer(cloud, dest_s, provider="ima") + ima_mount = ( + mount is not None + and mount.kind == "cloud" + and (mount.cloud_provider or "ima") == "ima" + and (mount.connector_instance_id or mount.selected_bases or mount.selected_docs) + ) + if cloud or ima_mount: + pointer = attach_cloud_pointer( + cloud, + dest_s, + provider="ima", + selected_bases=mount.selected_bases if mount is not None else (), + selected_docs=mount.selected_docs if mount is not None else (), + ) copied = int(pointer.get("copied") or 0) notes.append(f"attached cloud corpus pointer → {dest_s}") elif mount is not None and mount.source_path: diff --git a/tests/unit/api/test_knowledge_bases.py b/tests/unit/api/test_knowledge_bases.py index 0964d165..f1f19275 100644 --- a/tests/unit/api/test_knowledge_bases.py +++ b/tests/unit/api/test_knowledge_bases.py @@ -820,3 +820,102 @@ async def test_put_mount_missing_dir_is_not_prerequisites( ) assert raised.value.code == ErrorCode.KNOWLEDGE_MOUNT_INVALID + + +@pytest.mark.asyncio +async def test_ima_list_bases_uses_official_helper( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from octop.api.routers import knowledge_bases + + server = SimpleNamespace(services=_services()) + monkeypatch.setattr(knowledge_bases, "_require_enabled", lambda *_a, **_k: None) + monkeypatch.setattr( + "octop.infra.knowledge.ima.resolve_ima_credentials", + lambda *_a, **_k: ("inst-1", {"client_id": "c", "api_key": "k"}), + ) + monkeypatch.setattr( + "octop.infra.knowledge.ima.list_knowledge_bases", + lambda *_a, **_k: { + "items": [{"id": "ima-kb", "name": "工作库"}], + "next_cursor": "", + "is_end": True, + }, + ) + + response = await knowledge_bases.ima_list_bases( + request=_request(), + query="", + cursor="", + limit=20, + instance_id="inst-1", + server=server, + user=SimpleNamespace(id=1, is_admin=False), + ) + assert response["items"][0]["id"] == "ima-kb" + + +@pytest.mark.asyncio +async def test_put_ima_mount_persists_selection( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + from octop.api.routers import knowledge_bases + + settings = {"knowledge_bases_enabled": "true"} + server = SimpleNamespace( + services=_services( + settings_repo=SimpleNamespace(get=settings.get, set=settings.__setitem__), + ), + paths=SimpleNamespace(root=tmp_path), + ) + monkeypatch.setattr( + knowledge_bases, + "_knowledge_service", + lambda _server: SimpleNamespace(get_writable_base=lambda *_a, **_k: _Base()), + ) + + response = await knowledge_bases.put_kb_mount( + "kb-1", + knowledge_bases.MountBody( + kind="cloud", + cloud_provider="ima", + connector_instance_id="inst-1", + selected_bases=[knowledge_bases.ImaSelectedBase(id="ima-kb", name="工作库")], + selected_docs=[ + knowledge_bases.ImaSelectedDoc( + knowledge_base_id="ima-kb", + knowledge_base_name="工作库", + media_id="m1", + title="纪要", + ) + ], + ), + request=_request(), + server=server, + user=SimpleNamespace(id=1, is_admin=False), + ) + assert response["mounted"] is True + assert response["connector_instance_id"] == "inst-1" + assert response["selected_docs"][0]["media_id"] == "m1" + + +@pytest.mark.asyncio +async def test_ima_connect_maps_auth_error(monkeypatch: pytest.MonkeyPatch) -> None: + from octop.api.routers import knowledge_bases + + server = SimpleNamespace(services=_services()) + monkeypatch.setattr(knowledge_bases, "_require_enabled", lambda *_a, **_k: None) + + def fail(*_a: object, **_k: object) -> None: + raise ValueError("IMA credentials: bad key") + + monkeypatch.setattr("octop.infra.knowledge.ima.resolve_ima_credentials", fail) + + with pytest.raises(OctopError) as raised: + await knowledge_bases.ima_connect( + knowledge_bases.ImaConnectBody(client_id="c", api_key="k"), + request=_request(), + server=server, + user=SimpleNamespace(id=1, is_admin=False), + ) + assert raised.value.code == ErrorCode.KNOWLEDGE_IMA_AUTH diff --git a/tests/unit/connectors/test_tencent_ima.py b/tests/unit/connectors/test_tencent_ima.py index 299f3136..08ed3cef 100644 --- a/tests/unit/connectors/test_tencent_ima.py +++ b/tests/unit/connectors/test_tencent_ima.py @@ -225,6 +225,35 @@ def test_add_note_to_knowledge(monkeypatch: pytest.MonkeyPatch) -> None: } +def test_openapi_accepts_official_retcode(monkeypatch: pytest.MonkeyPatch) -> None: + class _Resp: + status_code = 200 + + def json(self) -> dict[str, object]: + return {"retcode": 0, "errmsg": "成功", "data": {"ok": True}} + + class _Client: + def __init__(self, *_args: object, **_kwargs: object) -> None: + return None + + def __enter__(self) -> _Client: + return self + + def __exit__(self, *_args: object) -> None: + return None + + def post(self, *_args: object, **_kwargs: object) -> _Resp: + return _Resp() + + monkeypatch.setattr( + "octop.infra.connectors.gateway.adapters.tencent_ima.httpx.Client", + _Client, + ) + from octop.infra.connectors.gateway.adapters.tencent_ima import openapi_data + + assert openapi_data({"api_key": "k", "client_id": "c"}, "openapi/wiki/v1/x", {}) == {"ok": True} + + def test_get_media(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} _install_httpx_mock(monkeypatch, captured) diff --git a/tests/unit/knowledge/test_knowledge_ima.py b/tests/unit/knowledge/test_knowledge_ima.py new file mode 100644 index 00000000..fa754951 --- /dev/null +++ b/tests/unit/knowledge/test_knowledge_ima.py @@ -0,0 +1,157 @@ +"""Unit tests for official IMA Agent Interface knowledge helpers.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from octop.infra.connectors.gateway.adapters.tencent_ima import unwrap_openapi_data +from octop.infra.knowledge.ima import ( + parse_knowledge_bases, + parse_knowledge_list, + preview_client_id, + search_selected_knowledge, +) +from octop.infra.knowledge.local_mount import KnowledgeMount, load_mounts, save_cloud_mount + + +def test_unwrap_official_retcode_payload() -> None: + data = unwrap_openapi_data( + { + "retcode": 0, + "errmsg": "成功", + "data": {"info_list": [{"id": "kb1", "name": "工作"}]}, + } + ) + assert data["info_list"][0]["id"] == "kb1" + + +def test_unwrap_legacy_code_payload() -> None: + data = unwrap_openapi_data({"code": 0, "data": {"info_list": []}}) + assert data == {"info_list": []} + + +def test_unwrap_rejects_official_error() -> None: + with pytest.raises(ValueError, match="没有权限"): + unwrap_openapi_data({"retcode": 110030, "errmsg": "没有权限"}) + + +def test_parse_knowledge_bases_and_docs() -> None: + bases, cursor, ended = parse_knowledge_bases( + { + "info_list": [{"id": "kb1", "name": "工作库", "cover_url": "https://x"}], + "next_cursor": "n1", + "is_end": False, + } + ) + assert bases == [{"id": "kb1", "name": "工作库", "cover_url": "https://x", "description": ""}] + assert cursor == "n1" + assert ended is False + + docs, folders, path, _cursor, is_end = parse_knowledge_list( + { + "knowledge_list": [ + {"media_id": "m1", "title": "纪要", "parent_folder_id": "kb1"}, + {"folder_id": "fd1", "name": "归档", "file_number": 2}, + ], + "current_path": [{"folder_id": "kb1", "name": "工作库"}], + "is_end": True, + } + ) + assert docs[0]["media_id"] == "m1" + assert folders[0]["folder_id"] == "fd1" + assert path[0]["name"] == "工作库" + assert is_end is True + + +def test_ima_mount_persists_selection_without_url(tmp_path) -> None: + stored = save_cloud_mount( + KnowledgeMount( + kb_id="kb-ima", + kind="cloud", + cloud_provider="ima", + connector_instance_id="inst-1", + selected_bases=({"id": "ima-kb", "name": "工作库"},), + selected_docs=( + { + "knowledge_base_id": "ima-kb", + "knowledge_base_name": "工作库", + "media_id": "m1", + "title": "纪要", + }, + ), + ), + tmp_path, + ) + assert stored.cloud_url == "" + assert stored.connector_instance_id == "inst-1" + loaded = load_mounts(tmp_path)["kb-ima"] + assert loaded.selected_bases[0]["id"] == "ima-kb" + assert loaded.selected_docs[0]["media_id"] == "m1" + + +def test_ima_mount_rejects_disconnected(tmp_path) -> None: + with pytest.raises(ValueError, match="Agent Interface"): + save_cloud_mount( + KnowledgeMount(kb_id="kb-ima", kind="cloud", cloud_provider="ima"), + tmp_path, + ) + + +def test_search_selected_knowledge_filters_docs(monkeypatch: pytest.MonkeyPatch) -> None: + captured: list[dict[str, object]] = [] + + def fake_openapi( + _creds: dict[str, object], path: str, body: dict[str, object] + ) -> dict[str, object]: + captured.append({"path": path, "body": body}) + return { + "info_list": [ + {"media_id": "keep", "title": "保留", "highlight_content": "命中保留"}, + {"media_id": "skip", "title": "跳过", "highlight_content": "不该出现"}, + ] + } + + monkeypatch.setattr("octop.infra.knowledge.ima.openapi_data", fake_openapi) + passages = search_selected_knowledge( + {"client_id": "c", "api_key": "k"}, + "周报", + [{"id": "kb1", "name": "工作库"}], + [{"knowledge_base_id": "kb1", "media_id": "keep", "title": "保留"}], + ) + assert captured[0]["path"] == "openapi/wiki/v1/search_knowledge" + assert [item["media_id"] for item in passages] == ["keep"] + assert passages[0]["text"] == "命中保留" + + +def test_preview_client_id() -> None: + assert preview_client_id("abcd1234efgh") == "abcd…efgh" + + +def test_connector_summaries_skip_foreign() -> None: + from octop.infra.knowledge.ima import list_ima_connector_summaries + + rows = [ + SimpleNamespace( + kind="tencent-ima", + has_credentials=True, + user_id=2, + shared=False, + instance_id="x", + display_name="other", + credential_blob=None, + ), + SimpleNamespace( + kind="tencent-ima", + has_credentials=True, + user_id=1, + shared=False, + instance_id="mine", + display_name="mine", + credential_blob=None, + ), + ] + services = SimpleNamespace(connector_repo=SimpleNamespace(list_visible=lambda _uid: rows)) + out = list_ima_connector_summaries(services, 1) + assert [item["instance_id"] for item in out] == ["mine"] diff --git a/tests/unit/knowledge/test_retrieve.py b/tests/unit/knowledge/test_retrieve.py index 8102ab32..d2c6bff0 100644 --- a/tests/unit/knowledge/test_retrieve.py +++ b/tests/unit/knowledge/test_retrieve.py @@ -79,3 +79,69 @@ def test_retrieve_context_skips_empty_non_text_turn() -> None: ) assert context == "" + + +def test_retrieve_context_includes_ima_selection(monkeypatch) -> None: + from octop.infra.knowledge.local_mount import KnowledgeMount + + base = SimpleNamespace(id="kb-1", name="Cloud") + services = SimpleNamespace( + knowledge_repo=SimpleNamespace( + list_visible=lambda _uid: [base], + list_documents=lambda _id: [], + ), + settings_repo=SimpleNamespace(get=lambda _key: "model"), + connector_repo=object(), + secret_repo=object(), + ) + monkeypatch.setattr(retrieve_module, "assert_knowledge_usable", lambda *_args: None) + monkeypatch.setattr(retrieve_module, "embed_knowledge_texts", lambda *_a, **_k: []) + monkeypatch.setattr( + "octop.infra.knowledge.local_mount.load_mounts", + lambda: { + "kb-1": KnowledgeMount( + kb_id="kb-1", + kind="cloud", + cloud_provider="ima", + connector_instance_id="inst-1", + selected_bases=({"id": "ima-kb", "name": "工作库"},), + selected_docs=( + { + "knowledge_base_id": "ima-kb", + "media_id": "m1", + "title": "纪要", + }, + ), + ) + }, + ) + monkeypatch.setattr( + "octop.infra.knowledge.ima.load_ima_connector_credentials", + lambda *_a, **_k: {"client_id": "c", "api_key": "k"}, + ) + monkeypatch.setattr( + "octop.infra.knowledge.ima.search_selected_knowledge", + lambda *_a, **_k: [ + { + "knowledge_base_id": "ima-kb", + "knowledge_base_name": "工作库", + "media_id": "m1", + "title": "纪要", + "text": "ima selected hit", + } + ], + ) + + context = asyncio.run( + retrieve_module.retrieve_context( + services, + user_id=1, + is_admin=False, + query="周报", + knowledge_base_ids=["kb-1"], + ) + ) + + assert "ima selected hit" in context + assert "工作库" in context + assert "纪要" in context From b4e24d2af0c4328caa3564fa107ad53551a0669c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 05:46:14 +0000 Subject: [PATCH 2/3] Align ima KB/doc picking with official Agent Interface 1.1.9. Parse folder_ media_id entries as folders, hydrate bases via get_knowledge_base, and let users search/page documents with search_knowledge so the mount can select both knowledge bases and documents after the official Client ID / API Key connect. Co-authored-by: XYAI Labs --- CHANGELOG.md | 1 + .../src/api/modules/knowledgeBases.test.ts | 7 +- dashboard/src/api/modules/knowledgeBases.ts | 6 +- dashboard/src/locales/en.json | 2 + dashboard/src/locales/zh.json | 2 + .../KnowledgeBases/CloudMountPanel.test.tsx | 86 ++++++++++ .../pages/KnowledgeBases/CloudMountPanel.tsx | 148 +++++++++++++----- src/octop/api/routers/knowledge_bases.py | 6 +- .../gateway/adapters/tencent_ima.py | 1 + src/octop/infra/knowledge/ima.py | 125 ++++++++++++--- tests/unit/connectors/test_tencent_ima.py | 5 + tests/unit/knowledge/test_knowledge_ima.py | 89 ++++++++++- 12 files changed, 413 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b58333d..9a5016ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 修复 +- ima 知识库挂接对齐官方 Agent Interface(https://ima.qq.com/agent-interface,ima-skill 1.1.9):用 Client ID / API Key 连接后,按 `search_knowledge_base` + `get_knowledge_base` 选知识库,按 `get_knowledge_list` / `search_knowledge` 自选文档;文件夹按官方 `folder_` `media_id` 浏览,选择会写入挂接。 - 模型页注册的本机 Ollama / GGUF 现在会出现在对话区模型选择器里,并走本机 `127.0.0.1:11434` 调用。注册会补齐 Ollama 供应商默认值、打开服务开关、热加载对话列表;Ollama 未启动时给出可操作错误,而不是静默失败。 - 对话模型选择器底部增加「模型管理」,跳到侧栏同一条「模型」路由(`/models`)。 - 组织页默认只显示本机 openXYOS 嵌入和顶部蓝色状态栏;「启用组织模块」「组织OS管理」在状态栏右下。互生长导入/导出、源码下载、循环时间线等收到「组织OS管理」。iframe 高度等于可见预览区,openXYOS 弹窗按 iframe 视口水平垂直居中。 diff --git a/dashboard/src/api/modules/knowledgeBases.test.ts b/dashboard/src/api/modules/knowledgeBases.test.ts index ab394091..ff8a4af5 100644 --- a/dashboard/src/api/modules/knowledgeBases.test.ts +++ b/dashboard/src/api/modules/knowledgeBases.test.ts @@ -90,7 +90,10 @@ describe("knowledgeBasesApi", () => { knowledgeBasesApi.imaStatus(); knowledgeBasesApi.imaConnect({ client_id: "c", api_key: "k" }); knowledgeBasesApi.imaListBases({ query: "work" }); - knowledgeBasesApi.imaListDocuments("kb-ima", { folder_id: "fd1" }); + knowledgeBasesApi.imaListDocuments("kb-ima", { + folder_id: "fd1", + query: "周报", + }); expect(request).toHaveBeenNthCalledWith(1, "/knowledge-bases/ima/status"); expect(request).toHaveBeenNthCalledWith(2, "/knowledge-bases/ima/connect", { @@ -103,7 +106,7 @@ describe("knowledgeBasesApi", () => { ); expect(request).toHaveBeenNthCalledWith( 4, - "/knowledge-bases/ima/bases/kb-ima/documents?folder_id=fd1", + "/knowledge-bases/ima/bases/kb-ima/documents?folder_id=fd1&query=%E5%91%A8%E6%8A%A5", ); }); diff --git a/dashboard/src/api/modules/knowledgeBases.ts b/dashboard/src/api/modules/knowledgeBases.ts index 9778969f..9fb2c80a 100644 --- a/dashboard/src/api/modules/knowledgeBases.ts +++ b/dashboard/src/api/modules/knowledgeBases.ts @@ -419,6 +419,7 @@ export const knowledgeBasesApi = { folder_id?: string; cursor?: string; limit?: number; + query?: string; instance_id?: string; }, ) => { @@ -426,6 +427,7 @@ export const knowledgeBasesApi = { if (opts?.folder_id) params.set("folder_id", opts.folder_id); if (opts?.cursor) params.set("cursor", opts.cursor); if (opts?.limit) params.set("limit", String(opts.limit)); + if (opts?.query) params.set("query", opts.query); if (opts?.instance_id) params.set("instance_id", opts.instance_id); const qs = params.toString(); return request<{ @@ -438,7 +440,9 @@ export const knowledgeBasesApi = { folder_id: string; }>( qs - ? `/knowledge-bases/ima/bases/${encodeURIComponent(imaKbId)}/documents?${qs}` + ? `/knowledge-bases/ima/bases/${encodeURIComponent( + imaKbId, + )}/documents?${qs}` : `/knowledge-bases/ima/bases/${encodeURIComponent(imaKbId)}/documents`, ); }, diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index b0dd66a5..47a198ef 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -540,6 +540,8 @@ "imaExistingConnection": "Existing IMA connection", "imaExistingPlaceholder": "Select a saved IMA connection", "imaSearchBases": "Search ima knowledge bases", + "imaSearchDocs": "Search documents in this knowledge base", + "imaDocsSearchEmpty": "No matching documents.", "imaSelectBases": "Select knowledge bases", "imaSelectDocs": "Select documents", "imaBasesEmpty": "No ima knowledge bases yet. Create one in ima, then refresh.", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 28018c1a..0e195043 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -540,6 +540,8 @@ "imaExistingConnection": "已有 IMA 连接", "imaExistingPlaceholder": "选择已保存的 IMA 连接", "imaSearchBases": "搜索 ima 知识库", + "imaSearchDocs": "在当前知识库中搜索文档", + "imaDocsSearchEmpty": "没有匹配的文档。", "imaSelectBases": "选择知识库", "imaSelectDocs": "选择文档", "imaBasesEmpty": "还没有 ima 知识库。请先在 ima 中创建,然后刷新。", diff --git a/dashboard/src/pages/KnowledgeBases/CloudMountPanel.test.tsx b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.test.tsx index acb68ee6..a0c48723 100644 --- a/dashboard/src/pages/KnowledgeBases/CloudMountPanel.test.tsx +++ b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.test.tsx @@ -127,4 +127,90 @@ describe("", () => { ], }); }); + + it("browses official folder_ entries and searches documents", async () => { + const user = userEvent.setup(); + imaStatus.mockResolvedValue({ + connected: true, + instance_id: "inst-1", + client_id_preview: "clid…1234", + auth_url: "https://ima.qq.com/agent-interface", + instances: [ + { + instance_id: "inst-1", + display_name: "腾讯 IMA", + client_id_preview: "clid…1234", + }, + ], + }); + imaListDocuments.mockImplementation( + async (_kb: string, opts?: { folder_id?: string; query?: string }) => { + if (opts?.query === "排期") { + return { + items: [{ media_id: "doc-3", title: "排期" }], + folders: [], + current_path: [], + next_cursor: "", + is_end: true, + knowledge_base_id: "kb-ima", + folder_id: "", + query: "排期", + }; + } + if (opts?.folder_id === "folder_abc") { + return { + items: [{ media_id: "doc-2", title: "需求" }], + folders: [], + current_path: [ + { folder_id: "folder_abc", name: "设计文档", is_folder: true }, + ], + next_cursor: "", + is_end: true, + knowledge_base_id: "kb-ima", + folder_id: "folder_abc", + }; + } + return { + items: [], + folders: [ + { folder_id: "folder_abc", name: "设计文档", is_folder: true }, + ], + current_path: [], + next_cursor: "", + is_end: true, + knowledge_base_id: "kb-ima", + folder_id: "", + }; + }, + ); + + render(); + + expect(await screen.findByText("设计文档")).toBeInTheDocument(); + await user.click(screen.getByText("设计文档")); + expect( + await screen.findByRole("checkbox", { name: "需求" }), + ).toBeInTheDocument(); + expect(imaListDocuments).toHaveBeenCalledWith("kb-ima", { + folder_id: "folder_abc", + query: "", + cursor: "", + instance_id: "inst-1", + }); + + await user.type( + screen.getByPlaceholderText("knowledgeBases.imaSearchDocs"), + "排期", + ); + await user.keyboard("{Enter}"); + expect( + await screen.findByRole("checkbox", { name: "排期" }), + ).toBeInTheDocument(); + expect(imaListDocuments).toHaveBeenLastCalledWith("kb-ima", { + folder_id: "", + query: "排期", + cursor: "", + instance_id: "inst-1", + }); + }); }); diff --git a/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx index 2f37e0b1..c5989610 100644 --- a/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx +++ b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx @@ -61,6 +61,10 @@ export function CloudMountPanel({ const [folders, setFolders] = useState([]); const [folderPath, setFolderPath] = useState([]); const [docsError, setDocsError] = useState(""); + const [docQuery, setDocQuery] = useState(""); + const [docsCursor, setDocsCursor] = useState(""); + const [docsEnd, setDocsEnd] = useState(true); + const [activeFolderId, setActiveFolderId] = useState(""); const [selectedBases, setSelectedBases] = useState([]); const [selectedDocs, setSelectedDocs] = useState([]); const canPick = canPickKnowledgeFolder(); @@ -72,7 +76,9 @@ export function CloudMountPanel({ const selectedDocKeys = useMemo( () => new Set( - selectedDocs.map((item) => `${item.knowledge_base_id}:${item.media_id}`), + selectedDocs.map( + (item) => `${item.knowledge_base_id}:${item.media_id}`, + ), ), [selectedDocs], ); @@ -107,25 +113,47 @@ export function CloudMountPanel({ setActiveKbId(""); setDocs([]); setFolders([]); + setActiveFolderId(""); + setDocQuery(""); } }, [], ); const loadDocs = useCallback( - async (id: string, imaKbId: string, folderId = "") => { + async ( + id: string, + imaKbId: string, + folderId = "", + query = "", + cursor = "", + append = false, + ) => { setDocsError(""); try { const result = await knowledgeBasesApi.imaListDocuments(imaKbId, { - folder_id: folderId, + folder_id: query ? "" : folderId, + query, + cursor, instance_id: id, }); - setDocs(result.items || []); - setFolders(result.folders || []); - setFolderPath(result.current_path || []); + setDocs((prev) => + append ? [...prev, ...result.items] : result.items || [], + ); + setFolders((prev) => + append ? [...prev, ...(result.folders || [])] : result.folders || [], + ); + if (!append) { + setFolderPath(result.current_path || []); + setActiveFolderId(query ? "" : folderId); + } + setDocsCursor(result.next_cursor || ""); + setDocsEnd(result.is_end); } catch (err) { - setDocs([]); - setFolders([]); + if (!append) { + setDocs([]); + setFolders([]); + } setDocsError( err instanceof Error ? err.message @@ -136,6 +164,15 @@ export function CloudMountPanel({ [t], ); + const browseDocs = useCallback( + (id: string, imaKbId: string, folderId = "", query = "") => { + setDocQuery(query); + setActiveFolderId(query ? "" : folderId); + void loadDocs(id, imaKbId, folderId, query); + }, + [loadDocs], + ); + useEffect(() => { let cancelled = false; void (async () => { @@ -185,8 +222,13 @@ export function CloudMountPanel({ useEffect(() => { if (!instanceId || !activeKbId || showAuthForm) return; + setDocQuery(""); + setActiveFolderId(""); void loadDocs(instanceId, activeKbId); - }, [instanceId, activeKbId, showAuthForm, loadDocs]); + // loadDocs is stable enough for mount/selection; omit it so i18n `t` + // identity changes do not retrigger listing. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [instanceId, activeKbId, showAuthForm]); const resolveKb = async () => { if (kbId) return kbId; @@ -217,10 +259,14 @@ export function CloudMountPanel({ applyStatus(status); setApiKey(""); await loadBases(status.instance_id, baseQuery); - message.success(t("knowledgeBases.imaConnected", { preview: status.client_id_preview })); + message.success( + t("knowledgeBases.imaConnected", { preview: status.client_id_preview }), + ); } catch (err) { message.error( - err instanceof Error ? err.message : t("knowledgeBases.cloudMountFailed"), + err instanceof Error + ? err.message + : t("knowledgeBases.cloudMountFailed"), ); } finally { setBusy(false); @@ -256,7 +302,11 @@ export function CloudMountPanel({ setSelectedDocs((prev) => { const key = `${activeKbId}:${doc.media_id}`; if (checked) { - if (prev.some((item) => `${item.knowledge_base_id}:${item.media_id}` === key)) { + if ( + prev.some( + (item) => `${item.knowledge_base_id}:${item.media_id}` === key, + ) + ) { return prev; } return [ @@ -482,9 +532,7 @@ export function CloudMountPanel({ > - toggleBase(base, event.target.checked) - } + onChange={(event) => toggleBase(base, event.target.checked)} onClick={() => setActiveKbId(base.id)} > {base.name} @@ -520,25 +568,36 @@ export function CloudMountPanel({ -
- - {folderPath.map((folder) => ( + setDocQuery(event.target.value)} + onSearch={(value) => { + browseDocs(instanceId, activeKbId, "", value); + }} + /> + {!docQuery ? ( +
- ))} -
+ {folderPath.map((folder) => ( + + ))} +
+ ) : null} {docsError ? ( ) : null} @@ -548,7 +607,7 @@ export function CloudMountPanel({ type="button" className={styles.folderRow} onClick={() => - void loadDocs(instanceId, activeKbId, folder.folder_id) + browseDocs(instanceId, activeKbId, folder.folder_id) } > @@ -561,7 +620,11 @@ export function CloudMountPanel({ {docs.length === 0 && folders.length === 0 && !docsError ? ( ) : ( docs.map((doc) => ( @@ -570,15 +633,30 @@ export function CloudMountPanel({ checked={selectedDocKeys.has( `${activeKbId}:${doc.media_id}`, )} - onChange={(event) => - toggleDoc(doc, event.target.checked) - } + onChange={(event) => toggleDoc(doc, event.target.checked)} > {doc.title}
)) )} + {!docsEnd ? ( + + ) : null} {selectedBaseIds.has(activeKbId) && !activeHasDocs ? ( {t("knowledgeBases.imaWholeBaseHint")} diff --git a/src/octop/api/routers/knowledge_bases.py b/src/octop/api/routers/knowledge_bases.py index 39275d72..0713d0fd 100644 --- a/src/octop/api/routers/knowledge_bases.py +++ b/src/octop/api/routers/knowledge_bases.py @@ -681,7 +681,7 @@ async def ima_list_bases( request: Request, query: str = Query(default=""), cursor: str = Query(default=""), - limit: int = Query(default=20, ge=1, le=50), + limit: int = Query(default=20, ge=1, le=20), instance_id: str = Query(default=""), server: OctopServer = Depends(get_server), user: User = Depends(require_permission("knowledge_bases")), @@ -707,7 +707,7 @@ def _load() -> dict[str, Any]: @router.get( "/ima/bases/{ima_kb_id}/documents", - summary="List ima documents via official get_knowledge_list", + summary="List ima documents via official get_knowledge_list or search_knowledge", ) async def ima_list_documents( ima_kb_id: str, @@ -715,6 +715,7 @@ async def ima_list_documents( folder_id: str = Query(default=""), cursor: str = Query(default=""), limit: int = Query(default=20, ge=1, le=50), + query: str = Query(default="", description="When set, uses official search_knowledge"), instance_id: str = Query(default=""), server: OctopServer = Depends(get_server), user: User = Depends(require_permission("knowledge_bases")), @@ -735,6 +736,7 @@ def _load() -> dict[str, Any]: folder_id=folder_id, cursor=cursor, limit=limit, + query=query, ) return await asyncio.to_thread(_load) diff --git a/src/octop/infra/connectors/gateway/adapters/tencent_ima.py b/src/octop/infra/connectors/gateway/adapters/tencent_ima.py index e7cb536f..475cea23 100644 --- a/src/octop/infra/connectors/gateway/adapters/tencent_ima.py +++ b/src/octop/infra/connectors/gateway/adapters/tencent_ima.py @@ -464,6 +464,7 @@ def _headers(creds: dict[str, Any]) -> dict[str, str]: return { "ima-openapi-clientid": client_id, "ima-openapi-apikey": api_key, + "ima-openapi-ctx": "skill_version=1.1.9", "Content-Type": "application/json", "User-Agent": "octop-connector/0.1", } diff --git a/src/octop/infra/knowledge/ima.py b/src/octop/infra/knowledge/ima.py index c11533ef..2310949a 100644 --- a/src/octop/infra/knowledge/ima.py +++ b/src/octop/infra/knowledge/ima.py @@ -1,9 +1,14 @@ """Official IMA Agent Interface helpers for knowledge-base mount. -Auth and endpoints follow https://ima.qq.com/agent-interface: +Auth and endpoints follow https://ima.qq.com/agent-interface and the +official ima-skill 1.1.9 Wiki OpenAPI (``openapi/wiki/v1``): - Headers ``ima-openapi-clientid`` / ``ima-openapi-apikey`` - POST JSON to ``https://ima.qq.com/openapi/wiki/v1/*`` +- List bases: ``search_knowledge_base`` (limit 1–20), then hydrate with + ``get_knowledge_base`` +- Browse docs: ``get_knowledge_list``; search docs: ``search_knowledge`` +- Folders in those lists use ``media_id`` prefixed ``folder_`` """ from __future__ import annotations @@ -160,7 +165,8 @@ def list_knowledge_bases( cursor: str = "", limit: int = 20, ) -> dict[str, Any]: - limit = max(1, min(int(limit or 20), 50)) + # Official ima-skill 1.1.9: search_knowledge_base limit is 1–20. + limit = max(1, min(int(limit or 20), 20)) try: data = openapi_data( creds, @@ -170,6 +176,7 @@ def list_knowledge_bases( except ValueError as exc: raise ValueError(f"IMA API: {exc}") from exc items, next_cursor, is_end = parse_knowledge_bases(data) + _hydrate_knowledge_bases(creds, items) return {"items": items, "next_cursor": next_cursor, "is_end": is_end} @@ -180,21 +187,30 @@ def list_knowledge_documents( folder_id: str = "", cursor: str = "", limit: int = 20, + query: str = "", ) -> dict[str, Any]: kb_id = knowledge_base_id.strip() if not kb_id: raise ValueError("IMA API: knowledge_base_id is required") limit = max(1, min(int(limit or 20), 50)) - body: dict[str, Any] = { - "knowledge_base_id": kb_id, - "cursor": cursor, - "limit": limit, - } folder = folder_id.strip() - if folder: - body["folder_id"] = folder + q = query.strip() try: - data = openapi_data(creds, "openapi/wiki/v1/get_knowledge_list", body) + if q: + data = openapi_data( + creds, + "openapi/wiki/v1/search_knowledge", + {"query": q, "knowledge_base_id": kb_id, "cursor": cursor}, + ) + else: + body: dict[str, Any] = { + "knowledge_base_id": kb_id, + "cursor": cursor, + "limit": limit, + } + if folder: + body["folder_id"] = folder + data = openapi_data(creds, "openapi/wiki/v1/get_knowledge_list", body) except ValueError as exc: raise ValueError(f"IMA API: {exc}") from exc items, folders, current_path, next_cursor, is_end = parse_knowledge_list(data) @@ -205,7 +221,8 @@ def list_knowledge_documents( "next_cursor": next_cursor, "is_end": is_end, "knowledge_base_id": kb_id, - "folder_id": folder, + "folder_id": "" if q else folder, + "query": q, } @@ -257,6 +274,8 @@ def search_selected_knowledge( added = 0 for item in _as_dicts(data.get("info_list") or data.get("knowledge_list")): media_id = str(item.get("media_id") or "").strip() + if not media_id or _is_folder_entry(item): + continue if allowed is not None and media_id not in allowed: continue text = str(item.get("highlight_content") or item.get("title") or "").strip() @@ -305,12 +324,15 @@ def parse_knowledge_list( ) -> tuple[list[dict[str, str]], list[dict[str, Any]], list[dict[str, Any]], str, bool]: docs: list[dict[str, str]] = [] folders: list[dict[str, Any]] = [] + seen_folders: set[str] = set() for item in _as_dicts(data.get("knowledge_list") or data.get("info_list")): - folder_id = str(item.get("folder_id") or "").strip() - media_id = str(item.get("media_id") or "").strip() - if folder_id and not media_id: - folders.append(_folder_item(item, folder_id)) + if _is_folder_entry(item): + folder_id = _folder_id_of(item) + if folder_id and folder_id not in seen_folders: + seen_folders.add(folder_id) + folders.append(_folder_item(item, folder_id)) continue + media_id = str(item.get("media_id") or "").strip() if media_id: docs.append( { @@ -321,28 +343,85 @@ def parse_knowledge_list( } ) for item in _as_dicts(data.get("folder_list") or data.get("folders")): - folder_id = str(item.get("folder_id") or item.get("id") or "").strip() - if folder_id: + folder_id = _folder_id_of(item) + if folder_id and folder_id not in seen_folders: + seen_folders.add(folder_id) folders.append(_folder_item(item, folder_id)) path = [ - _folder_item(item, str(item.get("folder_id") or item.get("id") or "").strip()) + _folder_item(item, fid) for item in _as_dicts(data.get("current_path")) - if str(item.get("folder_id") or item.get("id") or "").strip() + if (fid := _folder_id_of(item)) ] return docs, folders, path, _next_cursor(data), _is_end(data) def _folder_item(item: dict[str, Any], folder_id: str) -> dict[str, Any]: + nested = item.get("folder_info") + src = nested if isinstance(nested, dict) else item return { "folder_id": folder_id, - "name": str(item.get("name") or folder_id).strip() or folder_id, - "parent_folder_id": str(item.get("parent_folder_id") or ""), - "file_number": int(item.get("file_number") or 0), - "folder_number": int(item.get("folder_number") or 0), + "name": str(src.get("name") or item.get("title") or item.get("name") or folder_id).strip() + or folder_id, + "parent_folder_id": str(src.get("parent_folder_id") or item.get("parent_folder_id") or ""), + "file_number": int(src.get("file_number") or item.get("file_number") or 0), + "folder_number": int(src.get("folder_number") or item.get("folder_number") or 0), "is_folder": True, } +def _folder_id_of(item: dict[str, Any]) -> str: + nested = item.get("folder_info") + if isinstance(nested, dict): + nested_id = str(nested.get("folder_id") or nested.get("id") or "").strip() + if nested_id: + return nested_id + folder_id = str(item.get("folder_id") or item.get("id") or "").strip() + if folder_id.startswith("folder_") or ( + folder_id and not str(item.get("media_id") or "").strip() + ): + return folder_id + media_id = str(item.get("media_id") or "").strip() + if media_id.startswith("folder_"): + return media_id + return folder_id if folder_id.startswith("folder_") else "" + + +def _is_folder_entry(item: dict[str, Any]) -> bool: + """Official lists mix files and folders; folders use ``folder_`` ids.""" + media_id = str(item.get("media_id") or "").strip() + if media_id.startswith("folder_"): + return True + if item.get("is_folder") is True: + return True + if isinstance(item.get("folder_info"), dict): + return True + folder_id = str(item.get("folder_id") or "").strip() + return bool(folder_id and not media_id) + + +def _hydrate_knowledge_bases(creds: dict[str, Any], items: list[dict[str, str]]) -> None: + ids = [item["id"] for item in items if item.get("id")] + if not ids: + return + try: + data = openapi_data(creds, "openapi/wiki/v1/get_knowledge_base", {"ids": ids[:20]}) + except ValueError: + return + infos = data.get("infos") + if not isinstance(infos, dict): + return + by_id = {str(key): value for key, value in infos.items() if isinstance(value, dict)} + for item in items: + info = by_id.get(item["id"]) + if info is None: + continue + name = str(info.get("name") or "").strip() + if name: + item["name"] = name + item["cover_url"] = str(info.get("cover_url") or item.get("cover_url") or "") + item["description"] = str(info.get("description") or item.get("description") or "") + + def _as_dicts(raw: object) -> list[dict[str, Any]]: if not isinstance(raw, list): return [] diff --git a/tests/unit/connectors/test_tencent_ima.py b/tests/unit/connectors/test_tencent_ima.py index 08ed3cef..edf84848 100644 --- a/tests/unit/connectors/test_tencent_ima.py +++ b/tests/unit/connectors/test_tencent_ima.py @@ -74,6 +74,11 @@ def test_list_notebooks_defaults_cursor_zero(monkeypatch: pytest.MonkeyPatch) -> _call("list_notebooks", {"limit": 10}) assert captured["url"] == "https://ima.qq.com/openapi/note/v1/list_notebook" assert captured["json"] == {"cursor": "0", "limit": 10} + headers = captured["headers"] + assert isinstance(headers, dict) + assert headers["ima-openapi-clientid"] == "c" + assert headers["ima-openapi-apikey"] == "k" + assert headers["ima-openapi-ctx"] == "skill_version=1.1.9" def test_get_note_uses_plaintext(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/unit/knowledge/test_knowledge_ima.py b/tests/unit/knowledge/test_knowledge_ima.py index fa754951..3ef6e166 100644 --- a/tests/unit/knowledge/test_knowledge_ima.py +++ b/tests/unit/knowledge/test_knowledge_ima.py @@ -54,13 +54,18 @@ def test_parse_knowledge_bases_and_docs() -> None: "knowledge_list": [ {"media_id": "m1", "title": "纪要", "parent_folder_id": "kb1"}, {"folder_id": "fd1", "name": "归档", "file_number": 2}, + {"media_id": "folder_abc", "title": "设计文档"}, + { + "folder_info": {"folder_id": "folder_nested", "name": "会议纪要"}, + "title": "会议纪要", + }, ], "current_path": [{"folder_id": "kb1", "name": "工作库"}], "is_end": True, } ) - assert docs[0]["media_id"] == "m1" - assert folders[0]["folder_id"] == "fd1" + assert [item["media_id"] for item in docs] == ["m1"] + assert [item["folder_id"] for item in folders] == ["fd1", "folder_abc", "folder_nested"] assert path[0]["name"] == "工作库" assert is_end is True @@ -99,6 +104,81 @@ def test_ima_mount_rejects_disconnected(tmp_path) -> None: ) +def test_list_knowledge_bases_hydrates_via_get_knowledge_base( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from octop.infra.knowledge.ima import list_knowledge_bases + + calls: list[dict[str, object]] = [] + + def fake_openapi( + _creds: dict[str, object], path: str, body: dict[str, object] + ) -> dict[str, object]: + calls.append({"path": path, "body": body}) + if path.endswith("search_knowledge_base"): + assert body["limit"] == 20 + return {"info_list": [{"id": "kb1", "name": "工作"}], "is_end": True} + assert path.endswith("get_knowledge_base") + assert body == {"ids": ["kb1"]} + return { + "infos": { + "kb1": { + "id": "kb1", + "name": "工作库", + "description": "产品资料", + "cover_url": "https://x", + } + } + } + + monkeypatch.setattr("octop.infra.knowledge.ima.openapi_data", fake_openapi) + result = list_knowledge_bases({"client_id": "c", "api_key": "k"}, limit=50) + assert result["items"][0] == { + "id": "kb1", + "name": "工作库", + "cover_url": "https://x", + "description": "产品资料", + } + + +def test_list_knowledge_documents_search_uses_official_search( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from octop.infra.knowledge.ima import list_knowledge_documents + + captured: list[dict[str, object]] = [] + + def fake_openapi( + _creds: dict[str, object], path: str, body: dict[str, object] + ) -> dict[str, object]: + captured.append({"path": path, "body": body}) + return { + "info_list": [ + {"media_id": "keep", "title": "周报"}, + {"media_id": "folder_skip", "title": "归档"}, + ], + "is_end": True, + } + + monkeypatch.setattr("octop.infra.knowledge.ima.openapi_data", fake_openapi) + result = list_knowledge_documents( + {"client_id": "c", "api_key": "k"}, + "kb1", + query="周报", + folder_id="folder_ignored", + ) + assert captured[0]["path"] == "openapi/wiki/v1/search_knowledge" + assert captured[0]["body"] == { + "query": "周报", + "knowledge_base_id": "kb1", + "cursor": "", + } + assert [item["media_id"] for item in result["items"]] == ["keep"] + assert result["folders"][0]["folder_id"] == "folder_skip" + assert result["folder_id"] == "" + assert result["query"] == "周报" + + def test_search_selected_knowledge_filters_docs(monkeypatch: pytest.MonkeyPatch) -> None: captured: list[dict[str, object]] = [] @@ -110,6 +190,11 @@ def fake_openapi( "info_list": [ {"media_id": "keep", "title": "保留", "highlight_content": "命中保留"}, {"media_id": "skip", "title": "跳过", "highlight_content": "不该出现"}, + { + "media_id": "folder_skip", + "title": "归档", + "highlight_content": "文件夹不应作为文档命中", + }, ] } From d15eb9910379f497f96b94724c18a6d4cef731ac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 05:48:26 +0000 Subject: [PATCH 3/3] Match ima mount to live Agent Interface skill 1.1.10. The JS-rendered ima.qq.com/agent-interface page now ships ima-skills-1.1.10: send skill_version=1.1.10 and treat folder ids as the returned media_id instead of inventing a folder_ prefix. Co-authored-by: XYAI Labs --- CHANGELOG.md | 2 +- .../gateway/adapters/tencent_ima.py | 2 +- src/octop/infra/knowledge/ima.py | 46 ++++++++++++------- tests/unit/connectors/test_tencent_ima.py | 2 +- tests/unit/knowledge/test_knowledge_ima.py | 15 +++++- 5 files changed, 45 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a5016ae..4043066e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### 修复 -- ima 知识库挂接对齐官方 Agent Interface(https://ima.qq.com/agent-interface,ima-skill 1.1.9):用 Client ID / API Key 连接后,按 `search_knowledge_base` + `get_knowledge_base` 选知识库,按 `get_knowledge_list` / `search_knowledge` 自选文档;文件夹按官方 `folder_` `media_id` 浏览,选择会写入挂接。 +- ima 知识库挂接对齐官方 Agent Interface(https://ima.qq.com/agent-interface,落地页现发 ima-skill 1.1.10):用 Client ID / API Key 连接后,按 `search_knowledge_base` + `get_knowledge_base` 选知识库,按 `get_knowledge_list` / `search_knowledge` 自选文档;文件夹 ID 用接口返回的 `media_id`(不自造 `folder_` 前缀),选择会写入挂接。 - 模型页注册的本机 Ollama / GGUF 现在会出现在对话区模型选择器里,并走本机 `127.0.0.1:11434` 调用。注册会补齐 Ollama 供应商默认值、打开服务开关、热加载对话列表;Ollama 未启动时给出可操作错误,而不是静默失败。 - 对话模型选择器底部增加「模型管理」,跳到侧栏同一条「模型」路由(`/models`)。 - 组织页默认只显示本机 openXYOS 嵌入和顶部蓝色状态栏;「启用组织模块」「组织OS管理」在状态栏右下。互生长导入/导出、源码下载、循环时间线等收到「组织OS管理」。iframe 高度等于可见预览区,openXYOS 弹窗按 iframe 视口水平垂直居中。 diff --git a/src/octop/infra/connectors/gateway/adapters/tencent_ima.py b/src/octop/infra/connectors/gateway/adapters/tencent_ima.py index 475cea23..cb3a5ed1 100644 --- a/src/octop/infra/connectors/gateway/adapters/tencent_ima.py +++ b/src/octop/infra/connectors/gateway/adapters/tencent_ima.py @@ -464,7 +464,7 @@ def _headers(creds: dict[str, Any]) -> dict[str, str]: return { "ima-openapi-clientid": client_id, "ima-openapi-apikey": api_key, - "ima-openapi-ctx": "skill_version=1.1.9", + "ima-openapi-ctx": "skill_version=1.1.10", "Content-Type": "application/json", "User-Agent": "octop-connector/0.1", } diff --git a/src/octop/infra/knowledge/ima.py b/src/octop/infra/knowledge/ima.py index 2310949a..ba38e1c3 100644 --- a/src/octop/infra/knowledge/ima.py +++ b/src/octop/infra/knowledge/ima.py @@ -1,14 +1,15 @@ """Official IMA Agent Interface helpers for knowledge-base mount. -Auth and endpoints follow https://ima.qq.com/agent-interface and the -official ima-skill 1.1.9 Wiki OpenAPI (``openapi/wiki/v1``): +Auth and endpoints follow the live https://ima.qq.com/agent-interface page +(JS app) and the skill it ships (currently ima-skill 1.1.10): -- Headers ``ima-openapi-clientid`` / ``ima-openapi-apikey`` +- Headers ``ima-openapi-clientid`` / ``ima-openapi-apikey`` / + ``ima-openapi-ctx: skill_version=1.1.10`` - POST JSON to ``https://ima.qq.com/openapi/wiki/v1/*`` - List bases: ``search_knowledge_base`` (limit 1–20), then hydrate with ``get_knowledge_base`` - Browse docs: ``get_knowledge_list``; search docs: ``search_knowledge`` -- Folders in those lists use ``media_id`` prefixed ``folder_`` +- Folder id is the returned ``media_id`` (do not invent a ``folder_`` prefix) """ from __future__ import annotations @@ -165,7 +166,7 @@ def list_knowledge_bases( cursor: str = "", limit: int = 20, ) -> dict[str, Any]: - # Official ima-skill 1.1.9: search_knowledge_base limit is 1–20. + # Official ima-skill: search_knowledge_base limit is 1–20. limit = max(1, min(int(limit or 20), 20)) try: data = openapi_data( @@ -370,31 +371,42 @@ def _folder_item(item: dict[str, Any], folder_id: str) -> dict[str, Any]: def _folder_id_of(item: dict[str, Any]) -> str: + """Official 1.1.10: folder id is the returned media_id, not a made-up prefix.""" nested = item.get("folder_info") if isinstance(nested, dict): - nested_id = str(nested.get("folder_id") or nested.get("id") or "").strip() + nested_id = str( + nested.get("media_id") or nested.get("folder_id") or nested.get("id") or "" + ).strip() if nested_id: return nested_id - folder_id = str(item.get("folder_id") or item.get("id") or "").strip() - if folder_id.startswith("folder_") or ( - folder_id and not str(item.get("media_id") or "").strip() - ): - return folder_id media_id = str(item.get("media_id") or "").strip() - if media_id.startswith("folder_"): + if media_id and ( + media_id.startswith("folder_") + or item.get("is_folder") is True + or "file_number" in item + or "folder_number" in item + or isinstance(item.get("folder_info"), dict) + ): return media_id - return folder_id if folder_id.startswith("folder_") else "" + folder_id = str(item.get("folder_id") or item.get("id") or "").strip() + if folder_id and not media_id: + return folder_id + if folder_id.startswith("folder_"): + return folder_id + return media_id if media_id.startswith("folder_") else "" def _is_folder_entry(item: dict[str, Any]) -> bool: - """Official lists mix files and folders; folders use ``folder_`` ids.""" - media_id = str(item.get("media_id") or "").strip() - if media_id.startswith("folder_"): - return True + """Official lists mix files and folders; folder id is the item media_id.""" if item.get("is_folder") is True: return True if isinstance(item.get("folder_info"), dict): return True + if "file_number" in item or "folder_number" in item: + return True + media_id = str(item.get("media_id") or "").strip() + if media_id.startswith("folder_"): + return True folder_id = str(item.get("folder_id") or "").strip() return bool(folder_id and not media_id) diff --git a/tests/unit/connectors/test_tencent_ima.py b/tests/unit/connectors/test_tencent_ima.py index edf84848..dc168e43 100644 --- a/tests/unit/connectors/test_tencent_ima.py +++ b/tests/unit/connectors/test_tencent_ima.py @@ -78,7 +78,7 @@ def test_list_notebooks_defaults_cursor_zero(monkeypatch: pytest.MonkeyPatch) -> assert isinstance(headers, dict) assert headers["ima-openapi-clientid"] == "c" assert headers["ima-openapi-apikey"] == "k" - assert headers["ima-openapi-ctx"] == "skill_version=1.1.9" + assert headers["ima-openapi-ctx"] == "skill_version=1.1.10" def test_get_note_uses_plaintext(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/unit/knowledge/test_knowledge_ima.py b/tests/unit/knowledge/test_knowledge_ima.py index 3ef6e166..daa0a96b 100644 --- a/tests/unit/knowledge/test_knowledge_ima.py +++ b/tests/unit/knowledge/test_knowledge_ima.py @@ -56,8 +56,14 @@ def test_parse_knowledge_bases_and_docs() -> None: {"folder_id": "fd1", "name": "归档", "file_number": 2}, {"media_id": "folder_abc", "title": "设计文档"}, { - "folder_info": {"folder_id": "folder_nested", "name": "会议纪要"}, + "media_id": "kbpath-meeting", "title": "会议纪要", + "file_number": 12, + "folder_number": 0, + }, + { + "folder_info": {"folder_id": "folder_nested", "name": "归档"}, + "title": "归档", }, ], "current_path": [{"folder_id": "kb1", "name": "工作库"}], @@ -65,7 +71,12 @@ def test_parse_knowledge_bases_and_docs() -> None: } ) assert [item["media_id"] for item in docs] == ["m1"] - assert [item["folder_id"] for item in folders] == ["fd1", "folder_abc", "folder_nested"] + assert [item["folder_id"] for item in folders] == [ + "fd1", + "folder_abc", + "kbpath-meeting", + "folder_nested", + ] assert path[0]["name"] == "工作库" assert is_end is True