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