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:
吉鑫升andClaude Opus 4.8 committed 2026-06-10 11:23:01 +08:00
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" />