feat: 知识库实体筛选、技能白名单收紧、嵌入主题与提问组件优化
- 知识库笔记列表支持按实体精确筛选(路由/服务/仓储层透传,关系图索引查询) - 自定义智能体技能白名单在 skill_view 与 skill_manage 的 list/view 别名上同样生效; config 未声明 skills 时视为无可见技能 - 嵌入圆桌主题解析默认深蓝,强制主题期间不再写 localStorage 覆盖主站选择 - 智能体提问建议拆出 agent-question-chips 组件,编辑器与详情面板联动优化 - 新增 qa-client 问答测试脚本 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
11bc442f10
commit
28098b3155
22 files changed
+500
-126
No files matched your search
@@ -1,5 +1,15 @@
|
||||
import { FileText, Hash, Loader2, Sparkles } from "lucide-react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Hash,
|
||||
Loader2,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -7,21 +17,34 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useKnowledgeNotes } from "@/core/knowledge/hooks";
|
||||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||||
|
||||
import { SOURCE_TYPE_LABELS, formatDateTime } from "./wikiUtils";
|
||||
|
||||
/** What was clicked: a tag (exact filter) or an entity (keyword match in body). */
|
||||
/** What was clicked: a tag (exact filter) or an entity (relation-graph match). */
|
||||
export type RelatedNotesFilter =
|
||||
| { kind: "tag"; value: string }
|
||||
| { kind: "entity"; value: string };
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function useDebounced<T>(value: T, delay = 300): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setDebounced(value), delay);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a tag / entity chip → list every other note that shares it.
|
||||
* Tags use the backend's exact `tag` filter; entities fall back to a keyword
|
||||
* match (entity names appear verbatim in titles/summaries/bodies).
|
||||
* Server-side keyword search + pagination so huge graphs stay usable:
|
||||
* tags use the backend's exact `tag` filter, entities the exact `entity`
|
||||
* filter (note→entity relations), both combinable with `keyword`.
|
||||
*/
|
||||
export function RelatedNotesDialog({
|
||||
filter,
|
||||
@@ -34,17 +57,33 @@ export function RelatedNotesDialog({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (noteId: string) => void;
|
||||
}) {
|
||||
const params =
|
||||
filter?.kind === "tag"
|
||||
? { tag: filter.value, limit: 50 }
|
||||
: filter?.kind === "entity"
|
||||
? { keyword: filter.value, limit: 50 }
|
||||
: { limit: 0 };
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounced(search.trim());
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
// New chip or new search term → back to the first page.
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [filter?.kind, filter?.value, debouncedSearch]);
|
||||
// Reset the search box whenever a different chip is opened.
|
||||
useEffect(() => {
|
||||
setSearch("");
|
||||
}, [filter?.kind, filter?.value]);
|
||||
|
||||
const params = {
|
||||
...(filter?.kind === "tag" ? { tag: filter.value } : {}),
|
||||
...(filter?.kind === "entity" ? { entity: filter.value } : {}),
|
||||
keyword: debouncedSearch || undefined,
|
||||
limit: filter ? PAGE_SIZE : 0,
|
||||
offset: page * PAGE_SIZE,
|
||||
};
|
||||
|
||||
const { data, isLoading, isFetching } = useKnowledgeNotes(params);
|
||||
const items = (data?.items ?? []).filter(
|
||||
(n: KnowledgeNote) => n.id !== currentNoteId,
|
||||
);
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<Dialog open={!!filter} onOpenChange={onOpenChange}>
|
||||
@@ -62,17 +101,28 @@ export function RelatedNotesDialog({
|
||||
{filter?.kind === "entity"
|
||||
? "提到该实体的关联知识"
|
||||
: "使用该标签的关联知识"}
|
||||
{total > 0 ? ` · 共 ${total} 条` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[55vh]">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="在关联知识中搜索标题/内容..."
|
||||
className="h-9 pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[50vh]">
|
||||
{isLoading || (isFetching && items.length === 0) ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
|
||||
暂无其他关联知识。
|
||||
{debouncedSearch ? "没有匹配的关联知识。" : "暂无其他关联知识。"}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1 pr-2">
|
||||
@@ -104,6 +154,32 @@ export function RelatedNotesDialog({
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{pageCount > 1 ? (
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
第 {page + 1} / {pageCount} 页
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page === 0 || isFetching}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" /> 上一页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= pageCount - 1 || isFetching}
|
||||
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
>
|
||||
下一页 <ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Copy,
|
||||
Menu,
|
||||
Network,
|
||||
@@ -41,7 +40,7 @@ function HeaderButton({
|
||||
);
|
||||
}
|
||||
|
||||
/** Light 56px wiki header: logo + product name on the left, action entries on the right. */
|
||||
/** Light 56px wiki header: plain page title on the left, action entries on the right. */
|
||||
export function WikiHeader({
|
||||
onToggleSidebar,
|
||||
onImportFile,
|
||||
@@ -68,14 +67,8 @@ export function WikiHeader({
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
<div className="flex size-9 items-center justify-center rounded-[9px] bg-red-600 text-white">
|
||||
<BookOpen className="size-5" />
|
||||
</div>
|
||||
<div className="leading-none">
|
||||
<div className="text-base font-extrabold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
问答梳理
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-emerald-500">LLM Wiki · 问答梳理中心</div>
|
||||
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
问答梳理
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -422,14 +422,7 @@ export function AgentDetailPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="hidden"
|
||||
onClick={beginEdit}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={beginEdit}>
|
||||
<SquarePenIcon className="size-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
@@ -591,14 +584,29 @@ export function AgentDetailPanel({
|
||||
)}
|
||||
|
||||
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
|
||||
{(editing || questions.length > 0) && (
|
||||
{(editing || questions.length > 0 || canEdit) && (
|
||||
<Section
|
||||
icon={<MessageCircleQuestionIcon className="size-3.5" />}
|
||||
title="常用问题"
|
||||
extra={
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{(editing && draft ? draft.questions : questions).length} 个
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{(editing && draft ? draft.questions : questions).length} 个
|
||||
</span>
|
||||
{!editing && canEdit && (
|
||||
<Tooltip content="管理常用问题(新增 / 删除 / 排序)">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={beginEdit}
|
||||
aria-label="管理常用问题"
|
||||
>
|
||||
<SquarePenIcon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{editing && draft ? (
|
||||
@@ -607,6 +615,15 @@ export function AgentDetailPanel({
|
||||
onChange={(next) => setDraft({ ...draft, questions: next })}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
) : questions.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={beginEdit}
|
||||
className="text-muted-foreground hover:text-foreground hover:border-border w-full rounded-lg border border-dashed px-3 py-4 text-center text-xs transition-colors"
|
||||
>
|
||||
<PlusIcon className="mr-1 inline size-3.5 align-[-2px]" />
|
||||
添加常用问题
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{questions.map((question, index) => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { AgentQuestion } from "@/core/agents";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Chips shown before the rest folds behind the 更多问题 toggle. */
|
||||
const VISIBLE_COUNT = 4;
|
||||
|
||||
/**
|
||||
* 常用问题 prompt chips on the agent chat landing screen — light-gray
|
||||
* borderless pills stacked left-aligned between the welcome hero and the
|
||||
* input box. Clicking a chip sends the question's full prompt through the
|
||||
* page's existing submit flow. Lists longer than {@link VISIBLE_COUNT} fold
|
||||
* behind a 更多问题 toggle; an empty list renders nothing.
|
||||
*/
|
||||
export function AgentQuestionChips({
|
||||
questions,
|
||||
onAsk,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
questions: AgentQuestion[];
|
||||
onAsk: (prompt: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (questions.length === 0) return null;
|
||||
|
||||
const visible = expanded ? questions : questions.slice(0, VISIBLE_COUNT);
|
||||
const foldedCount = questions.length - VISIBLE_COUNT;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-start gap-2 px-1", className)}>
|
||||
{visible.map((question, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onAsk(question.prompt)}
|
||||
title={desensitize(question.prompt)}
|
||||
className={cn(
|
||||
"group bg-muted text-foreground/90 flex max-w-full items-center gap-2 rounded-full px-4 py-2.5 text-left text-sm leading-5 transition",
|
||||
"hover:bg-accent active:scale-[0.98]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{desensitize(question.title)}</span>
|
||||
<ArrowRightIcon className="text-muted-foreground size-3.5 shrink-0 transition-transform group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
))}
|
||||
{foldedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 rounded-full px-4 py-1.5 text-xs transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
收起
|
||||
<ChevronUpIcon className="size-3.5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
更多问题({foldedCount})
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,20 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { AgentQuestion } from "@/core/agents";
|
||||
|
||||
/** Soft cap on the chip label length — beyond this the landing chip truncates. */
|
||||
const TITLE_SOFT_LIMIT = 80;
|
||||
|
||||
/** The text a question row would actually persist as (panel save falls back
|
||||
* title↔prompt), used for duplicate detection. */
|
||||
function effectivePrompt(q: AgentQuestion): string {
|
||||
return (q.prompt.trim() || q.title.trim()).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable list of an agent's 常用问题 (recommended questions). Each row has a
|
||||
* short `title` (the chip label) and the full `prompt` sent on click. Supports
|
||||
* add / remove / inline edit / reorder (move up & down). Pure controlled
|
||||
* add / remove / inline edit / reorder (move up & down), with non-blocking
|
||||
* hints for over-long titles and duplicate questions. Pure controlled
|
||||
* component — persistence stays with the caller (edit dialog or detail panel).
|
||||
*/
|
||||
export function AgentQuestionEditor({
|
||||
@@ -52,7 +62,13 @@ export function AgentQuestionEditor({
|
||||
尚未配置常用问题,点击下方按钮添加。
|
||||
</p>
|
||||
)}
|
||||
{value.map((item, index) => (
|
||||
{value.map((item, index) => {
|
||||
const titleLength = item.title.trim().length;
|
||||
const prompt = effectivePrompt(item);
|
||||
const duplicateOf = prompt
|
||||
? value.findIndex((other, i) => i < index && effectivePrompt(other) === prompt)
|
||||
: -1;
|
||||
return (
|
||||
<div key={index} className="bg-background/60 space-y-1.5 rounded-lg border p-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
@@ -103,8 +119,19 @@ export function AgentQuestionEditor({
|
||||
onChange={(e) => update(index, { prompt: e.currentTarget.value })}
|
||||
className="min-h-16 resize-none text-sm"
|
||||
/>
|
||||
{titleLength > TITLE_SOFT_LIMIT && (
|
||||
<p className="text-destructive text-xs">
|
||||
标题建议不超过 {TITLE_SOFT_LIMIT} 字(当前 {titleLength} 字),过长会在聊天区被截断。
|
||||
</p>
|
||||
)}
|
||||
{duplicateOf >= 0 && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
与第 {duplicateOf + 1} 条问题内容重复。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Loader2,
|
||||
LockIcon,
|
||||
PinIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
@@ -436,6 +437,18 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<Upload className="size-4" />
|
||||
{t.settings.skills.installSkill}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
router.push("/page/workspace/chats/new?mode=skill");
|
||||
}}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
title="在对话中描述需求,由 AI 帮你创建一个新技能"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
{t.settings.skills.createSkill}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<TagSearchBar
|
||||
|
||||
Reference in new issue
Block a user