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
+495
-121
No files matched your search
@@ -9,6 +9,11 @@ import { I18nProvider } from "@/core/i18n/context";
|
||||
import { detectLocale } from "@/core/i18n/locale";
|
||||
import { ChatProvider } from "@/contexts/ChatContext";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import {
|
||||
resolveEmbedRoundtableTheme,
|
||||
resolveEmbedTheme,
|
||||
} from "@/core/embed/params";
|
||||
import { readWujieProps } from "@/core/embed/wujie";
|
||||
|
||||
import { HashRouter, useLocation } from "react-router-dom";
|
||||
|
||||
@@ -29,7 +34,26 @@ import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
const location = useLocation();
|
||||
const forcedTheme = location.pathname === "/landing" ? "light" : undefined;
|
||||
// 嵌入路由的主题用 forcedTheme 强制:只影响当前渲染,不写 localStorage。
|
||||
// 千万不要在嵌入页里调 ThemeContext.setTheme —— 它会持久化主题并打上
|
||||
// 「用户已显式选择」标记。无界子应用与主站同域共享 localStorage,宿主
|
||||
// 注入的 theme 会就此覆盖用户在主站自己选的主题(且每次打开宿主都覆盖)。
|
||||
const forcedTheme = useMemo(() => {
|
||||
if (location.pathname === "/landing") return "light";
|
||||
if (location.pathname === "/embed/roundtable") {
|
||||
const wp = readWujieProps();
|
||||
const raw =
|
||||
(typeof wp.theme === "string" && wp.theme.trim()) ||
|
||||
new URLSearchParams(location.search).get("theme")?.trim() ||
|
||||
"dark";
|
||||
return resolveEmbedRoundtableTheme(raw);
|
||||
}
|
||||
if (location.pathname.startsWith("/embed/chats")) {
|
||||
const raw = new URLSearchParams(location.search).get("theme")?.trim();
|
||||
return raw ? resolveEmbedTheme(raw) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
|
||||
@@ -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,15 +67,9 @@ 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 className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
问答梳理
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-emerald-500">LLM Wiki · 问答梳理中心</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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={
|
||||
<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
|
||||
|
||||
@@ -128,7 +128,10 @@ interface ThemeProviderProps {
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
|
||||
// forcedTheme:嵌入路由 / landing 由 AppProviders 强制的主题。强制期间
|
||||
// 它决定渲染,且本组件不得写 localStorage(嵌入子应用与主站同域共享
|
||||
// 存储,写入会覆盖用户在主站的选择)。
|
||||
const { resolvedTheme, forcedTheme, setTheme: setNextTheme } = useNextTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// Admin-configured global default. If the user hasn't explicitly chosen
|
||||
// a theme yet, this value drives the rendered theme — and re-syncs on
|
||||
@@ -144,7 +147,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
// 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作
|
||||
// 「用户已选过」,admin 默认就再也注入不进来了。
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!mounted || forcedTheme) return;
|
||||
const current = localStorage.getItem(STORAGE_KEY);
|
||||
const legacy = localStorage.getItem('theme');
|
||||
|
||||
@@ -162,24 +165,25 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
if (legacy) {
|
||||
localStorage.removeItem('theme');
|
||||
}
|
||||
}, [mounted, setNextTheme]);
|
||||
}, [mounted, forcedTheme, setNextTheme]);
|
||||
|
||||
// Boot-time sync: when the user has never picked a theme themselves,
|
||||
// apply whatever the admin configured. Re-runs whenever the admin
|
||||
// setting changes (so a logged-in user sees the new default after
|
||||
// refreshing without having to clear cookies).
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!mounted || forcedTheme) return;
|
||||
if (hasExplicitChoice()) return;
|
||||
const adminDefault = appearance?.default_theme;
|
||||
if (!adminDefault) return;
|
||||
if (resolvedTheme !== adminDefault) {
|
||||
setNextTheme(adminDefault);
|
||||
}
|
||||
}, [mounted, appearance?.default_theme, resolvedTheme, setNextTheme]);
|
||||
}, [mounted, forcedTheme, appearance?.default_theme, resolvedTheme, setNextTheme]);
|
||||
|
||||
const effectiveTheme = forcedTheme ?? resolvedTheme;
|
||||
const theme: ThemeType = mounted
|
||||
? resolvedTheme === 'dark'
|
||||
? effectiveTheme === 'dark'
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: readPersistedTheme();
|
||||
|
||||
@@ -44,3 +44,14 @@ export function resolveEmbedChatParams(search: string): EmbedChatParams {
|
||||
export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
|
||||
return resolveThemeFromQuery(themeParam || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把宿主传入的主题标识映射成 light/dark。**默认深蓝(dark)**:
|
||||
* 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。
|
||||
* 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。
|
||||
*/
|
||||
export function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (v === "light" || v === "red" || v === "红白") return "light";
|
||||
return "dark";
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.keyword) qs.set("keyword", params.keyword);
|
||||
if (params.tag) qs.set("tag", params.tag);
|
||||
if (params.entity) qs.set("entity", params.entity);
|
||||
if (params.source_type) qs.set("source_type", params.source_type);
|
||||
if (params.status) qs.set("status", params.status);
|
||||
qs.set("limit", String(params.limit ?? 20));
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface KnowledgeListResponse {
|
||||
export interface KnowledgeListParams {
|
||||
keyword?: string;
|
||||
tag?: string;
|
||||
/** Exact entity-name filter (matched via the knowledge relation graph). */
|
||||
entity?: string;
|
||||
source_type?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
|
||||
import { AgentQuestionChips } from "@/components/workspace/agents/agent-question-chips";
|
||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
@@ -28,7 +29,6 @@ import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
@@ -235,29 +235,12 @@ export default function AgentChatPage() {
|
||||
className="mb-7"
|
||||
/>
|
||||
<div className="w-full max-w-(--container-width-md)">
|
||||
{commonQuestions.length > 0 && (
|
||||
<div className="mb-2 flex max-h-20 flex-wrap items-start justify-start gap-2 overflow-y-auto px-1">
|
||||
{commonQuestions.map((question, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
<AgentQuestionChips
|
||||
className="mb-3"
|
||||
questions={commonQuestions}
|
||||
disabled={isDemoMode}
|
||||
onClick={() =>
|
||||
handleSubmit({ text: question.prompt, files: [] })
|
||||
}
|
||||
title={desensitize(question.prompt)}
|
||||
className={cn(
|
||||
"border-border/70 bg-background/85 hover:bg-accent hover:border-border max-w-full rounded-full border px-3 py-1.5 text-left text-sm leading-5 shadow-sm transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="block max-w-[220px] truncate">
|
||||
{desensitize(question.title)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
onAsk={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
|
||||
@@ -4,13 +4,9 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { loginByUsername } from "@/core/auth/api";
|
||||
import { setStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
resolveEmbedChatParams,
|
||||
resolveEmbedTheme,
|
||||
} from "@/core/embed/params";
|
||||
import { resolveEmbedChatParams } from "@/core/embed/params";
|
||||
import { registerEmbedSession } from "@/core/embed/status-api";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
@@ -29,7 +25,8 @@ const EMBED_ROUTE_BASE = "/embed/chats";
|
||||
export default function EmbedChatPage() {
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const { setTheme } = useTheme();
|
||||
// 主题不在这里处理 —— ?theme= 由 AppProviders 通过 forcedTheme 强制
|
||||
// (不落 localStorage),避免污染主站用户自己选的主题。
|
||||
const embedParams = resolveEmbedChatParams(searchParams.toString());
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
@@ -51,12 +48,6 @@ export default function EmbedChatPage() {
|
||||
const [settings] = useThreadSettings(threadId);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
|
||||
useEffect(() => {
|
||||
if (embedParams.theme) {
|
||||
setTheme(resolveEmbedTheme(embedParams.theme));
|
||||
}
|
||||
}, [embedParams.theme, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function runLogin() {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { loginByUsername } from "@/core/auth/api";
|
||||
import { setStoredAuth } from "@/core/auth";
|
||||
import { readWujieProps, isWujieEnv } from "@/core/embed/wujie";
|
||||
@@ -13,17 +12,6 @@ import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
|
||||
/**
|
||||
* 把宿主传入的主题标识映射成 light/dark。**默认深蓝(dark)**:
|
||||
* 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。
|
||||
* 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。
|
||||
*/
|
||||
function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (v === "light" || v === "red" || v === "红白") return "light";
|
||||
return "dark";
|
||||
}
|
||||
|
||||
/**
|
||||
* 圆桌规划嵌入页(独立入口,**与现有 wujie ticket 登录无关**)。
|
||||
*
|
||||
@@ -37,9 +25,10 @@ function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
|
||||
*/
|
||||
export default function EmbedRoundtablePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
// username / password / theme:wujie props 优先,URL query 兜底。
|
||||
// username / password:wujie props 优先,URL query 兜底。
|
||||
// 主题不在这里处理 —— 嵌入路由的主题由 AppProviders 通过 forcedTheme
|
||||
// 强制(不落 localStorage),避免污染主站用户自己选的主题。
|
||||
const wp = readWujieProps();
|
||||
const username =
|
||||
(typeof wp.username === "string" && wp.username.trim()) ||
|
||||
@@ -53,19 +42,10 @@ export default function EmbedRoundtablePage() {
|
||||
(typeof wp.password === "string" && wp.password) ||
|
||||
searchParams.get("password") ||
|
||||
undefined;
|
||||
// 主题色:宿主传入优先;默认「深蓝」= dark 主题(其主色为蓝,oklch hue 259)。
|
||||
const themeParam =
|
||||
(typeof wp.theme === "string" && wp.theme.trim()) ||
|
||||
searchParams.get("theme")?.trim() ||
|
||||
"dark";
|
||||
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(resolveEmbedRoundtableTheme(themeParam));
|
||||
}, [themeParam, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWujieEnv()) return;
|
||||
document.documentElement.classList.add("wujie-embed");
|
||||
|
||||
@@ -184,13 +184,11 @@ export default function NewAgentPage() {
|
||||
// Name is intentionally NOT required here so the user can start chatting
|
||||
// immediately. If they leave it blank, ensureAgent() generates a placeholder
|
||||
// and writes it back into the input so they can rename it later.
|
||||
if (enabledSkills.length > 0 && selectedSkills.length === 0) {
|
||||
setFormError("请至少选择一个技能");
|
||||
return null;
|
||||
}
|
||||
// Zero skills is a valid choice: the backend treats an empty/omitted skill
|
||||
// list as "no skills" for custom agents (never "all skills").
|
||||
setFormError("");
|
||||
return nameInput.trim();
|
||||
}, [enabledSkills.length, nameInput, selectedSkills.length]);
|
||||
}, [nameInput]);
|
||||
|
||||
const ensureAgent = useCallback(async () => {
|
||||
const provided = validateForm();
|
||||
|
||||
@@ -251,7 +251,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||
| **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/source_type/status + pagination); `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** (`/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). |
|
||||
|
||||
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
|
||||
|
||||
@@ -406,6 +406,7 @@ Operators upgrading an existing DB run the whole file (`USE <db>;` then execute)
|
||||
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools)
|
||||
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
|
||||
- **Injection**: Enabled skills listed in agent system prompt with container paths
|
||||
- **Per-agent whitelist (custom agents)**: a custom agent's `config.yaml` `skills:` field is an explicit allowlist — `None` (key omitted) and `[]` both mean **no skills**, never "all skills". Only the default lead agent (no `agent_id`) and the bootstrap flow see every enabled skill. Enforced consistently in three places: the system prompt (`get_skills_prompt_section`, empty whitelist → no `<skill_system>` block at all), the `skill_list` tool, and the `skill_view` tool (out-of-whitelist names answer "not found" so existence isn't leaked) — including the `skill_manage` `list`/`view` aliases, which delegate to the shared `skill_list_impl`/`skill_view_impl` with the active `agent_id`. Tests: `tests/test_skill_tools_whitelist.py`
|
||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
||||
- **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) — never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill → owner or admin; built-in/public → admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (一键补充: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` — an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) — distinct from the model returning an unusable name (→ `None`). The single endpoint maps that to `502 大模型报错,无法补全技能中文名:…`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N 个因大模型报错失败". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI 生成" plus an **admin-only** header "一键补充中文名" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action.
|
||||
|
||||
|
||||
@@ -424,6 +424,7 @@ async def list_notes(
|
||||
request: Request,
|
||||
keyword: str | None = None,
|
||||
tag: str | None = None,
|
||||
entity: str | None = None,
|
||||
source_type: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 20,
|
||||
@@ -432,7 +433,7 @@ async def list_notes(
|
||||
service = get_knowledge_service(request)
|
||||
limit = max(1, min(limit, 100))
|
||||
offset = max(0, offset)
|
||||
return await service.list_notes(keyword=keyword, tag=tag, source_type=source_type, status=status, limit=limit, offset=offset)
|
||||
return await service.list_notes(keyword=keyword, tag=tag, entity=entity, source_type=source_type, status=status, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/notes/{note_id}")
|
||||
|
||||
@@ -276,6 +276,7 @@ class KnowledgeRepository:
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
tag: str | None = None,
|
||||
entity: str | None = None,
|
||||
source_type: str | None = None,
|
||||
status: str | None = None,
|
||||
include_archived: bool = False,
|
||||
@@ -303,6 +304,16 @@ class KnowledgeRepository:
|
||||
.where(func.lower(KnowledgeTagRow.name) == tag.lower())
|
||||
)
|
||||
conditions.append(KnowledgeNoteRow.id.in_(tag_subq))
|
||||
if entity:
|
||||
# Exact entity match via the note→entity relation graph — precise
|
||||
# and index-backed, unlike a content LIKE scan on large vaults.
|
||||
entity_subq = (
|
||||
select(KnowledgeRelationRow.from_note_id)
|
||||
.join(KnowledgeEntityRow, KnowledgeRelationRow.to_entity_id == KnowledgeEntityRow.id)
|
||||
.where(func.lower(KnowledgeEntityRow.name) == entity.lower())
|
||||
.where(KnowledgeRelationRow.from_note_id.is_not(None))
|
||||
)
|
||||
conditions.append(KnowledgeNoteRow.id.in_(entity_subq))
|
||||
|
||||
for cond in conditions:
|
||||
stmt = stmt.where(cond)
|
||||
|
||||
@@ -523,6 +523,7 @@ class KnowledgeService:
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
tag: str | None = None,
|
||||
entity: str | None = None,
|
||||
source_type: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 20,
|
||||
@@ -531,6 +532,7 @@ class KnowledgeService:
|
||||
items, total = await self.repo.list_notes(
|
||||
keyword=keyword,
|
||||
tag=tag,
|
||||
entity=entity,
|
||||
source_type=normalize_source_type(source_type) if source_type else None,
|
||||
status=status,
|
||||
limit=limit,
|
||||
|
||||
+36
-12
@@ -39,10 +39,15 @@ def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None:
|
||||
"""Return the explicit skill name set declared in an agent's config.yaml.
|
||||
|
||||
Semantics:
|
||||
- ``None`` returned → no agent_id, or config missing — caller should
|
||||
fall back to "all skills visible".
|
||||
- ``set()`` (empty) → agent has ``skills: []`` declared — visibility
|
||||
is intentionally empty.
|
||||
- ``None`` returned → no agent_id, or the agent's on-disk config is
|
||||
missing entirely — caller should fall back to "all skills visible"
|
||||
(default lead agent behavior).
|
||||
- ``set()`` (empty) → custom agent with ``skills: []`` declared **or**
|
||||
with no ``skills`` key at all — visibility is intentionally empty.
|
||||
This mirrors the prompt layer (``agents_config.AgentConfig.skills``:
|
||||
``None``/``[]`` both mean "no skills" for custom agents), so an agent
|
||||
whose config omits ``skills`` can no longer enumerate every skill
|
||||
through this tool.
|
||||
- Non-empty set → only these names are visible to this agent.
|
||||
"""
|
||||
if not agent_id:
|
||||
@@ -51,9 +56,9 @@ def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None:
|
||||
from deerflow.config.agents_config import load_agent_config
|
||||
|
||||
cfg = load_agent_config(agent_id)
|
||||
if cfg is None or cfg.skills is None:
|
||||
if cfg is None:
|
||||
return None
|
||||
return set(cfg.skills)
|
||||
return set(cfg.skills or [])
|
||||
except Exception:
|
||||
logger.debug("Failed to load agent config for skill_list filter", exc_info=True)
|
||||
return None
|
||||
@@ -119,16 +124,21 @@ def _load_owned_skill_names(user_id: str | None) -> set[str] | None:
|
||||
return None
|
||||
|
||||
|
||||
@tool("skill_view")
|
||||
def skill_view_tool(name: str) -> str:
|
||||
"""Read the full SKILL.md content of a skill by name.
|
||||
def skill_view_impl(name: str, agent_id: str | None) -> str:
|
||||
"""Read a skill's SKILL.md, honoring the agent's config.yaml whitelist.
|
||||
|
||||
Args:
|
||||
name: The skill name (hyphen-case, e.g. 'my-skill').
|
||||
Custom agents may only read skills on their whitelist — same filter as
|
||||
``skill_list``, so a restricted agent cannot bypass its allowlist by
|
||||
guessing skill names. Replies "not found" rather than "forbidden" to
|
||||
avoid leaking which skills exist.
|
||||
"""
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.usage import bump_view
|
||||
|
||||
allowed = _load_agent_skill_whitelist(agent_id)
|
||||
if allowed is not None and name not in allowed:
|
||||
return f"Skill '{name}' not found."
|
||||
|
||||
storage = get_or_new_skill_storage()
|
||||
try:
|
||||
if storage.custom_skill_exists(name):
|
||||
@@ -156,6 +166,16 @@ def skill_view_tool(name: str) -> str:
|
||||
return content
|
||||
|
||||
|
||||
@tool("skill_view")
|
||||
def skill_view_tool(name: str, runtime: ToolRuntime) -> str:
|
||||
"""Read the full SKILL.md content of a skill by name.
|
||||
|
||||
Args:
|
||||
name: The skill name (hyphen-case, e.g. 'my-skill').
|
||||
"""
|
||||
return skill_view_impl(name, _resolve_active_agent_id(runtime))
|
||||
|
||||
|
||||
@tool("skill_list")
|
||||
def skill_list_tool(runtime: ToolRuntime) -> str:
|
||||
"""List skills available to this agent, with metadata and usage stats.
|
||||
@@ -175,11 +195,15 @@ def skill_list_tool(runtime: ToolRuntime) -> str:
|
||||
state, use_count, view_count, patch_count, last_activity_at, pinned,
|
||||
source.
|
||||
"""
|
||||
return skill_list_impl(_resolve_active_agent_id(runtime))
|
||||
|
||||
|
||||
def skill_list_impl(agent_id: str | None) -> str:
|
||||
"""List skills visible to ``agent_id`` (None = default lead agent)."""
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.types import SkillCategory
|
||||
from deerflow.skills.usage import STATE_ARCHIVED, all_entries, latest_activity_at
|
||||
|
||||
agent_id = _resolve_active_agent_id(runtime)
|
||||
allowed = _load_agent_skill_whitelist(agent_id)
|
||||
|
||||
# 默认 lead agent 没有 per-agent 白名单 —— 改为只暴露"未归档"且"内置或属于
|
||||
|
||||
+6
-4
@@ -218,14 +218,16 @@ async def _skill_manage_impl(
|
||||
"""
|
||||
# Read-only aliases — delegate to the dedicated tools so a model that
|
||||
# over-uses skill_manage doesn't trip on Unsupported action errors.
|
||||
# Pass the active agent_id through so a custom agent's skill whitelist
|
||||
# is enforced on these aliases just like on skill_list/skill_view.
|
||||
if action == "list":
|
||||
from deerflow.tools.builtins.skill_tools import skill_list_tool
|
||||
from deerflow.tools.builtins.skill_tools import _resolve_active_agent_id, skill_list_impl
|
||||
|
||||
return await _to_thread(skill_list_tool.invoke, {})
|
||||
return await _to_thread(skill_list_impl, _resolve_active_agent_id(runtime))
|
||||
if action == "view":
|
||||
from deerflow.tools.builtins.skill_tools import skill_view_tool
|
||||
from deerflow.tools.builtins.skill_tools import _resolve_active_agent_id, skill_view_impl
|
||||
|
||||
return await _to_thread(skill_view_tool.invoke, {"name": name})
|
||||
return await _to_thread(skill_view_impl, name, _resolve_active_agent_id(runtime))
|
||||
|
||||
name = SkillStorage.validate_skill_name(name)
|
||||
lock = _get_lock(name)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DeerFlow 后端问答客户端(零第三方依赖,纯 Python 标准库)。
|
||||
|
||||
用法:只需修改下面的 BASE_URL(必要时改 USERNAME / LOGIN_PASSWORD),
|
||||
然后调用 ask("你的问题") 即可拿到回答文本。
|
||||
|
||||
from qa_client import ask
|
||||
|
||||
answer = ask("你好,介绍一下你自己")
|
||||
print(answer)
|
||||
|
||||
多轮对话(共用同一个 thread_id 即可保留上下文):
|
||||
|
||||
tid = new_thread_id()
|
||||
print(ask("我叫小明", thread_id=tid))
|
||||
print(ask("我叫什么名字?", thread_id=tid))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
# ======================= 只需要修改这里 =======================
|
||||
BASE_URL = "http://47.94.209.59:2026" # 后端 Gateway 地址(改成你的服务器地址即可)
|
||||
USERNAME = "api-client" # 登录用户名(免密登录,不存在会自动注册)
|
||||
LOGIN_PASSWORD = "123ewq" # 仅当后端开启 require_password 口令门槛时才需要填(默认口令 123ewq)
|
||||
MODEL_NAME = None # 可选:指定模型名;None = 使用后端默认模型
|
||||
TIMEOUT_SECONDS = 600 # 等待回答的超时时间(复杂问题可能跑几分钟)
|
||||
# =============================================================
|
||||
|
||||
_token_cache = {"token": None}
|
||||
_ssl_ctx = ssl.create_default_context()
|
||||
_ssl_ctx.check_hostname = False
|
||||
_ssl_ctx.verify_mode = ssl.CERT_NONE # 内网自签证书也能用
|
||||
|
||||
|
||||
def _post_json(url: str, payload: dict, token: str | None = None, timeout: int = 30) -> dict:
|
||||
"""POST JSON 并解析 JSON 响应。"""
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_ctx) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _login() -> str | None:
|
||||
"""免密用户名登录,返回 Bearer token;后端关闭鉴权时返回 None 也能正常问答。"""
|
||||
if _token_cache["token"]:
|
||||
return _token_cache["token"]
|
||||
url = f"{BASE_URL.rstrip('/')}/api/v1/auth/login/username"
|
||||
if LOGIN_PASSWORD:
|
||||
url += "?" + urllib.parse.urlencode({"password": LOGIN_PASSWORD})
|
||||
try:
|
||||
result = _post_json(url, {"username": USERNAME})
|
||||
_token_cache["token"] = result.get("access_token")
|
||||
return _token_cache["token"]
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise RuntimeError("登录被拒绝:后端开启了登录口令,请在 LOGIN_PASSWORD 填入正确口令(默认 123ewq)") from e
|
||||
# 登录接口异常时尝试无 token 直连(后端可能处于免鉴权模式)
|
||||
return None
|
||||
except urllib.error.URLError:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_answer(channel_values: dict) -> str:
|
||||
"""从 /api/runs/wait 返回的最终状态里取出最后一条 AI 回复文本。"""
|
||||
messages = channel_values.get("messages") or []
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("type") not in ("ai", "AIMessageChunk", "AIMessage"):
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
return content.strip()
|
||||
if isinstance(content, list): # 结构化内容块:拼接其中的文本
|
||||
parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
|
||||
text = "".join(parts).strip()
|
||||
if text:
|
||||
return text
|
||||
# 没有 AI 消息时,把原始返回吐出来便于排查
|
||||
return json.dumps(channel_values, ensure_ascii=False)[:2000]
|
||||
|
||||
|
||||
def new_thread_id() -> str:
|
||||
"""生成一个新会话 id,用于多轮对话。"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def ask(question: str, thread_id: str | None = None) -> str:
|
||||
"""向后端发送一个问题,阻塞直到拿到最终回答文本。
|
||||
|
||||
:param question: 要问的问题
|
||||
:param thread_id: 可选。传同一个 id 即为多轮对话(保留上下文);不传则每次新会话
|
||||
:return: AI 的回答文本
|
||||
"""
|
||||
token = _login()
|
||||
configurable: dict = {}
|
||||
if thread_id:
|
||||
configurable["thread_id"] = thread_id
|
||||
if MODEL_NAME:
|
||||
configurable["model_name"] = MODEL_NAME
|
||||
|
||||
payload = {
|
||||
"input": {"messages": [{"role": "user", "content": question}]},
|
||||
"config": {"configurable": configurable},
|
||||
}
|
||||
url = f"{BASE_URL.rstrip('/')}/api/runs/wait"
|
||||
try:
|
||||
result = _post_json(url, payload, token=token, timeout=TIMEOUT_SECONDS)
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")[:500]
|
||||
raise RuntimeError(f"请求失败 HTTP {e.code}: {body}") from e
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"无法连接后端 {BASE_URL},请检查 BASE_URL 和服务是否启动: {e.reason}") from e
|
||||
|
||||
return _extract_answer(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 示例 1:单轮问答
|
||||
print(ask("你好,请简单介绍一下你自己"))
|
||||
|
||||
# 示例 2:多轮对话(取消注释即可)
|
||||
# tid = new_thread_id()
|
||||
# print(ask("我叫小明,记住了吗?", thread_id=tid))
|
||||
# print(ask("我叫什么名字?", thread_id=tid))
|
||||
Reference in new issue
Block a user