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:
吉鑫升andClaude Fable 5 committed 2026-06-12 11:09:32 +08:00
1 parent 11bc442f10
commit 28098b3155
22 files changed
+500 -126

No files matched your search

+25 -1
View File
@@ -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,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
+10 -6
View File
@@ -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();
+11
View File
@@ -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";
}
+1
View File
@@ -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));
+2
View File
@@ -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;
+7 -24
View File
@@ -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"
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>
)}
<AgentQuestionChips
className="mb-3"
questions={commonQuestions}
disabled={isDemoMode}
onAsk={(text) => handleSubmit({ text, files: [] })}
/>
<div className="min-w-0 flex-1">
<InputBox
className="w-full"
+3 -12
View File
@@ -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 -23
View File
@@ -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");
+3 -5
View File
@@ -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();