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:
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}
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user