feat(skills,knowledge): 中文名补全与知识库沉淀支持可选大模型并明确大模型报错
技能中文名: - 单个「AI 生成」与「一键补充中文名」均可在请求体 model_name 选择大模型 - 一键补充改为仅管理员开放 - _generate_skill_name_zh 在模型自身失败(构建/超时/API 报错)时抛 SkillNameZhModelError,与模型返回无效名称区分;单个接口返回 502「大模型报错,无法补全技能中文名」,批量在 model_error 中带回代表性报错 知识库沉淀: - 对话保存到知识库 / 批量导入均新增可选 model_name(透传至 capture_thread → extract_thread_llm_multi) - 批量返回 llm_fallback 计数;模型异常时仍按既有逻辑回退规则抽取并落草稿, 前端明确提示「已改用规则抽取」(保存按钮保持后台异步) 前端新增 components/workspace/model-name-select.tsx 复用下拉(默认=系统配置模型) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8fbbfc8093
commit
96e0d695c0
11 files changed
+295
-83
No files matched your search
@@ -1,17 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { BookMarked } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useKnowledgeActions } from "@/core/knowledge/hooks";
|
||||
|
||||
import { ModelNameSelect } from "./model-name-select";
|
||||
import { useThread } from "./messages/context";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
export function KnowledgeSaveTrigger({ threadId }: { threadId: string }) {
|
||||
const { thread } = useThread();
|
||||
const { ingestThread } = useKnowledgeActions();
|
||||
// Optional model override for the extraction (null = configured default).
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
|
||||
if (thread.messages.length === 0) {
|
||||
return null;
|
||||
@@ -23,7 +27,7 @@ export function KnowledgeSaveTrigger({ threadId }: { threadId: string }) {
|
||||
try {
|
||||
await ingestThread.mutateAsync({
|
||||
threadId,
|
||||
payload: { background: true },
|
||||
payload: { background: true, model_name: model },
|
||||
});
|
||||
toast.success("已加入知识库保存队列,正在后台整理…");
|
||||
} catch (error) {
|
||||
@@ -32,16 +36,24 @@ export function KnowledgeSaveTrigger({ threadId }: { threadId: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content="保存到知识库(后台异步整理)">
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
onClick={() => void handleSave()}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ModelNameSelect
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={ingestThread.isPending}
|
||||
>
|
||||
<BookMarked />
|
||||
{ingestThread.isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
triggerClassName="h-8 w-[8.5rem]"
|
||||
/>
|
||||
<Tooltip content="保存到知识库(用所选大模型后台异步整理)">
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={ingestThread.isPending}
|
||||
>
|
||||
<BookMarked />
|
||||
{ingestThread.isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Sentinel for "use the backend's default/configured model" (no override). */
|
||||
const DEFAULT_VALUE = "__default__";
|
||||
|
||||
/**
|
||||
* Compact dropdown to optionally pick an LLM model for a one-off action
|
||||
* (skill-name generation, knowledge ingestion, …). The value is the model
|
||||
* `name`; `null` means "use the configured default" (no override sent).
|
||||
*/
|
||||
export function ModelNameSelect({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
triggerClassName,
|
||||
defaultLabel = "默认模型",
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (modelName: string | null) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
defaultLabel?: string;
|
||||
}) {
|
||||
const { models } = useModels();
|
||||
return (
|
||||
<Select
|
||||
value={value ?? DEFAULT_VALUE}
|
||||
onValueChange={(v) => onChange(v === DEFAULT_VALUE ? null : v)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
||||
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
|
||||
>
|
||||
<SelectValue placeholder={defaultLabel} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectItem value={DEFAULT_VALUE}>{defaultLabel}</SelectItem>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
{m.display_name || m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
@@ -163,6 +164,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const [viewMode, setViewMode] = useViewMode("skill");
|
||||
const reconcileMutation = useReconcileCustomSkills();
|
||||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||||
const [fillModel, setFillModel] = useState<string | null>(null);
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
||||
|
||||
@@ -249,13 +252,18 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
|
||||
const handleFillNames = async () => {
|
||||
try {
|
||||
const result = await fillNamesMutation.mutateAsync();
|
||||
const result = await fillNamesMutation.mutateAsync(fillModel);
|
||||
const okCount = result.generated.length;
|
||||
const failCount = result.failed.length;
|
||||
if (okCount === 0 && failCount === 0) {
|
||||
toast.info("所有技能均已配置中文名称");
|
||||
} else if (failCount === 0) {
|
||||
toast.success(`已为 ${okCount} 个技能补充中文名称`);
|
||||
} else if (result.model_error) {
|
||||
// Failures came from the model itself — say so explicitly.
|
||||
toast.warning(
|
||||
`已补充 ${okCount} 个,${failCount} 个因大模型报错失败:${result.model_error}`,
|
||||
);
|
||||
} else {
|
||||
toast.warning(`已补充 ${okCount} 个,${failCount} 个生成失败,可稍后重试或手动填写`);
|
||||
}
|
||||
@@ -354,24 +362,37 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
? t.settings.skills.rescanSkillsRunning
|
||||
: t.settings.skills.rescanSkills}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 border-violet-300 bg-violet-50 font-medium text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
|
||||
onClick={() => void handleFillNames()}
|
||||
disabled={
|
||||
fillNamesMutation.isPending ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
title="用大模型为所有尚未配置中文名称的技能自动生成中文名"
|
||||
>
|
||||
{fillNamesMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-4" />
|
||||
)}
|
||||
{fillNamesMutation.isPending ? "补充中…" : "一键补充中文名"}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<ModelNameSelect
|
||||
value={fillModel}
|
||||
onChange={setFillModel}
|
||||
disabled={
|
||||
fillNamesMutation.isPending ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
triggerClassName="h-8 w-[9.5rem]"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 border-violet-300 bg-violet-50 font-medium text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
|
||||
onClick={() => void handleFillNames()}
|
||||
disabled={
|
||||
fillNamesMutation.isPending ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
title="用所选大模型为所有尚未配置中文名称的技能自动生成中文名(仅管理员)"
|
||||
>
|
||||
{fillNamesMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-4" />
|
||||
)}
|
||||
{fillNamesMutation.isPending ? "补充中…" : "一键补充中文名"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import { SkillDisplayAdapterPanel } from "@/components/workspace/skill-display-adapter-panel";
|
||||
import { SkillFilePreviewDialog } from "@/components/workspace/skills/skill-file-preview-dialog";
|
||||
import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree";
|
||||
@@ -109,6 +110,8 @@ export function SkillDetailDialog({
|
||||
const [draft, setDraft] = useState("");
|
||||
const [editingNameZh, setEditingNameZh] = useState(false);
|
||||
const [nameZhDraft, setNameZhDraft] = useState("");
|
||||
// Optional model override for the "AI 生成" button (null = configured default).
|
||||
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"detail" | "display" | "source" | "files" | null
|
||||
>(null);
|
||||
@@ -194,7 +197,10 @@ export function SkillDetailDialog({
|
||||
async function handleGenerateNameZh() {
|
||||
if (!skill) return;
|
||||
try {
|
||||
const result = await generateNameZh.mutateAsync(skill.name);
|
||||
const result = await generateNameZh.mutateAsync({
|
||||
skillName: skill.name,
|
||||
modelName: nameZhModel,
|
||||
});
|
||||
setNameZhDraft(result.name_zh ?? "");
|
||||
toast.success("已生成中文名称");
|
||||
} catch (err) {
|
||||
@@ -237,17 +243,23 @@ export function SkillDetailDialog({
|
||||
if (event.key === "Escape") setEditingNameZh(false);
|
||||
}}
|
||||
placeholder={desensitize(skill?.name ?? skillName ?? "")}
|
||||
className="h-8 max-w-[18rem] flex-1 text-sm"
|
||||
className="h-8 max-w-[14rem] flex-1 text-sm"
|
||||
autoFocus
|
||||
disabled={generateNameZh.isPending || setNameZh.isPending}
|
||||
/>
|
||||
<ModelNameSelect
|
||||
value={nameZhModel}
|
||||
onChange={setNameZhModel}
|
||||
disabled={generateNameZh.isPending || setNameZh.isPending}
|
||||
triggerClassName="h-8 w-[8.5rem] shrink-0"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 shrink-0 gap-1 border-violet-300 bg-violet-50 px-2.5 text-xs font-medium text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
|
||||
onClick={() => void handleGenerateNameZh()}
|
||||
disabled={generateNameZh.isPending || setNameZh.isPending}
|
||||
title="用大模型自动生成中文名"
|
||||
title="用所选大模型自动生成中文名"
|
||||
>
|
||||
{generateNameZh.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
|
||||
@@ -101,6 +101,8 @@ export interface IngestThreadPayload {
|
||||
include_sources?: boolean;
|
||||
/** When true the backend sediments asynchronously and returns immediately. */
|
||||
background?: boolean;
|
||||
/** Optional model override for knowledge extraction (null = configured default). */
|
||||
model_name?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchIngestPayload {
|
||||
@@ -110,6 +112,8 @@ export interface BatchIngestPayload {
|
||||
include_sources?: boolean;
|
||||
skip_existing?: boolean;
|
||||
mode?: "summary" | "full";
|
||||
/** Optional model override for knowledge extraction (null = configured default). */
|
||||
model_name?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchIngestResult {
|
||||
@@ -118,7 +122,14 @@ export interface BatchIngestResult {
|
||||
created: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
items: Array<{ thread_id: string; note_id: string | null; status: string }>;
|
||||
/** Notes that fell back to rule-based extraction because the model failed. */
|
||||
llm_fallback?: number;
|
||||
items: Array<{
|
||||
thread_id: string;
|
||||
note_id: string | null;
|
||||
status: string;
|
||||
llm_fallback?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GraphExportResult {
|
||||
|
||||
@@ -439,6 +439,8 @@ export interface GenerateMissingSkillNameZhResponse {
|
||||
generated: SkillNameZhResponse[];
|
||||
failed: string[];
|
||||
skipped: number;
|
||||
/** Representative model error when some skills failed due to the LLM. */
|
||||
model_error?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -466,10 +468,15 @@ export async function setSkillNameZh(
|
||||
/** Ask the LLM to generate and persist a Chinese name for a single skill. */
|
||||
export async function generateSkillNameZh(
|
||||
skillName: string,
|
||||
modelName?: string | null,
|
||||
): Promise<SkillNameZhResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/name-zh/generate`,
|
||||
{ method: "POST" },
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_name: modelName ?? null }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to generate skill Chinese name");
|
||||
@@ -477,11 +484,17 @@ export async function generateSkillNameZh(
|
||||
return response.json() as Promise<SkillNameZhResponse>;
|
||||
}
|
||||
|
||||
/** One-click: generate Chinese names for every skill that lacks one. */
|
||||
export async function generateMissingSkillNameZh(): Promise<GenerateMissingSkillNameZhResponse> {
|
||||
/** One-click (admin): generate Chinese names for every skill that lacks one. */
|
||||
export async function generateMissingSkillNameZh(
|
||||
modelName?: string | null,
|
||||
): Promise<GenerateMissingSkillNameZhResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/name-zh/generate-missing`,
|
||||
{ method: "POST" },
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_name: modelName ?? null }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to bulk-generate skill Chinese names");
|
||||
|
||||
@@ -108,8 +108,9 @@ export function useSetSkillNameZh() {
|
||||
export function useGenerateSkillNameZh() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (skillName: string) => generateSkillNameZh(skillName),
|
||||
onSuccess: (_data, skillName) => {
|
||||
mutationFn: ({ skillName, modelName }: { skillName: string; modelName?: string | null }) =>
|
||||
generateSkillNameZh(skillName, modelName),
|
||||
onSuccess: (_data, { skillName }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
},
|
||||
@@ -119,7 +120,7 @@ export function useGenerateSkillNameZh() {
|
||||
export function useGenerateMissingSkillNameZh() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => generateMissingSkillNameZh(),
|
||||
mutationFn: (modelName?: string | null) => generateMissingSkillNameZh(modelName),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import {
|
||||
useKnowledgeActions,
|
||||
useKnowledgeDuplicates,
|
||||
@@ -241,6 +242,8 @@ function BatchImportDialog({
|
||||
const { batchIngest } = useKnowledgeActions();
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [skipExisting, setSkipExisting] = useState(true);
|
||||
// Optional model override for extraction (null = configured default).
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
|
||||
const handleRun = async () => {
|
||||
try {
|
||||
@@ -249,10 +252,18 @@ function BatchImportDialog({
|
||||
skip_existing: skipExisting,
|
||||
status: "approved",
|
||||
include_sources: true,
|
||||
model_name: model,
|
||||
});
|
||||
toast.success(
|
||||
`批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`,
|
||||
);
|
||||
const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`;
|
||||
if (result.llm_fallback && result.llm_fallback > 0) {
|
||||
// The model failed on some threads — they were saved via rule-based
|
||||
// extraction instead. Say so explicitly per the requirement.
|
||||
toast.warning(
|
||||
`${base};其中 ${result.llm_fallback} 条因大模型异常改用了规则抽取(草稿)`,
|
||||
);
|
||||
} else {
|
||||
toast.success(base);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "批量导入失败");
|
||||
@@ -289,6 +300,15 @@ function BatchImportDialog({
|
||||
className="size-4"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
提炼大模型
|
||||
<ModelNameSelect
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={batchIngest.isPending}
|
||||
triggerClassName="h-9 w-[11rem]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
|
||||
@@ -44,6 +44,7 @@ class IngestThreadRequest(BaseModel):
|
||||
default=False,
|
||||
description="When true, sediment the thread asynchronously in a background task and return immediately.",
|
||||
)
|
||||
model_name: str | None = Field(default=None, description="可选:指定用于知识提炼的大模型名称;留空使用默认配置")
|
||||
|
||||
|
||||
class BatchIngestRequest(BaseModel):
|
||||
@@ -53,6 +54,7 @@ class BatchIngestRequest(BaseModel):
|
||||
include_sources: bool = True
|
||||
skip_existing: bool = True
|
||||
mode: str = "summary"
|
||||
model_name: str | None = Field(default=None, description="可选:指定用于知识提炼的大模型名称;留空使用默认配置")
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
@@ -175,6 +177,7 @@ async def _ingest_thread_background(service, checkpointer, thread_id: str, body:
|
||||
include_sources=body.include_sources,
|
||||
reference_batches=batches,
|
||||
created_by=created_by,
|
||||
model_name=body.model_name,
|
||||
)
|
||||
except EmptyThreadError:
|
||||
logger.warning("Background knowledge ingest skipped: thread %s has no sediment-able content", sanitize_log_param(thread_id))
|
||||
@@ -224,6 +227,7 @@ async def ingest_thread(thread_id: str, body: IngestThreadRequest, request: Requ
|
||||
include_sources=body.include_sources,
|
||||
reference_batches=batches,
|
||||
created_by=get_effective_user_id(),
|
||||
model_name=body.model_name,
|
||||
)
|
||||
except EmptyThreadError:
|
||||
raise HTTPException(status_code=400, detail="Thread has no sediment-able content")
|
||||
@@ -245,6 +249,10 @@ async def ingest_threads_batch(body: BatchIngestRequest, request: Request) -> di
|
||||
|
||||
threads = await thread_store.search(limit=body.limit, offset=body.offset)
|
||||
created = skipped = failed = 0
|
||||
# Notes that landed via the rule-based fallback because the chosen model
|
||||
# produced nothing usable (timeout / API error / non-JSON). Surfaced so the
|
||||
# caller can tell the user "X 条因大模型异常改用了规则抽取".
|
||||
llm_fallback = 0
|
||||
items: list[dict[str, Any]] = []
|
||||
for meta in threads:
|
||||
thread_id = meta.get("thread_id")
|
||||
@@ -267,10 +275,13 @@ async def ingest_threads_batch(body: BatchIngestRequest, request: Request) -> di
|
||||
reference_batches=batches,
|
||||
created_by=get_effective_user_id(),
|
||||
skip_existing=body.skip_existing,
|
||||
model_name=body.model_name,
|
||||
)
|
||||
if result.get("llm_fallback"):
|
||||
llm_fallback += 1
|
||||
if result["created"]:
|
||||
created += 1
|
||||
items.append({"thread_id": thread_id, "note_id": result["note"]["id"], "status": "created"})
|
||||
items.append({"thread_id": thread_id, "note_id": result["note"]["id"], "status": "created", "llm_fallback": bool(result.get("llm_fallback"))})
|
||||
else:
|
||||
skipped += 1
|
||||
items.append({"thread_id": thread_id, "note_id": result["note"]["id"], "status": "skipped"})
|
||||
@@ -285,7 +296,7 @@ async def ingest_threads_batch(body: BatchIngestRequest, request: Request) -> di
|
||||
failed += 1
|
||||
items.append({"thread_id": thread_id, "note_id": None, "status": "failed"})
|
||||
|
||||
return {"ok": True, "processed": len(items), "created": created, "skipped": skipped, "failed": failed, "items": items}
|
||||
return {"ok": True, "processed": len(items), "created": created, "skipped": skipped, "failed": failed, "llm_fallback": llm_fallback, "items": items}
|
||||
|
||||
|
||||
@router.post("/ingest/search")
|
||||
|
||||
@@ -2366,6 +2366,15 @@ _SKILL_NAME_ZH_SYSTEM_PROMPT = (
|
||||
)
|
||||
|
||||
|
||||
class SkillNameZhModelError(Exception):
|
||||
"""The LLM call backing skill-name generation failed (a *model-side* problem).
|
||||
|
||||
Distinguishes a genuine model failure (timeout / API error / unbuildable
|
||||
model) — which must be surfaced to the user as "大模型报错导致无法补全" — from
|
||||
the model merely returning an unusable name (callers get ``None`` instead).
|
||||
"""
|
||||
|
||||
|
||||
def _clean_generated_name_zh(raw: str) -> str | None:
|
||||
"""Normalise an LLM-produced Chinese name: first line, strip quotes/punct, cap length."""
|
||||
if not raw:
|
||||
@@ -2388,24 +2397,45 @@ def _clean_generated_name_zh(raw: str) -> str | None:
|
||||
return text or None
|
||||
|
||||
|
||||
async def _generate_skill_name_zh(skill: Skill, app_config: AppConfig, model: Any = None) -> str | None:
|
||||
def _resolve_skill_name_zh_model_name(app_config: AppConfig, model_name: str | None) -> str | None:
|
||||
"""Pick the model name for skill-name generation.
|
||||
|
||||
An explicit ``model_name`` (user-selected in the UI) wins; otherwise fall
|
||||
back to the curator/security-scan model setting (itself falling back to the
|
||||
main model when unset).
|
||||
"""
|
||||
if model_name and model_name.strip():
|
||||
return model_name.strip()
|
||||
if app_config.skill_evolution and app_config.skill_evolution.curator:
|
||||
return app_config.skill_evolution.curator.model_name
|
||||
return None
|
||||
|
||||
|
||||
async def _generate_skill_name_zh(skill: Skill, app_config: AppConfig, model: Any = None, *, model_name: str | None = None) -> str | None:
|
||||
"""Generate a Chinese display name for ``skill`` via the LLM.
|
||||
|
||||
Reuses the curator/security-scan model setting (falls back to the main model).
|
||||
Returns None when the model is unconfigured, the call times out/fails, or the
|
||||
output cannot be normalised into a short Chinese name.
|
||||
An explicit ``model_name`` lets the caller pick the model; otherwise it
|
||||
reuses the curator/security-scan model setting (falls back to the main
|
||||
model). Returns ``None`` only when the model replies but the output cannot be
|
||||
normalised into a short Chinese name. Raises :class:`SkillNameZhModelError`
|
||||
when the model itself fails (cannot be built / times out / API error), so the
|
||||
caller can tell the user it was a *model* problem.
|
||||
"""
|
||||
user_prompt = (
|
||||
f"技能英文名:{skill.name}\n"
|
||||
f"功能描述:{skill.description or '(无描述)'}\n\n"
|
||||
"请为该技能起一个中文名称。"
|
||||
)
|
||||
try:
|
||||
from deerflow.models import create_chat_model
|
||||
if model is None:
|
||||
try:
|
||||
from deerflow.models import create_chat_model
|
||||
|
||||
if model is None:
|
||||
model_name = app_config.skill_evolution.curator.model_name if app_config.skill_evolution and app_config.skill_evolution.curator else None
|
||||
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=app_config)
|
||||
resolved = _resolve_skill_name_zh_model_name(app_config, model_name)
|
||||
model = create_chat_model(name=resolved, thinking_enabled=False, app_config=app_config)
|
||||
except Exception as e:
|
||||
logger.warning("Skill name_zh model build failed for %s", skill.name, exc_info=True)
|
||||
raise SkillNameZhModelError(str(e) or "无法初始化所选大模型") from e
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
model.ainvoke(
|
||||
[
|
||||
@@ -2416,12 +2446,13 @@ async def _generate_skill_name_zh(skill: Skill, app_config: AppConfig, model: An
|
||||
),
|
||||
timeout=30,
|
||||
)
|
||||
return _clean_generated_name_zh(str(getattr(response, "content", "") or ""))
|
||||
except asyncio.TimeoutError:
|
||||
except asyncio.TimeoutError as e:
|
||||
logger.warning("Skill name_zh LLM call timed out for %s", skill.name)
|
||||
except Exception:
|
||||
raise SkillNameZhModelError("大模型响应超时(30 秒)") from e
|
||||
except Exception as e:
|
||||
logger.warning("Skill name_zh LLM call failed for %s", skill.name, exc_info=True)
|
||||
return None
|
||||
raise SkillNameZhModelError(str(e) or "大模型调用失败") from e
|
||||
return _clean_generated_name_zh(str(getattr(response, "content", "") or ""))
|
||||
|
||||
|
||||
async def _persist_skill_name_zh(
|
||||
@@ -2475,6 +2506,13 @@ class SkillNameZhBulkResponse(BaseModel):
|
||||
generated: list[SkillNameZhResponse] = Field(default_factory=list, description="本次成功生成中文名的技能")
|
||||
failed: list[str] = Field(default_factory=list, description="生成失败的技能英文名")
|
||||
skipped: int = Field(default=0, description="已有中文名而被跳过的技能数")
|
||||
model_error: str | None = Field(default=None, description="若有技能因大模型报错而失败,这里给出代表性的报错信息")
|
||||
|
||||
|
||||
class GenerateSkillNameZhRequest(BaseModel):
|
||||
"""Optional body for skill-name generation: pick the model to use."""
|
||||
|
||||
model_name: str | None = Field(default=None, description="可选:指定用于生成的大模型名称;留空使用默认配置")
|
||||
|
||||
|
||||
def _can_edit_skill_name_zh(skill: Skill, record: dict[str, Any] | None, user_id: str, *, is_admin: bool) -> bool:
|
||||
@@ -2521,15 +2559,17 @@ async def set_skill_name_zh(
|
||||
"/skills/{skill_name}/name-zh/generate",
|
||||
response_model=SkillNameZhResponse,
|
||||
summary="Generate Skill Chinese Name",
|
||||
description="用大模型为该技能生成一个中文名称并保存到数据库。",
|
||||
description="用大模型为该技能生成一个中文名称并保存到数据库。可在请求体里通过 model_name 指定使用的大模型。",
|
||||
)
|
||||
async def generate_skill_name_zh(
|
||||
skill_name: str,
|
||||
request: Request,
|
||||
body: GenerateSkillNameZhRequest | None = None,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillNameZhResponse:
|
||||
try:
|
||||
body = body or GenerateSkillNameZhRequest()
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
user_id = _current_user_id(request)
|
||||
is_admin = _is_admin_user(request)
|
||||
@@ -2537,9 +2577,12 @@ async def generate_skill_name_zh(
|
||||
skill = next((s for s in skills if s.name == skill_name), None)
|
||||
if skill is None:
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||
name_zh = await _generate_skill_name_zh(skill, config)
|
||||
try:
|
||||
name_zh = await _generate_skill_name_zh(skill, config, model_name=body.model_name)
|
||||
except SkillNameZhModelError as e:
|
||||
raise HTTPException(status_code=502, detail=f"大模型报错,无法补全技能中文名:{e}")
|
||||
if not name_zh:
|
||||
raise HTTPException(status_code=502, detail="模型未能生成有效的中文名称,请稍后重试或手动填写")
|
||||
raise HTTPException(status_code=502, detail="大模型未能生成有效的中文名称,请稍后重试或手动填写")
|
||||
stored = await _persist_skill_name_zh(skill, name_zh, skill_store, user_id, is_admin=is_admin)
|
||||
return SkillNameZhResponse(name=skill_name, name_zh=stored)
|
||||
except HTTPException:
|
||||
@@ -2553,26 +2596,27 @@ async def generate_skill_name_zh(
|
||||
"/skills/name-zh/generate-missing",
|
||||
response_model=SkillNameZhBulkResponse,
|
||||
summary="Bulk Generate Missing Skill Chinese Names",
|
||||
description="一键为库中所有尚未配置中文名称、且当前用户有权编辑的技能生成中文名(存数据库)。",
|
||||
description="管理员一键为库中所有尚未配置中文名称的技能生成中文名(存数据库)。可在请求体里通过 model_name 指定使用的大模型。",
|
||||
)
|
||||
async def generate_missing_skill_names_zh(
|
||||
request: Request,
|
||||
body: GenerateSkillNameZhRequest | None = None,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillNameZhBulkResponse:
|
||||
try:
|
||||
from deerflow.models import create_chat_model
|
||||
|
||||
body = body or GenerateSkillNameZhRequest()
|
||||
user_id = _current_user_id(request)
|
||||
is_admin = _is_admin_user(request)
|
||||
# 一键补充技能中文名仅对管理员开放。
|
||||
if not is_admin:
|
||||
raise HTTPException(status_code=403, detail="仅管理员可一键补充技能中文名")
|
||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
|
||||
# Existing name_zh values come from the DB. Admins see every row; others
|
||||
# see only the rows visible to them (owned / published / built-in).
|
||||
if is_admin:
|
||||
records = {r["name"]: r for r in await skill_store.list_all()}
|
||||
else:
|
||||
records = {r["name"]: r for r in await skill_store.list_visible(user_id)}
|
||||
# Admin sees every row, so existing name_zh values come from the full set.
|
||||
records = {r["name"]: r for r in await skill_store.list_all()}
|
||||
|
||||
pending: list[Skill] = []
|
||||
skipped = 0
|
||||
@@ -2581,32 +2625,36 @@ async def generate_missing_skill_names_zh(
|
||||
if record and record.get("name_zh"):
|
||||
skipped += 1
|
||||
continue
|
||||
# Skip skills the caller is not allowed to annotate (no error — bulk
|
||||
# is best-effort over what the user can edit).
|
||||
if not _can_edit_skill_name_zh(skill, record, user_id, is_admin=is_admin):
|
||||
continue
|
||||
pending.append(skill)
|
||||
|
||||
generated: list[SkillNameZhResponse] = []
|
||||
failed: list[str] = []
|
||||
model_error: str | None = None
|
||||
if pending:
|
||||
# Build the model once and reuse it for every skill.
|
||||
# Build the model once and reuse it for every skill. A build failure
|
||||
# is a model problem — fail fast with a clear message rather than
|
||||
# silently producing zero results.
|
||||
try:
|
||||
model_name = config.skill_evolution.curator.model_name if config.skill_evolution and config.skill_evolution.curator else None
|
||||
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=config)
|
||||
except Exception:
|
||||
resolved_name = _resolve_skill_name_zh_model_name(config, body.model_name)
|
||||
model = create_chat_model(name=resolved_name, thinking_enabled=False, app_config=config)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to build model for bulk name_zh generation", exc_info=True)
|
||||
model = None
|
||||
raise HTTPException(status_code=502, detail=f"大模型报错,无法补全技能中文名:{str(e) or '无法初始化所选大模型'}")
|
||||
|
||||
# Generate concurrently with a small bound to avoid overloading the model.
|
||||
semaphore = asyncio.Semaphore(4)
|
||||
|
||||
async def _one(skill: Skill) -> tuple[Skill, str | None]:
|
||||
async def _one(skill: Skill) -> tuple[Skill, str | None, str | None]:
|
||||
async with semaphore:
|
||||
return skill, await _generate_skill_name_zh(skill, config, model=model)
|
||||
try:
|
||||
return skill, await _generate_skill_name_zh(skill, config, model=model), None
|
||||
except SkillNameZhModelError as e:
|
||||
return skill, None, str(e)
|
||||
|
||||
results = await asyncio.gather(*[_one(s) for s in pending])
|
||||
for skill, name_zh in results:
|
||||
for skill, name_zh, err in results:
|
||||
if err and model_error is None:
|
||||
model_error = err
|
||||
if not name_zh:
|
||||
failed.append(skill.name)
|
||||
continue
|
||||
@@ -2617,7 +2665,7 @@ async def generate_missing_skill_names_zh(
|
||||
# Permission / persistence issue for this one skill — skip it.
|
||||
failed.append(skill.name)
|
||||
|
||||
return SkillNameZhBulkResponse(generated=generated, failed=failed, skipped=skipped)
|
||||
return SkillNameZhBulkResponse(generated=generated, failed=failed, skipped=skipped, model_error=model_error)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -222,9 +222,13 @@ class KnowledgeService:
|
||||
reference_batches: list[dict[str, Any]] | None = None,
|
||||
created_by: str | None = None,
|
||||
skip_existing: bool = False,
|
||||
model_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Sediment a thread into a knowledge note.
|
||||
|
||||
``model_name`` optionally overrides the configured extraction model for
|
||||
this one capture (falls back to ``config.extract_model_name``).
|
||||
|
||||
Returns ``{"note": <note>, "created": bool}``. Raises
|
||||
:class:`EmptyThreadError` when there is nothing worth keeping.
|
||||
"""
|
||||
@@ -269,7 +273,7 @@ class KnowledgeService:
|
||||
project_name=self.project_name,
|
||||
sources=sources,
|
||||
max_messages=self.config.max_thread_messages,
|
||||
model_name=self.config.extract_model_name,
|
||||
model_name=model_name or self.config.extract_model_name,
|
||||
max_input_chars=self.config.extract_max_input_chars,
|
||||
allow_multi=self.config.multi_note_enabled,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user