feat: 智能体 md 产物一键发布到外部知识入库接口

- 智能体新增「入库模板名」(template_name) 配置,持久化到 config.yaml
  并经 AgentCreate/Update/Response 回读(NewAgentPage / AgentDetailPanel)
- 新增 POST /api/knowledge-ingest/publish:把 md 标题+全文发到 knowes,
  标题字段先「名称」失败再用同一 nodeName 换「标题」重试一次
- md 产物预览右上角新增「发布入库」按钮(仅智能体会话且配置了模板名时显示)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-12 16:38:04 +08:00
1 parent d9d5780ea7
commit 229830ee89
10 files changed
+371 -3

No files matched your search

@@ -15,6 +15,7 @@ import {
SparklesIcon,
SquarePenIcon,
Trash2Icon,
UploadCloudIcon,
UserRoundIcon,
XIcon,
} from "lucide-react";
@@ -96,6 +97,7 @@ interface AgentDraft {
skills: string[];
published: boolean;
featured: boolean;
templateName: string;
}
function draftFromAgent(agent: Agent): AgentDraft {
@@ -107,6 +109,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
skills: agent.skills ?? [],
published: agent.published,
featured: agent.featured_order != null,
templateName: agent.template_name ?? "",
};
}
@@ -305,6 +308,8 @@ export function AgentDetailPanel({
if (draft.description !== (agent.description ?? ""))
request.description = draft.description;
if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul;
if (draft.templateName.trim() !== (agent.template_name ?? ""))
request.template_name = draft.templateName.trim();
if (nextPublished !== agent.published) request.published = nextPublished;
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
request.skills = draft.skills;
@@ -546,9 +551,31 @@ export function AgentDetailPanel({
)}
</div>
{/* 入库模板名 — view mode shows a hint row when configured. */}
{!editing && agent.template_name?.trim() && (
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
<UploadCloudIcon className="size-3 shrink-0" />
入库模板:{desensitize(agent.template_name)}
</div>
)}
{/* Publish / featured toggles — only meaningful in edit mode. */}
{editing && draft && (
<div className="mt-3 space-y-2">
<div className="bg-background/60 space-y-1 rounded-lg border px-3 py-2">
<p className="text-sm font-medium">入库模板名</p>
<Input
value={draft.templateName}
onChange={(e) =>
setDraft({ ...draft, templateName: e.currentTarget.value })
}
placeholder="例如:临时策略库(留空表示不支持入库)"
className="h-8 text-sm"
/>
<p className="text-muted-foreground text-xs">
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
</p>
</div>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">公开发布</p>
@@ -11,8 +11,10 @@ import {
PackageIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
UploadCloudIcon,
XIcon,
} from "lucide-react";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
@@ -35,6 +37,8 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
@@ -86,6 +90,13 @@ function extractTextFromChildren(children: ReactNode): string {
return "";
}
/** 取 md 标题用于入库:优先文中第一个标题行,否则退回文件名(去扩展名)。 */
function extractMarkdownTitle(content: string, filepath: string): string {
const heading = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/m.exec(content);
if (heading?.[1]) return heading[1].trim();
return getFileName(filepath).replace(/\.(md|markdown)$/i, "");
}
function findScrollableArtifactElement(root: HTMLElement | null) {
if (!root) {
return null;
@@ -439,6 +450,50 @@ export function ArtifactFileDetail({
updateArtifact,
]);
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
const { agent_id: routeAgentId } = useParams<{ agent_id?: string }>();
const { agent: routeAgent } = useAgent(routeAgentId ?? null);
const ingestTemplateName = routeAgent?.template_name?.trim() ?? "";
const canPublishToKnowledge =
!!routeAgentId &&
!!ingestTemplateName &&
language === "markdown" &&
!isSkillFile &&
!isMock;
const [isPublishingToKnowledge, setIsPublishingToKnowledge] = useState(false);
const handlePublishToKnowledge = useCallback(async () => {
if (!routeAgentId || isPublishingToKnowledge) {
return;
}
const source = (displayContent ?? "").trim();
if (!source) {
toast.error("Markdown 内容为空,无法发布入库");
return;
}
setIsPublishingToKnowledge(true);
try {
const result = await publishArtifactToKnowledge({
agent_id: routeAgentId,
title: extractMarkdownTitle(source, filepath),
content: source,
});
if (result.success) {
toast.success(`已发布入库:${result.node_name}`);
} else {
const last = result.attempts[result.attempts.length - 1];
toast.error(
`发布入库失败:${last?.error ?? last?.response_text ?? "接口返回失败"}`,
);
}
} catch (error) {
console.error("Failed to publish artifact to knowledge base:", error);
toast.error(error instanceof Error ? error.message : "发布入库失败");
} finally {
setIsPublishingToKnowledge(false);
}
}, [displayContent, filepath, isPublishingToKnowledge, routeAgentId]);
const [isExportingWord, setIsExportingWord] = useState(false);
const handleDownloadAsWord = useCallback(async () => {
const source = (displayContent ?? "").trim();
@@ -591,6 +646,15 @@ export function ArtifactFileDetail({
</div>
<div className="flex items-center justify-end gap-2">
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
{canPublishToKnowledge && (
<ArtifactAction
icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon}
label="发布入库"
tooltip={`发布到知识库(模板:${ingestTemplateName})`}
disabled={isPublishingToKnowledge || !displayContent}
onClick={() => void handlePublishToKnowledge()}
/>
)}
{canEditArtifact && (
<ArtifactAction
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
+38
View File
@@ -335,6 +335,44 @@ export async function adminSyncAgentSkills(): Promise<AgentSkillsSyncResult> {
return res.json() as Promise<AgentSkillsSyncResult>;
}
export interface KnowledgeIngestAttempt {
title_field: string;
status_code: number | null;
response_text: string;
success: boolean;
error: string | null;
}
export interface KnowledgeIngestPublishResult {
success: boolean;
template_name: string;
node_name: string;
batch_number: string;
attempts: KnowledgeIngestAttempt[];
}
/** 把一份 md 产物(标题 + 全文)发布到外部知识入库接口。后端会先按
* 「名称」字段映射发送,失败后用同一 nodeName 换「标题」字段重试一次。 */
export async function publishArtifactToKnowledge(request: {
agent_id: string;
title: string;
content: string;
}): Promise<KnowledgeIngestPublishResult> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/knowledge-ingest/publish`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
},
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `发布入库失败: ${res.statusText}`);
}
return res.json() as Promise<KnowledgeIngestPublishResult>;
}
export async function checkAgentName(
name: string,
): Promise<{ available: boolean; name: string }> {
+6
View File
@@ -55,6 +55,9 @@ export interface Agent {
* New agents store `{title, prompt}`; legacy agents may still return `string[]`.
* Read via {@link normalizeAgentQuestions}. */
common_questions?: (string | AgentQuestion)[] | null;
/** 入库模板名(外部知识入库接口的 templateName)。配置后该智能体会话中的
* Markdown 产物右上角出现"发布入库"按钮。空/缺省 = 不支持入库。 */
template_name?: string | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -87,6 +90,7 @@ export interface CreateAgentRequest {
model?: string | null;
tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
template_name?: string | null;
}
export interface UpdateAgentRequest {
@@ -100,4 +104,6 @@ export interface UpdateAgentRequest {
model?: string | null;
tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
/** 入库模板名;传空字符串表示清除。 */
template_name?: string | null;
}
+19
View File
@@ -82,6 +82,7 @@ export default function NewAgentPage() {
const [soulInput, setSoulInput] = useState("");
const [modelInput, setModelInput] = useState<string>("");
const [chatModelInput, setChatModelInput] = useState<string>("");
const [templateNameInput, setTemplateNameInput] = useState("");
const [formError, setFormError] = useState("");
const [agentId, setAgentId] = useState("");
const [agent, setAgent] = useState<Agent | null>(null);
@@ -228,6 +229,7 @@ export default function NewAgentPage() {
soul: soulInput,
skills: selectedSkills,
model: modelOverride,
template_name: templateNameInput.trim(),
});
setAgent(updated);
return updated;
@@ -248,6 +250,7 @@ export default function NewAgentPage() {
soul: soulInput,
skills: selectedSkills,
model: modelOverride,
template_name: templateNameInput.trim(),
});
setAgentId(created.id);
setAgent(created);
@@ -277,6 +280,7 @@ export default function NewAgentPage() {
modelInput,
selectedSkills,
soulInput,
templateNameInput,
t.agents.nameStepCheckError,
t.agents.nameStepNetworkError,
validateForm,
@@ -549,6 +553,21 @@ export default function NewAgentPage() {
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">入库模板名</label>
<Input
placeholder="例如:临时策略库(留空表示不支持入库)"
value={templateNameInput}
onChange={(event) =>
setTemplateNameInput(event.currentTarget.value)
}
/>
<p className="text-muted-foreground text-xs">
配置后,该智能体对话中生成的 Markdown
文件右上角会出现"发布入库"按钮,可一键发送到知识入库接口。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">SOUL.md</label>
<Textarea
@@ -252,6 +252,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/entity/source_type/status + pagination; `entity` is an exact entity-name filter resolved via the note→entity relation graph, combinable with `keyword` — powers the related-notes dialog's server-side search + paging); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
| **Knowledge Ingest** (`/api/knowledge-ingest`) | 外部知识入库(knowes)。`POST /publish` `{agent_id, title, content}` - 把一份 md 产物发布到外部入库接口。仅当该智能体 `config.yaml` 配置了 `template_name`(入库模板名,经 `AgentCreateRequest`/`AgentUpdateRequest.template_name` 持久化、`AgentResponse.template_name` 回读)才允许;否则 400。字段映射先按「名称/内容」发送,判定失败后用 **同一个 nodeName**(`{md标题}-{时间戳}`)把标题字段换成「标题」重试一次;`batchNumber = md{YYYYMMDD}`。成功判定看响应 JSON 的 `success`/`code`/`status`/`state` 字段,无法解析时退化为 HTTP 2xx。目标 URL 默认 UAT 地址,可用环境变量 `KNOWES_INGEST_URL` 覆盖。全程 `[knowledge-ingest]` 前缀日志(请求载荷、每次响应、最终结果)。前端入口:md 产物预览右上角"发布入库"按钮(`artifact-file-detail.tsx`,仅智能体会话路由且配置了模板名时显示)。Tests: `tests/test_knowledge_ingest.py` |
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
@@ -40,6 +40,7 @@ from app.gateway.routers import (
html_page_favorites,
intent,
knowledge,
knowledge_ingest,
llm_metrics,
mcp,
memory,
@@ -1341,6 +1342,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Product knowledge base API is mounted at /api/knowledge
app.include_router(knowledge.router)
# External knowledge-ingest (发布 md 产物到外部知识库) at /api/knowledge-ingest
app.include_router(knowledge_ingest.router)
# System settings API is mounted at /api/system-settings
app.include_router(system_settings.router)
@@ -59,6 +59,11 @@ class AgentResponse(BaseModel):
description="Recommended questions shown on the agent chat home / sidebar (常用问题). Each item is {title, prompt}; legacy string lists are coerced.",
)
template_name: str | None = Field(
default=None,
description="入库模板名 (external knowledge-ingest templateName). When set, markdown artifacts produced in this agent's chats can be published to the external knowledge-ingest API.",
)
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
featured_order: int | None = Field(
@@ -109,6 +114,7 @@ class AgentCreateRequest(BaseModel):
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="入库模板名 persisted to config.yaml. Enables publishing markdown artifacts to the external knowledge-ingest API.")
class AgentUpdateRequest(BaseModel):
@@ -124,6 +130,7 @@ class AgentUpdateRequest(BaseModel):
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="Updated 入库模板名 (config.yaml). Empty string clears it.")
class AgentSelectorResponse(BaseModel):
@@ -228,6 +235,7 @@ def _write_agent_config(
model: str | None = None,
tool_groups: list[str] | None = None,
common_questions: list[CommonQuestion] | None = None,
template_name: str | None = None,
) -> None:
agent_dir = get_paths().agent_dir(agent_id)
agent_dir.mkdir(parents=True, exist_ok=True)
@@ -254,13 +262,15 @@ def _write_agent_config(
config_data["common_questions"] = [
q.model_dump() for q in normalized if q.prompt.strip()
]
if template_name is not None and template_name.strip():
config_data["template_name"] = template_name.strip()
with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
def _load_config_extras(agent_id: str) -> dict[str, Any]:
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None}
try:
cfg = load_agent_config(agent_id)
except (FileNotFoundError, ValueError):
@@ -272,6 +282,7 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]:
"model": cfg.model,
"tool_groups": cfg.tool_groups,
"common_questions": cfg.common_questions,
"template_name": cfg.template_name,
}
@@ -432,7 +443,7 @@ def _record_to_response(
can happen lazily when the agent is actually opened.
"""
soul: str | None = None
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None}
if include_extras:
_ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"])
@@ -455,6 +466,7 @@ def _record_to_response(
tool_groups=extras["tool_groups"],
skills=extras["skills"],
common_questions=extras.get("common_questions"),
template_name=extras.get("template_name"),
featured_order=record.get("featured_order"),
)
@@ -777,6 +789,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
model=request.model,
tool_groups=request.tool_groups,
common_questions=request.common_questions,
template_name=request.template_name,
)
_write_agent_soul(agent_id, request.soul)
@@ -856,8 +869,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
next_model = request.model if "model" in fields_set else existing_extras["model"]
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"]
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions"))
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name"))
if config_dirty:
_write_agent_config(
agent_id,
@@ -867,6 +881,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
model=next_model,
tool_groups=next_tool_groups,
common_questions=next_common_questions,
template_name=next_template_name,
)
# Keep the listing cache in lock-step with config.yaml, but only
# when the cached fields were actually touched — a partial update
@@ -0,0 +1,191 @@
"""外部知识入库接口(发布 Markdown 产物到 knowes 知识库)。
智能体 config.yaml 配置了 ``template_name`` 后,该智能体会话中生成的 Markdown
产物可以通过 ``POST /api/knowledge-ingest/publish`` 一键入库。
入库目标接口的字段名不固定:标题字段可能叫「名称」也可能叫「标题」,因此先按
「名称」发送一次,若判定失败则用 **同一个 nodeName** 把标题字段换成「标题」再
发一次。nodeName 是入库记录的唯一标识,取「md 标题 + 时间戳」。
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from deerflow.config.agents_config import load_agent_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/knowledge-ingest", tags=["knowledge-ingest"])
# 入库接口地址,可用环境变量覆盖(如切换 UAT/生产)。
KNOWES_INGEST_URL = os.environ.get(
"KNOWES_INGEST_URL",
"https://ch1.b.uat.4.cn/knowes/api/knowledge-outer/add",
)
_REQUEST_TIMEOUT_SECONDS = 30.0
# 标题字段在目标模板里可能叫「名称」或「标题」,按此顺序逐个尝试。
_TITLE_FIELD_CANDIDATES = ("名称", "标题")
_CONTENT_FIELD = "内容"
class KnowledgeIngestPublishRequest(BaseModel):
"""Request body for publishing one markdown artifact."""
agent_id: str = Field(..., description="智能体 id,用于读取其 config.yaml 中的 template_name")
title: str = Field(..., description="md 标题(一级标题或文件名)")
content: str = Field(..., description="md 正文全文")
class KnowledgeIngestAttempt(BaseModel):
"""One delivery attempt against the ingest API."""
title_field: str = Field(..., description="本次尝试使用的标题字段名(名称/标题)")
status_code: int | None = Field(default=None, description="HTTP 状态码;请求异常时为 null")
response_text: str = Field(default="", description="入库接口原始响应(截断到 2000 字符)")
success: bool = False
error: str | None = Field(default=None, description="请求层异常信息(网络/超时等)")
class KnowledgeIngestPublishResponse(BaseModel):
success: bool
template_name: str
node_name: str = Field(..., description="入库唯一标识(md 标题 + 时间戳),两次尝试共用")
batch_number: str
attempts: list[KnowledgeIngestAttempt]
def _judge_success(status_code: int, response_text: str) -> bool:
"""判断入库接口响应是否表示成功。
接口未提供正式契约:优先看响应 JSON 中常见的 success/code/status/state
字段;JSON 不可解析或没有这类字段时,退化为按 HTTP 2xx 判定。
"""
if not (200 <= status_code < 300):
return False
try:
data = json.loads(response_text)
except (ValueError, TypeError):
return True
if not isinstance(data, dict):
return True
if isinstance(data.get("success"), bool):
return data["success"]
for key in ("code", "status", "state"):
if key in data:
return str(data[key]).lower() in {"0", "200", "success", "ok", "true"}
return True
def _build_payload(template_name: str, batch_number: str, node_name: str, title_field: str, title: str, content: str) -> dict[str, Any]:
return {
"templateName": template_name,
"batchNumber": batch_number,
"list": [
{
"rec": {"nodeName": node_name},
"data": [
{"fieldName": title_field, "value": title},
{"fieldName": _CONTENT_FIELD, "value": content},
],
}
],
}
@router.post("/publish", response_model=KnowledgeIngestPublishResponse, summary="Publish markdown artifact to external knowledge base")
async def publish_to_knowledge_base(request: KnowledgeIngestPublishRequest) -> KnowledgeIngestPublishResponse:
title = request.title.strip()
content = request.content.strip()
if not title:
raise HTTPException(status_code=422, detail="标题不能为空")
if not content:
raise HTTPException(status_code=422, detail="内容为空,无法入库")
try:
agent_cfg = load_agent_config(request.agent_id)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
template_name = (agent_cfg.template_name or "").strip() if agent_cfg else ""
if not template_name:
raise HTTPException(status_code=400, detail=f"智能体 '{request.agent_id}' 未配置入库模板名(template_name),无法发布")
now = datetime.now()
# nodeName 是入库记录的唯一标识:md 标题 + 时间戳。两次尝试必须共用同一个
# nodeName,避免第一次实际已入库时第二次产生重复记录。
node_name = f"{title}-{now.strftime('%Y%m%d%H%M%S')}"
batch_number = f"md{now.strftime('%Y%m%d')}"
logger.info(
"[knowledge-ingest] 开始入库: agent=%s template=%s nodeName=%s batchNumber=%s title=%r content_len=%d url=%s",
request.agent_id,
template_name,
node_name,
batch_number,
title,
len(content),
KNOWES_INGEST_URL,
)
attempts: list[KnowledgeIngestAttempt] = []
headers = {"Content-Type": "application/json", "User-Agent": "DeerFlow/1.0"}
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT_SECONDS) as client:
for title_field in _TITLE_FIELD_CANDIDATES:
payload = _build_payload(template_name, batch_number, node_name, title_field, title, content)
logger.info(
"[knowledge-ingest] 第 %d 次发送 (标题字段=%s): %s",
len(attempts) + 1,
title_field,
json.dumps({**payload, "list": [{**payload["list"][0], "data": [{"fieldName": d["fieldName"], "value": d["value"][:200]} for d in payload["list"][0]["data"]]}]}, ensure_ascii=False),
)
try:
response = await client.post(KNOWES_INGEST_URL, json=payload, headers=headers)
except httpx.HTTPError as exc:
logger.error("[knowledge-ingest] 第 %d 次发送请求异常 (标题字段=%s): %s", len(attempts) + 1, title_field, exc)
attempts.append(KnowledgeIngestAttempt(title_field=title_field, error=str(exc)))
continue
text = response.text or ""
success = _judge_success(response.status_code, text)
logger.info(
"[knowledge-ingest] 第 %d 次响应 (标题字段=%s): status=%d success=%s body=%s",
len(attempts) + 1,
title_field,
response.status_code,
success,
text[:2000],
)
attempts.append(
KnowledgeIngestAttempt(
title_field=title_field,
status_code=response.status_code,
response_text=text[:2000],
success=success,
)
)
if success:
break
logger.warning("[knowledge-ingest] 第 %d 次入库判定失败 (标题字段=%s),准备换字段重试", len(attempts), title_field)
overall = any(a.success for a in attempts)
if overall:
logger.info("[knowledge-ingest] 入库成功: nodeName=%s 使用标题字段=%s", node_name, next(a.title_field for a in attempts if a.success))
else:
logger.error("[knowledge-ingest] 入库失败(全部 %d 次尝试均未成功): nodeName=%s", len(attempts), node_name)
return KnowledgeIngestPublishResponse(
success=overall,
template_name=template_name,
node_name=node_name,
batch_number=batch_number,
attempts=attempts,
)
@@ -100,6 +100,9 @@ class AgentConfig(BaseModel):
# Accepts both the legacy ``list[str]`` shape and the new ``{title, prompt}``
# objects (see CommonQuestion).
common_questions: list[CommonQuestion] | None = None
# 入库模板名(外部知识入库接口的 templateName)。配置后该智能体会话中的
# Markdown 产物可一键发布到外部入库接口;None/空 = 不支持入库。
template_name: str | None = None
def load_agent_config(agent_id: str | None) -> AgentConfig | None: