This commit is contained in:
zjy committed 2026-05-29 09:56:12 +08:00
commit ee8e2b3225
298 files changed
+92327 -2435

No files matched your search

@@ -0,0 +1,24 @@
.sidebar-contents{
width:98px;
height:986px;
background:rgba(3, 38, 80, 0.8);
border:1px solid;
border-color:rgba(43, 143, 254, 0.6);
border-radius:4px;
backdrop-filter:blur(2px);
}
.item-nav{
width:78px;
height:70px;
background:rgba(0, 12, 28, 0.15);
border:1px solid;
border-color:rgba(85, 166, 254, 0.2);
line-height:8px;
font-family:Noto Sans S Chinese;
font-weight:500;
color:rgba(255, 255, 255, 0.8);
font-size:20px;
padding-bottom:18px;
text-align:center;
}
@@ -0,0 +1,311 @@
import { useEffect, useState } from "react";
import type { ElementType } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Bell,
Bot,
Brain,
CalendarClock,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
CircleHelp,
Database,
FileBox,
FileText,
Folder,
FolderOpen,
Gauge,
Grid3X3,
HelpCircle,
History,
Layout,
Library,
List,
ListChecks,
ListTodo,
MessageSquareCode,
MessageSquarePlus,
Network,
NotebookPen,
Plus,
Settings,
Sparkles,
Target,
User,
Users,
Wrench,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { SettingsDialog } from "@/components/workspace/settings";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import './page-sdebar.css'
type MenuItem = {
id: string;
label: string;
icon: ElementType;
path?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
type PageSidebarDarkProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
const menuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答中心",
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
],
},
{
id: "light-app-center",
label: "轻应用",
icon: Grid3X3,
children: [
{
id: "light-app-note-management",
label: "笔记管理",
icon: NotebookPen,
children: [
{
id: "light-app-new-note",
label: "新建笔记",
icon: Plus,
path: "/page/strategy/light-app/new-note",
},
{
id: "light-app-note-list",
label: "笔记列表",
icon: List,
path: "/page/strategy/light-app/notes",
},
{
id: "light-app-note-qa",
label: "笔记问答",
icon: MessageSquareCode,
path: "/page/strategy/light-app/note-qa",
},
],
},
{ id: "light-app-square", label: "应用广场", icon: Layout },
{ id: "light-app-records", label: "运行记录", icon: History },
],
},
{
id: "task-management",
label: "任务管理",
icon: Target,
children: [
{
id: "xk-center-task",
label: "XK中任务",
icon: Folder,
children: [
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo, path: "/page/strategy/task-management/xk-task-jc" },
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo, path: "/page/strategy/task-management/xk-task-dispatch" },
],
},
{
id: "coh-center-task",
label: "COH中任务",
icon: Folder,
children: [
{ id: "coh-task-3qfx", label: "3QFX", icon: ListTodo },
{ id: "coh-task-fx", label: "任务FX", icon: ListTodo },
{ id: "coh-task-xdfx", label: "XDFX", icon: ListTodo },
],
},
{
id: "pg-center-task",
label: "PG中任务",
icon: Folder,
children: [
{ id: "pg-task-pg", label: "任务PG", icon: ListTodo },
{ id: "pg-task-xdpg", label: "XDPG", icon: ListTodo },
{ id: "pg-task-info-pg", label: "信息PG", icon: ListTodo },
],
},
{
id: "ty-center-task",
label: "TY中任务",
icon: Folder,
children: [
{ id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo },
{ id: "ty-task-plan", label: "方案TY任务", icon: ListTodo },
{ id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo },
],
},
],
},
{
id: "comprehensive-management",
label: "综合管理",
icon: Settings,
children: [
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "mgmt-tasks",
label: "任务管理",
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
},
{
id: "mgmt-recommended-questions",
label: "推荐问题配置",
icon: MessageSquarePlus,
path: "/page/strategy/settings/recommended-questions",
adminOnly: true,
},
{
id: "mgmt-prompt-prefix",
label: "问答前缀配置",
icon: MessageSquareCode,
path: "/page/strategy/settings/prompt-prefix",
adminOnly: true,
},
{
id: "mgmt-llm-metrics",
label: "大模型调用统计",
icon: Gauge,
path: "/page/strategy/settings/llm-metrics",
adminOnly: true,
},
{
id: "mgmt-users",
label: "用户管理",
icon: Users,
path: "/page/strategy/admin/users",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",
icon: CalendarClock,
path: "/page/strategy/admin/scheduled-tasks",
adminOnly: true,
},
{
id: "mgmt-user-activity",
label: "用户活动审计",
icon: History,
path: "/page/strategy/admin/user-activity",
adminOnly: true,
},
],
},
{
id: "resource-management",
label: "资源管理",
icon: Database,
children: [
{ id: "resource-network-xwt", label: "网络xwt资源管理", icon: Network },
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
{ id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
],
},
];
const defaultExpandedIds = new Set([
"qa-center",
"light-app-center",
"light-app-note-management",
"task-management",
"xk-center-task",
]);
function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
return items
.filter((item) => !item.adminOnly || isAdmin)
.map((item) =>
item.children
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) }
: item,
);
}
export function PageSidebarDark({
collapsed,
onToggleCollapsed,
}: PageSidebarDarkProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = filterMenuItemsByRole(menuItems, !!isAdmin);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] =
useState<SettingsSection>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navigate = useNavigate();
const { pathname } = useLocation();
useEffect(() => {
setMounted(true);
}, []);
const openSettings = (section: SettingsSection) => {
setSettingsDefaultSection(section);
setSettingsOpen(true);
};
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
{/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */}
<div className="sidebar-contnet">
<div className="sidebar-contents">
<div className="item-nav">
问答管理
</div>
</div>
</div>
</>
);
}
+9 -1
View File
@@ -26,6 +26,7 @@ import {
ListTodo,
MessageSquareCode,
MessageSquarePlus,
ShieldCheck,
Network,
NotebookPen,
Plus,
@@ -175,7 +176,7 @@ const menuItems: MenuItem[] = [
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "mgmt-tasks",
label: "任务管理",
label: "定时任务",
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
},
@@ -193,6 +194,13 @@ const menuItems: MenuItem[] = [
path: "/page/strategy/settings/prompt-prefix",
adminOnly: true,
},
{
id: "mgmt-private-square",
label: "私密广场配置",
icon: ShieldCheck,
path: "/page/strategy/settings/private-square",
adminOnly: true,
},
{
id: "mgmt-llm-metrics",
label: "大模型调用统计",
@@ -6,8 +6,10 @@ import {
InfoIcon,
LockIcon,
MessageSquareIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
StarIcon,
TagsIcon,
Trash2Icon,
} from "lucide-react";
@@ -40,8 +42,11 @@ import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-di
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import {
useAgent,
useDeleteAgent,
useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useUpdateAgent,
} from "@/core/agents";
import type { Agent } from "@/core/agents";
@@ -61,6 +66,8 @@ export function AgentCard({ agent }: AgentCardProps) {
const deleteAgent = useDeleteAgent();
const updateAgent = useUpdateAgent();
const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -69,11 +76,19 @@ export function AgentCard({ agent }: AgentCardProps) {
const [editDescription, setEditDescription] = useState(agent.description ?? "");
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
const [editPublished, setEditPublished] = useState(agent.published);
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [],
);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
// The /api/agents list response intentionally omits `soul` (perf optimization
// — SOUL.md is only read on demand). Fetch the detail row when the edit
// dialog opens so the SOUL textarea can be hydrated.
const { agent: agentDetail, isLoading: isAgentDetailLoading } = useAgent(
editOpen ? agent.id : null,
);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
@@ -81,11 +96,16 @@ export function AgentCard({ agent }: AgentCardProps) {
const isOwned =
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
const isFromOthers = !isBuiltin && !isOwned;
// System admins are allowed to edit and delete built-in agents.
// System admins are allowed to edit and delete any agent (built-in or owned
// by another user) so the admin console can manage the full catalog.
// Publish-toggle stays owner-only — built-ins are inherently visible to
// everyone so toggling published is moot.
const canEdit = isOwned || (isBuiltin && isAdmin);
const canDelete = isOwned || (isBuiltin && isAdmin);
const canEdit = isOwned || isAdmin;
const canDelete = isOwned || isAdmin;
// Favoriting collects a square agent into "我的"; pointless for one you
// already own, so only offer it on built-in / others' agents.
const canFavorite = !isOwned;
const isFavorite = !!agent.is_favorite;
useEffect(() => {
if (!editOpen) {
@@ -93,18 +113,32 @@ export function AgentCard({ agent }: AgentCardProps) {
}
setEditName(agent.name);
setEditDescription(agent.description ?? "");
setEditSoul(agent.soul ?? "");
setEditPublished(agent.published);
setEditIsPrivate(agent.is_private);
setSelectedSkills(agent.skills ?? []);
}, [
agent.description,
agent.is_private,
agent.name,
agent.published,
agent.skills,
agent.soul,
editOpen,
]);
useEffect(() => {
if (!editOpen) {
return;
}
// SOUL.md only ships on the detail endpoint; fall back to whatever the list
// happened to carry so older deployments (or cached objects that still
// include `soul`) keep working.
if (agentDetail) {
setEditSoul(agentDetail.soul ?? "");
} else {
setEditSoul(agent.soul ?? "");
}
}, [agent.soul, agentDetail, editOpen]);
function handleChat() {
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
}
@@ -125,6 +159,10 @@ export function AgentCard({ agent }: AgentCardProps) {
toast.error(t.agents.nameStepInvalidError ?? "名称不能为空");
return;
}
if (editIsPrivate && editPublished) {
toast.warning("私密广场与公开发布互斥,请只勾选其一");
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
@@ -132,7 +170,10 @@ export function AgentCard({ agent }: AgentCardProps) {
name: trimmedName,
description: editDescription,
soul: editSoul,
published: editPublished,
// Private agents cannot also be published — force the value the
// backend will see, regardless of any stale state in editPublished.
published: editIsPrivate ? false : editPublished,
is_private: editIsPrivate,
skills: selectedSkills,
},
});
@@ -144,6 +185,13 @@ export function AgentCard({ agent }: AgentCardProps) {
}
async function handleTogglePublished() {
// Private and public squares are mutually exclusive — a private agent
// can't simultaneously be published to everyone. Block the toggle
// instead of silently moving the agent between squares.
if (!agent.published && agent.is_private) {
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
@@ -155,6 +203,37 @@ export function AgentCard({ agent }: AgentCardProps) {
}
}
async function handleTogglePrivate() {
const turningOn = !agent.is_private;
try {
if (isOwned) {
// Owners can re-categorize their own agents through the normal update
// endpoint — keeps publish/private edits in one transaction.
await updateAgent.mutateAsync({
id: agent.id,
request: { is_private: turningOn },
});
} else {
// Admins can move any agent (built-in or other users') via the
// dedicated admin endpoint.
await setPrivate.mutateAsync({ id: agent.id, isPrivate: turningOn });
}
toast.success(turningOn ? "已加入私密广场" : "已移出私密广场");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleFavorite() {
const next = !isFavorite;
try {
await toggleFavorite.mutateAsync({ id: agent.id, favorite: next });
toast.success(next ? "已收藏到「我的」" : "已取消收藏");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleFeatured() {
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
// dialog. Featured agents get appended to the end on add (Date.now() is a
@@ -232,7 +311,7 @@ export function AgentCard({ agent }: AgentCardProps) {
来自他人
</Badge>
)}
{isOwned && agent.published && (
{isOwned && agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
@@ -242,6 +321,16 @@ export function AgentCard({ agent }: AgentCardProps) {
已发布
</Badge>
)}
{agent.is_private && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title="该智能体位于私密广场,需要密码才能查看"
>
<ShieldIcon className="size-3" />
私密
</Badge>
)}
{agent.featured_order != null && (
<Badge
variant="outline"
@@ -297,13 +386,26 @@ export function AgentCard({ agent }: AgentCardProps) {
</CardContent>
)}
<CardFooter className="mt-auto flex items-center justify-between gap-2 pt-3">
<Button size="sm" className="flex-1" onClick={handleChat}>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
{(canEdit || canDelete || isOwned || isAdmin) && (
<div className="flex gap-1">
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
{(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
<div className="flex flex-wrap items-center justify-end gap-1">
{canFavorite && (
<Button
size="icon"
variant="ghost"
className={cn(
"mr-auto h-8 w-8 shrink-0",
isFavorite && "text-amber-500 hover:text-amber-600",
)}
onClick={() => void handleToggleFavorite()}
disabled={toggleFavorite.isPending}
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
>
<StarIcon
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
/>
</Button>
)}
{isAdmin && (
<Button
size="icon"
@@ -351,6 +453,24 @@ export function AgentCard({ agent }: AgentCardProps) {
)}
</Button>
)}
{(isOwned || isAdmin) && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
agent.is_private &&
"text-purple-600 hover:text-purple-700",
)}
onClick={() => void handleTogglePrivate()}
disabled={updateAgent.isPending || setPrivate.isPending}
title={
agent.is_private ? "移出私密广场" : "放入私密广场"
}
>
<ShieldIcon className="h-3.5 w-3.5" />
</Button>
)}
{canEdit && (
<Button
size="icon"
@@ -375,6 +495,10 @@ export function AgentCard({ agent }: AgentCardProps) {
)}
</div>
)}
<Button size="sm" className="w-full" onClick={handleChat}>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
</CardFooter>
</Card>
@@ -462,7 +586,11 @@ export function AgentCard({ agent }: AgentCardProps) {
<Textarea
value={editSoul ?? ""}
onChange={(e) => setEditSoul(e.target.value)}
placeholder="输入 SOUL.md 内容"
placeholder={
isAgentDetailLoading && !agentDetail
? "正在加载 SOUL.md..."
: "输入 SOUL.md 内容"
}
className="min-h-40 font-mono text-xs"
/>
</div>
@@ -470,12 +598,41 @@ export function AgentCard({ agent }: AgentCardProps) {
<div>
<p className="text-sm font-medium">公开发布</p>
<p className="text-muted-foreground text-xs">
打开后,该智能体对所有用户可见。
{editIsPrivate
? "已加入私密广场,不可发布;请先关闭私密广场。"
: "打开后,该智能体对所有用户可见。"}
</p>
</div>
<Switch
checked={editPublished}
onCheckedChange={setEditPublished}
checked={editPublished && !editIsPrivate}
disabled={editIsPrivate}
onCheckedChange={(v) => {
if (editIsPrivate) {
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
setEditPublished(v);
}}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">加入私密广场</p>
<p className="text-muted-foreground text-xs">
打开后,该智能体仅在私密广场显示,需输入密码才能查看;与「公开发布」互斥。
</p>
</div>
<Switch
checked={editIsPrivate}
onCheckedChange={(v) => {
setEditIsPrivate(v);
if (v) {
// Mutual-exclusion: switching the agent into the private
// square automatically clears the public-publish flag so
// the user can't end up with a half-and-half state.
setEditPublished(false);
}
}}
/>
</div>
</div>
@@ -1,44 +1,141 @@
"use client";
import { BotIcon, PlusIcon } from "lucide-react";
import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { useAgents, usePrivateAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
import { desensitize } from "@/lib/desensitize";
import { AgentCard } from "./agent-card";
// Within a single tab the verified password is kept in sessionStorage so a
// refresh / route navigation does not re-prompt. Cleared on tab close, so the
// password never persists beyond the browsing session.
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
export function AgentGallery() {
const { t } = useI18n();
const router = useRouter();
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
"mine",
);
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
// Name + tag filtering is resolved server-side via the /api/agents query.
const { agents, isLoading } = useAgents({ search, tagIds });
// Tag filtering stays server-side; name search must run on the *desensitized*
// form because that's what the user sees on the card. If we sent the search
// string to the backend it would compare against the raw stored name and
// miss every desensitized hit (e.g. typing "TS分析" wouldn't match the
// stored "态势分析").
const { agents, isLoading } = useAgents({ tagIds });
const needle = search.trim().toLowerCase();
const matchesSearch = (a: Agent) =>
!needle || desensitize(a.name).toLowerCase().includes(needle);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
const privateFeatureUsable =
privateFeatureAvailable && !!privateStatus?.password_set;
const [privatePassword, setPrivatePassword] = useState<string | null>(() => {
if (typeof window === "undefined") return null;
return window.sessionStorage.getItem(PRIVATE_SQUARE_PASSWORD_KEY);
});
const [passwordInput, setPasswordInput] = useState("");
const {
agents: privateAgents,
isLoading: isPrivateLoading,
error: privateError,
} = usePrivateAgents(privatePassword);
// If the server reports the stored password no longer works (likely the
// admin rotated it), drop it from session storage and re-prompt.
useEffect(() => {
if (privatePassword && privateError) {
window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY);
setPrivatePassword(null);
toast.error("私密广场密码已失效,请重新输入");
}
}, [privateError, privatePassword]);
// The "广场" tab must never surface private-square agents, even to their
// own owner — moving an agent into the private square is supposed to hide
// it from the public square entirely. (The backend listing endpoint
// already filters them out for non-owners; this extra clause covers the
// owner's own private agents, which still come back in the unified list
// because owners always see their own.)
const allAgents = useMemo(
() => agents.filter((a) => a.builtin || a.published),
[agents],
() =>
agents.filter(
(a) => (a.builtin || a.published) && !a.is_private && matchesSearch(a),
),
// matchesSearch closes over `needle`; depending on `search` is sufficient
// and avoids re-running on every render via the function reference.
// eslint-disable-next-line react-hooks/exhaustive-deps
[agents, search],
);
// "我的" shows the user's own agents plus any agent they've favorited (收藏)
// from the square — favorited public agents have a different user_id but a
// truthy is_favorite flag.
const myAgents = useMemo(
() => agents.filter((a) => a.user_id !== null && a.user_id === currentUserId),
[agents, currentUserId],
() =>
agents.filter(
(a) =>
((a.user_id !== null && a.user_id === currentUserId) ||
a.is_favorite) &&
matchesSearch(a),
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[agents, currentUserId, search],
);
const filteredPrivateAgents = useMemo(() => {
const wanted = new Set(tagIds);
return privateAgents.filter((a) => {
if (!matchesSearch(a)) return false;
if (wanted.size > 0) {
if (!(a.tags ?? []).some((tg) => wanted.has(tg.id))) return false;
}
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [privateAgents, search, tagIds]);
const handleNewAgent = () => {
router.push("/page/workspace/agents/new");
};
const handleSubmitPassword = () => {
const trimmed = passwordInput.trim();
if (!trimmed) {
toast.error("请输入密码");
return;
}
window.sessionStorage.setItem(PRIVATE_SQUARE_PASSWORD_KEY, trimmed);
setPrivatePassword(trimmed);
setPasswordInput("");
};
const handleLockPrivate = () => {
window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY);
setPrivatePassword(null);
};
const renderGrid = (list: typeof agents) => (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((agent) => (
@@ -71,6 +168,78 @@ export function AgentGallery() {
</div>
);
const renderPrivateTab = () => {
if (!privateFeatureAvailable) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<ShieldIcon className="text-muted-foreground h-7 w-7" />
</div>
<p className="font-medium">私密广场尚未开启</p>
<p className="text-muted-foreground max-w-sm text-sm">
请管理员前往「综合管理 → 私密广场配置」开启该功能并设置密码。
</p>
</div>
);
}
if (!privateFeatureUsable) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<ShieldIcon className="text-muted-foreground h-7 w-7" />
</div>
<p className="font-medium">私密广场未配置密码</p>
<p className="text-muted-foreground max-w-sm text-sm">
请管理员在私密广场配置页设置一个访问密码。
</p>
</div>
);
}
if (!privatePassword) {
return (
<div className="mx-auto flex w-full max-w-sm flex-col gap-4 pt-12 text-center">
<div className="bg-muted mx-auto flex h-14 w-14 items-center justify-center rounded-full">
<LockIcon className="text-muted-foreground h-7 w-7" />
</div>
<div>
<p className="font-medium">私密广场</p>
<p className="text-muted-foreground mt-1 text-sm">
请输入访问密码,验证通过后才能查看其中的智能体。
</p>
</div>
<div className="flex flex-col gap-2">
<Input
type="password"
placeholder="访问密码"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmitPassword();
}}
/>
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
</div>
</div>
);
}
if (isPrivateLoading) {
return (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
);
}
if (filteredPrivateAgents.length === 0) {
return renderEmpty("私密广场暂时还没有智能体。");
}
return renderGrid(filteredPrivateAgents);
};
return (
<div className="flex size-full flex-col">
{/* Page header */}
@@ -90,14 +259,35 @@ export function AgentGallery() {
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
onValueChange={(v) =>
setActiveTab(v as "all" | "mine" | "private")
}
className="flex flex-1 flex-col overflow-hidden"
>
<div className="mx-6 mt-4 flex flex-col gap-3">
<TabsList className="w-auto self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger>
</TabsList>
<div className="flex items-center justify-between gap-3">
<TabsList className="w-auto self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger>
{privateFeatureAvailable && (
<TabsTrigger value="private">
<LockIcon className="mr-1 h-3.5 w-3.5" />
私密广场
</TabsTrigger>
)}
</TabsList>
{activeTab === "private" && privatePassword && (
<Button
variant="outline"
size="sm"
onClick={handleLockPrivate}
title="锁定私密广场,下次需要重新输入密码"
>
<LockIcon className="mr-1.5 h-3.5 w-3.5" />
锁定
</Button>
)}
</div>
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
@@ -129,6 +319,15 @@ export function AgentGallery() {
renderGrid(myAgents)
)}
</TabsContent>
{privateFeatureAvailable && (
<TabsContent
value="private"
className="flex-1 overflow-y-auto p-6 mt-0"
>
{renderPrivateTab()}
</TabsContent>
)}
</Tabs>
</div>
);
@@ -61,7 +61,12 @@ export function AgentManageDialog({
const candidates = useMemo(() => {
const existing = new Set(agents.map((a) => a.id));
return allAgents.filter((a) => a.published && !existing.has(a.id));
// Private-square agents are hidden from the homepage selector even if
// they're published — the backend ``list_featured`` filter would drop
// them again on the way out, so offering them here is just confusing.
return allAgents.filter(
(a) => a.published && !a.is_private && !existing.has(a.id),
);
}, [agents, allAgents]);
const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
@@ -32,7 +32,10 @@ interface ArtifactsProviderProps {
}
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifacts] = useState<string[]>([]);
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
setArtifactsState(Array.isArray(next) ? next : []);
}, []);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [open, setOpen] = useState(
@@ -1,183 +1,769 @@
import { FilesIcon, XIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import {
ArtifactFileDetail,
ArtifactFileList,
useArtifacts,
} from "../artifacts";
import { useThread } from "../messages/context";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
children,
threadId,
}) => {
const { thread } = useThread();
const pathname = usePathname();
const threadIdRef = useRef(threadId);
const layoutRef = useRef<GroupImperativeHandle>(null);
const {
artifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
setArtifacts,
select: selectArtifact,
deselect,
selectedArtifact,
} = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => {
if (threadIdRef.current !== threadId) {
threadIdRef.current = threadId;
deselect();
}
// Update artifacts from the current thread
setArtifacts(thread.values.artifacts);
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
// if (
// selectedArtifact &&
// !thread.values.artifacts?.includes(selectedArtifact)
// ) {
// deselect();
// }
if (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
}
}
}, [
threadId,
autoSelectFirstArtifact,
deselect,
selectArtifact,
selectedArtifact,
setArtifacts,
thread.values.artifacts,
]);
const artifactPanelOpen = useMemo(() => {
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
return artifactsOpen && artifacts?.length > 0;
}
return artifactsOpen;
}, [artifactsOpen, artifacts]);
const resizableIdBase = useMemo(() => {
const raw = pathname
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
return raw.length > 0 ? raw : `thread-${threadId}`;
}, [pathname, threadId]);
useEffect(() => {
if (layoutRef.current) {
if (artifactPanelOpen) {
layoutRef.current.setLayout(OPEN_MODE);
} else {
layoutRef.current.setLayout(CLOSE_MODE);
}
}
}, [artifactPanelOpen]);
return (
<ResizablePanelGroup
id={`${resizableIdBase}-panels`}
orientation="horizontal"
defaultLayout={{ chat: 100, artifacts: 0 }}
groupRef={layoutRef}
>
<ResizablePanel className="relative" defaultSize={100} id="chat">
{children}
</ResizablePanel>
<ResizableHandle
id={`${resizableIdBase}-separator`}
className={cn(
"opacity-33 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0",
)}
id="artifacts"
>
<div
className={cn(
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
{selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
{thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId}
/>
</main>
</div>
)}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
};
export { ChatBox };
import type { Message } from "@langchain/langgraph-sdk";
import { FilesIcon, XIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import {
ArtifactFileDetail,
ArtifactFileList,
useArtifacts,
} from "../artifacts";
import { useThread } from "../messages/context";
import { SkillDisplaySources } from "../skill-display-sources";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
type BackendReferenceBatch = {
id: string;
assistant_message_id?: string;
sources: BackendReferenceSource[];
};
function logReferenceDebug(
event: string,
threadId: string,
payload: Record<string, unknown>,
) {
console.info(`[RAG引用调试] ${event}`, payload);
void apiFetch(`${getBackendBaseURL()}/api/threads/reference-debug-log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event,
thread_id: threadId,
payload,
}),
}).catch(() => {
// Debug logging must never affect the chat flow.
});
}
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
children,
threadId,
}) => {
const { thread } = useThread();
const pathname = usePathname();
const threadIdRef = useRef(threadId);
const artifactsCountRef = useRef(0);
const layoutRef = useRef<GroupImperativeHandle>(null);
const latestReferenceBatchIdRef = useRef<string | null>(null);
const manualReferenceBatchRef = useRef(false);
const referenceDebugSignatureRef = useRef<string>("");
const [sideTab, setSideTab] = useState("document");
const [referencesOpen, setReferencesOpen] = useState(true);
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const { skills } = useSkills();
const {
artifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
setArtifacts,
select: selectArtifact,
deselect,
selectedArtifact,
} = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
const fallbackReferenceBatches = useMemo(() => {
if (!ragModeEnabled) {
return [];
}
return buildTurnReferenceBatches(
thread.values.messages ?? [],
skillDisplayMap,
thread.isLoading,
);
}, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
useEffect(() => {
if (!ragModeEnabled) {
setBackendReferenceBatches([]);
return;
}
if (thread.isLoading) {
return;
}
const controller = new AbortController();
logReferenceDebug("开始拉取后端参考文献", threadId, {
messageCount: thread.values.messages?.length ?? 0,
});
apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/reference-batches`,
{ signal: controller.signal },
)
.then(async (response) => {
if (!response.ok) {
throw new Error(`Failed to load reference batches: ${response.status}`);
}
return (await response.json()) as { batches?: BackendReferenceBatch[] };
})
.then((data) => {
const batches = data.batches ?? [];
logReferenceDebug("后端参考文献返回", threadId, {
batchCount: batches.length,
sourceCounts: batches.map((batch) => batch.sources?.length ?? 0),
batchIds: batches.map((batch) => batch.id),
});
setBackendReferenceBatches(batches);
})
.catch((error) => {
if (!controller.signal.aborted) {
console.warn("Failed to load backend reference batches", error);
}
});
return () => controller.abort();
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
const adaptedBackendReferenceBatches = useMemo(
() =>
adaptBackendReferenceBatches(
backendReferenceBatches,
skillDisplayMap,
),
[backendReferenceBatches, skillDisplayMap],
);
const referenceBatches = useMemo(
() => {
if (adaptedBackendReferenceBatches.length > 0) {
return adaptedBackendReferenceBatches;
}
return fallbackReferenceBatches;
},
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
);
useEffect(() => {
if (!ragModeEnabled) {
return;
}
const signature = JSON.stringify({
backend: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
adaptedBackend: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
fallback: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
final: referenceBatches.map((batch) => batch.sources?.length ?? 0),
});
if (referenceDebugSignatureRef.current === signature) {
return;
}
referenceDebugSignatureRef.current = signature;
logReferenceDebug("前端参考文献来源选择", threadId, {
using: backendReferenceBatches.length > 0 ? "后端接口" : "前端兜底",
backendBatchCount: backendReferenceBatches.length,
backendSourceCounts: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
adaptedBackendSourceCounts: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
fallbackBatchCount: fallbackReferenceBatches.length,
fallbackSourceCounts: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
finalBatchCount: referenceBatches.length,
finalSourceCounts: referenceBatches.map((batch) => batch.sources?.length ?? 0),
backendBatches: debugReferenceBatches(backendReferenceBatches),
adaptedBackendBatches: debugReferenceBatches(adaptedBackendReferenceBatches),
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
finalBatches: debugReferenceBatches(referenceBatches),
});
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
const latestReferenceBatch =
referenceBatches.length > 0
? referenceBatches[referenceBatches.length - 1]!
: null;
const activeReferenceBatch =
referenceBatches.find((batch) => batch.id === activeReferenceBatchId) ??
latestReferenceBatch;
const referenceSources = activeReferenceBatch?.sources ?? [];
const hasCitationReferences = referenceSources.length > 0;
useEffect(() => {
if (!ragModeEnabled || !activeReferenceBatch) {
return;
}
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
activeBatchId: activeReferenceBatch.id,
assistantMessageId: activeReferenceBatch.assistant_message_id,
sourceCount: activeReferenceBatch.sources.length,
sources: activeReferenceBatch.sources.map(debugReferenceSource),
});
}, [activeReferenceBatch, ragModeEnabled, threadId]);
useEffect(() => {
if (hasCitationReferences) {
setReferencesOpen(true);
}
}, [hasCitationReferences]);
useEffect(() => {
if (!latestReferenceBatch) {
setActiveReferenceBatchId(null);
latestReferenceBatchIdRef.current = null;
manualReferenceBatchRef.current = false;
return;
}
const previousLatestId = latestReferenceBatchIdRef.current;
latestReferenceBatchIdRef.current = latestReferenceBatch.id;
setActiveReferenceBatchId((current) =>
current &&
referenceBatches.some((batch) => batch.id === current) &&
manualReferenceBatchRef.current &&
current !== previousLatestId
? current
: latestReferenceBatch.id,
);
}, [latestReferenceBatch?.id, referenceBatches]);
useEffect(() => {
const handleShowReference = (event: Event) => {
const detail = (event as CustomEvent<{ batchId?: string; referenceId?: string }>).detail;
if (detail?.batchId) {
manualReferenceBatchRef.current = true;
setActiveReferenceBatchId(detail.batchId);
}
setPinnedReferenceId(null);
window.setTimeout(() => {
setPinnedReferenceId(detail?.referenceId ?? null);
}, 0);
setReferencesOpen(true);
setSideTab("references");
};
window.addEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
return () => {
window.removeEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
};
}, []);
useEffect(() => {
if (threadIdRef.current !== threadId) {
threadIdRef.current = threadId;
artifactsCountRef.current = 0;
deselect();
setReferencesOpen(false);
setActiveReferenceBatchId(null);
setPinnedReferenceId(null);
setBackendReferenceBatches([]);
latestReferenceBatchIdRef.current = null;
manualReferenceBatchRef.current = false;
referenceDebugSignatureRef.current = "";
}
// Update artifacts from the current thread
const nextArtifacts = thread.values.artifacts ?? [];
const hadArtifacts = artifactsCountRef.current > 0;
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
artifactsCountRef.current = nextArtifacts.length;
setArtifacts(nextArtifacts);
if ((hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) && !selectedArtifact) {
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
setArtifactsOpen(true);
setSideTab("document");
}
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
// if (
// selectedArtifact &&
// !thread.values.artifacts?.includes(selectedArtifact)
// ) {
// deselect();
// }
if (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
}
}
}, [
threadId,
autoSelectFirstArtifact,
deselect,
selectArtifact,
selectedArtifact,
setArtifacts,
thread.values.artifacts,
]);
const artifactPanelOpen = useMemo(() => {
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
return artifactsOpen && artifacts?.length > 0;
}
return artifactsOpen;
}, [artifactsOpen, artifacts]);
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
const showTabs = !!selectedArtifact && hasCitationReferences;
useEffect(() => {
if (selectedArtifact) {
setSideTab("document");
} else if (hasCitationReferences) {
setSideTab("references");
}
}, [hasCitationReferences, selectedArtifact]);
const resizableIdBase = useMemo(() => {
const raw = pathname
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
return raw.length > 0 ? raw : `thread-${threadId}`;
}, [pathname, threadId]);
useEffect(() => {
if (layoutRef.current) {
if (sidePanelOpen) {
layoutRef.current.setLayout(OPEN_MODE);
} else {
layoutRef.current.setLayout(CLOSE_MODE);
}
}
}, [sidePanelOpen]);
return (
<ResizablePanelGroup
id={`${resizableIdBase}-panels`}
orientation="horizontal"
defaultLayout={{ chat: 100, artifacts: 0 }}
groupRef={layoutRef}
>
<ResizablePanel className="relative" defaultSize={100} id="chat">
{children}
</ResizablePanel>
<ResizableHandle
id={`${resizableIdBase}-separator`}
className={cn(
"opacity-33 hover:opacity-100",
!sidePanelOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
!sidePanelOpen && "opacity-0",
)}
id="artifacts"
>
<div
className={cn(
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
sidePanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
{showTabs ? (
<Tabs value={sideTab} onValueChange={setSideTab} className="flex size-full flex-col overflow-hidden">
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
<TabsList variant="line">
<TabsTrigger value="document">文档</TabsTrigger>
<TabsTrigger value="references">参考文献</TabsTrigger>
</TabsList>
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setReferencesOpen(false);
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
</TabsContent>
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
</TabsContent>
</Tabs>
) : selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
) : hasCitationReferences ? (
<div className="relative size-full overflow-hidden">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setReferencesOpen(false);
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
</div>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
{thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId}
/>
</main>
</div>
)}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
};
function ReferencePanel({
highlightedReferenceId,
sources,
}: {
highlightedReferenceId?: string | null;
sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
}) {
const orderedSources = useMemo(() => {
if (!highlightedReferenceId) {
return sources;
}
const index = Number(highlightedReferenceId);
if (!Number.isFinite(index)) {
return sources;
}
const selected = sources.find((source) => source.index === index);
if (!selected) {
return sources;
}
return [selected, ...sources.filter((source) => source.index !== index)];
}, [highlightedReferenceId, sources]);
return (
<div className="flex size-full flex-col overflow-hidden">
<header className="shrink-0 px-1 pb-3">
<div className="flex items-baseline gap-2">
<h2 className="text-foreground text-base font-semibold tracking-tight">
参考文献
</h2>
<span className="text-muted-foreground text-xs tabular-nums">
{sources.length} 条
</span>
</div>
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
点击编号或卡片可查看引用详情。
</p>
</header>
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-1 pb-6">
<SkillDisplaySources
highlightedReferenceId={highlightedReferenceId}
sources={orderedSources}
/>
</main>
</div>
);
}
function debugReferenceBatches(batches: BackendReferenceBatch[]) {
return batches.map((batch) => ({
id: batch.id,
assistantMessageId: batch.assistant_message_id,
sourceCount: batch.sources?.length ?? 0,
sources: (batch.sources ?? []).map(debugReferenceSource),
}));
}
function debugReferenceSource(source: BackendReferenceSource) {
const raw =
source.raw && typeof source.raw === "object" && !Array.isArray(source.raw)
? (source.raw as Record<string, unknown>)
: {};
return {
index: source.index,
id: source.id,
skillName: source.skillName,
mode: source.mode,
title: source.title,
source: source.source,
author: source.author,
time: source.time,
url: source.url,
snippet: source.snippet,
content: source.content,
rawIdCandidates: {
recUuid: raw.recUuid,
rec_uuid: raw.rec_uuid,
uuid: raw.uuid,
docId: raw.docId,
doc_id: raw.doc_id,
id: raw.id,
url: raw.url,
},
raw: source.raw,
};
}
function adaptBackendReferenceBatches(
backendBatches: BackendReferenceBatch[],
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
): BackendReferenceBatch[] {
if (backendBatches.length === 0) {
return backendBatches;
}
return backendBatches.map((backendBatch) => {
return {
...backendBatch,
sources: backendBatch.sources.map((backendSource) => {
const configuredSource = adaptBackendSourceWithSkillDisplay(
backendSource,
skillDisplayMap,
);
if (!configuredSource) {
return backendSource;
}
return {
...backendSource,
...configuredSource,
index: backendSource.index,
mode:
backendSource.mode !== "citation"
? backendSource.mode
: configuredSource.mode || backendSource.mode,
raw: backendSource.raw ?? configuredSource.raw,
};
}),
};
});
}
function adaptBackendSourceWithSkillDisplay(
source: BackendReferenceSource,
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
): BackendReferenceSource | null {
const raw = source.raw;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const rawItem = raw as Record<string, unknown>;
const match = findBackendSourceDisplay(source, skillDisplayMap);
if (!match) {
return null;
}
const normalized = normalizeDisplaySources(
buildPayloadAtPath(match.display.resultPath || "results", [rawItem]),
match.display,
)[0];
if (!normalized) {
return null;
}
return {
...source,
...normalized,
skillName: match.skillName,
mode: match.display.mode,
tableColumns: match.display.tableColumns,
index: source.index,
raw: source.raw,
};
}
function findBackendSourceDisplay(
source: BackendReferenceSource,
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
) {
const direct = skillDisplayMap.get(source.skillName);
if (direct) {
return { skillName: source.skillName, display: direct };
}
const normalizedSource = normalizeSkillName(source.skillName);
for (const [skillName, display] of skillDisplayMap) {
const normalizedSkill = normalizeSkillName(skillName);
if (
normalizedSource === normalizedSkill ||
normalizedSource.endsWith(normalizedSkill) ||
normalizedSource.includes(normalizedSkill)
) {
return { skillName, display };
}
}
return null;
}
function normalizeSkillName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function buildPayloadAtPath(path: string, items: Record<string, unknown>[]) {
const parts = path.split(".").map((part) => part.trim()).filter(Boolean);
if (parts.length === 0) {
return items;
}
const root: Record<string, unknown> = {};
let cursor = root;
for (let index = 0; index < parts.length; index++) {
const part = parts[index]!;
if (index === parts.length - 1) {
cursor[part] = items;
} else {
const next: Record<string, unknown> = {};
cursor[part] = next;
cursor = next;
}
}
return root;
}
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
): string | undefined {
const beforeIndex =
beforeMessageId === undefined
? messages.length
: messages.findIndex((message) => message.id === beforeMessageId);
const end = beforeIndex < 0 ? messages.length : beforeIndex;
for (let index = end - 1; index >= 0; index--) {
const message = messages[index];
if (message?.type === "human") {
return message.id;
}
}
return undefined;
}
function buildTurnReferenceBatches(
messages: Message[],
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
isLoading: boolean,
): BackendReferenceBatch[] {
const batches: BackendReferenceBatch[] = [];
for (let index = 0; index < messages.length; index++) {
const human = messages[index];
if (human?.type !== "human" || !human.id) {
continue;
}
const nextHumanIndex = findNextHumanIndex(messages, index + 1);
if (nextHumanIndex === -1 && isLoading) {
continue;
}
const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex;
const assistant = findLastAssistantAnswer(messages, index + 1, endIndex);
if (!assistant?.id) {
continue;
}
const sources = collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: human.id,
beforeMessageId:
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
includeBeforeMessage: false,
}).filter((source) => source.mode !== "none");
if (sources.length > 0) {
batches.push({
id: assistant.id,
assistant_message_id: assistant.id,
sources,
});
}
}
return batches;
}
function findNextHumanIndex(messages: Message[], start: number): number {
for (let index = start; index < messages.length; index++) {
if (messages[index]?.type === "human") {
return index;
}
}
return -1;
}
function findLastAssistantAnswer(
messages: Message[],
start: number,
end: number,
): Message | null {
for (let index = end - 1; index >= start; index--) {
const message = messages[index];
if (
message?.type === "ai" &&
message.id &&
hasAssistantAnswerContent(message)
) {
return message;
}
}
return null;
}
function hasAssistantAnswerContent(message: Message): boolean {
const content = message.content;
if (typeof content === "string") {
return content.trim().length > 0;
}
if (!Array.isArray(content)) {
return false;
}
return content.some((part) => {
if (!part || typeof part !== "object") {
return false;
}
const block = part as Record<string, unknown>;
return block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0;
});
}
export { ChatBox };
@@ -2,12 +2,19 @@
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { uuid } from "@/core/utils/uuid";
export function useThreadChat() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const pathname = usePathname();
// ``location.key`` is regenerated on every react-router navigation, even when
// the resolved pathname is identical. We depend on it so that clicking
// "新对话" while already at /chats/new still triggers a fresh thread reset
// (otherwise navigate-to-same-path is silently a no-op and the in-flight
// conversation would persist on screen).
const locationKey = useLocation().key;
const searchParams = useSearchParams();
const [threadId, setThreadId] = useState(() => {
@@ -37,7 +44,7 @@ export function useThreadChat() {
}
setIsNewThread(false);
setThreadId(threadIdFromPath);
}, [pathname, threadIdFromPath]);
}, [pathname, threadIdFromPath, locationKey]);
const isMock = searchParams.get("mock") === "true";
return { threadId, setThreadId, isNewThread, setIsNewThread, isMock };
}
@@ -1,82 +1,77 @@
import { ExternalLinkIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { Badge } from "@/components/ui/badge";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
export function CitationLink({
href,
children,
...props
}: ComponentProps<"a">) {
const domain = extractDomain(href ?? "");
// Priority: children > domain
const childrenText =
typeof children === "string"
? children.replace(/^citation:\s*/i, "")
: null;
const isGenericText = childrenText === "Source" || childrenText === "来源";
const displayText = (!isGenericText && childrenText) ?? domain;
return (
<HoverCard closeDelay={0} openDelay={0}>
<HoverCardTrigger asChild>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
onClick={(e) => e.stopPropagation()}
{...props}
>
<Badge
variant="secondary"
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
>
{displayText}
<ExternalLinkIcon className="size-3" />
</Badge>
</a>
</HoverCardTrigger>
<HoverCardContent className={cn("relative w-80 p-0", props.className)}>
<div className="p-3">
<div className="space-y-1">
{displayText && (
<h4 className="truncate text-sm leading-tight font-medium">
{displayText}
</h4>
)}
{href && (
<p className="text-muted-foreground truncate text-xs break-all">
{href}
</p>
)}
</div>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
>
Visit source
<ExternalLinkIcon className="size-3" />
</a>
</div>
</HoverCardContent>
</HoverCard>
);
}
function extractDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./i, "");
} catch {
return url;
}
}
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
export function CitationLink({
href,
children,
className,
...props
}: ComponentProps<"a">) {
const childrenText = normalizeCitationText(children);
const displayText = childrenText ?? "";
const referenceId = href?.startsWith("#reference-")
? href.slice("#reference-".length)
: null;
if (referenceId) {
const badgeLabel = displayText.replace(/^\[|\]$/g, "") || referenceId;
return (
<a
href={href}
title={`查看参考文献 ${badgeLabel}`}
className={cn(
"border-primary/30 bg-primary/10 text-primary hover:border-primary/60 hover:bg-primary/20 focus-visible:ring-primary/30 mx-0.5 inline-flex h-[18px] min-w-[18px] -translate-y-[1px] items-center justify-center rounded-full border px-1 text-[10.5px] font-semibold tabular-nums no-underline transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
onClick={(event) => {
event.preventDefault();
const batchId =
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
?.dataset.referenceBatchId;
window.dispatchEvent(
new CustomEvent(SHOW_REFERENCE_EVENT, {
detail: { batchId, referenceId },
}),
);
const scroll = () => {
const target = document.getElementById(`reference-${referenceId}`);
target?.scrollIntoView({ behavior: "smooth", block: "center" });
};
window.setTimeout(scroll, 240);
}}
{...props}
>
{badgeLabel}
</a>
);
}
return (
<a
href={href}
target={href?.startsWith("http") ? "_blank" : undefined}
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
className={cn(
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
)}
{...props}
>
{displayText || children}
</a>
);
}
function normalizeCitationText(children: ComponentProps<"a">["children"]) {
if (typeof children !== "string") {
return null;
}
let text = children.trim();
while (/^citation:\s*/i.test(text)) {
text = text.replace(/^citation:\s*/i, "");
}
return text;
}
@@ -0,0 +1,294 @@
"use client";
import {
ChevronLeftIcon,
ChevronRightIcon,
PencilIcon,
Settings2Icon,
Trash2Icon,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
useCreateCustomPrompt,
useCustomPrompts,
useDeleteCustomPrompt,
useUpdateCustomPrompt,
} from "@/core/custom-prompts";
import { cn } from "@/lib/utils";
/**
* Bar above the chat input showing the user's saved prompts as centered,
* clickable chips. When there are too many to fit, they become a horizontal
* carousel (arrows appear). A gear pinned to the far right opens the manage
* dialog. Prompts are bound to the account (server-backed), and this coexists
* with the admin-configured global prompt prefix.
*/
export function CustomPromptBar({
onInsert,
className,
}: {
onInsert: (content: string) => void;
className?: string;
}) {
const prompts = useCustomPrompts();
const [manageOpen, setManageOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const [canLeft, setCanLeft] = useState(false);
const [canRight, setCanRight] = useState(false);
const updateArrows = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanLeft(el.scrollLeft > 4);
setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
}, []);
useEffect(() => {
updateArrows();
window.addEventListener("resize", updateArrows);
return () => window.removeEventListener("resize", updateArrows);
}, [updateArrows, prompts]);
const scrollByDir = (dir: 1 | -1) => {
scrollRef.current?.scrollBy({ left: dir * 220, behavior: "smooth" });
};
const overflowing = canLeft || canRight;
return (
<>
<div className={cn("flex items-center gap-1", className)}>
{prompts.length > 0 && (
<>
{overflowing && (
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
disabled={!canLeft}
onClick={() => scrollByDir(-1)}
aria-label="上一组"
>
<ChevronLeftIcon className="size-4" />
</Button>
)}
<div
ref={scrollRef}
onScroll={updateArrows}
className="flex-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<div className="mx-auto flex w-max items-center gap-1.5 px-0.5 py-0.5">
{prompts.map((prompt) => (
<button
key={prompt.id}
type="button"
onClick={() => onInsert(prompt.content)}
title={prompt.content}
className={cn(
"border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors",
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
)}
>
{prompt.title || prompt.content || "未命名提示词"}
</button>
))}
</div>
</div>
{overflowing && (
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
disabled={!canRight}
onClick={() => scrollByDir(1)}
aria-label="下一组"
>
<ChevronRightIcon className="size-4" />
</Button>
)}
</>
)}
{prompts.length === 0 && <div className="flex-1" />}
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
title="管理常用提示词"
aria-label="管理常用提示词"
onClick={() => setManageOpen(true)}
>
<Settings2Icon className="size-4" />
</Button>
</div>
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
</>
);
}
function ManagePromptsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const prompts = useCustomPrompts();
const createPrompt = useCreateCustomPrompt();
const updatePrompt = useUpdateCustomPrompt();
const deletePrompt = useDeleteCustomPrompt();
const [editingId, setEditingId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const saving = createPrompt.isPending || updatePrompt.isPending;
const resetForm = () => {
setEditingId(null);
setTitle("");
setContent("");
};
const handleSubmit = async () => {
const trimmedTitle = title.trim();
if (!content.trim()) {
toast.error("提示词内容不能为空");
return;
}
try {
if (editingId) {
await updatePrompt.mutateAsync({
id: editingId,
input: { title: trimmedTitle, content },
});
toast.success("提示词已更新");
} else {
await createPrompt.mutateAsync({ title: trimmedTitle, content });
toast.success("提示词已添加");
}
resetForm();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const handleEdit = (id: string) => {
const target = prompts.find((p) => p.id === id);
if (!target) return;
setEditingId(id);
setTitle(target.title);
setContent(target.content);
};
const handleDelete = async (id: string) => {
try {
await deletePrompt.mutateAsync(id);
if (editingId === id) resetForm();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) resetForm();
onOpenChange(next);
}}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>管理常用提示词</DialogTitle>
<DialogDescription>
保存常用的提示词,保存后会以小标签形式显示在输入框上方,点击即可填入。提示词与你的账号绑定,可跨设备使用。
</DialogDescription>
</DialogHeader>
<div className="space-y-3 rounded-md border p-3">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="名称(可选,例如:周报总结)"
/>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="提示词内容"
className="min-h-28"
/>
<div className="flex justify-end gap-2">
{editingId && (
<Button variant="outline" size="sm" onClick={resetForm} disabled={saving}>
取消编辑
</Button>
)}
<Button size="sm" onClick={handleSubmit} disabled={saving}>
{editingId ? "保存修改" : "添加提示词"}
</Button>
</div>
</div>
<div className="space-y-2">
{prompts.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
暂无常用提示词
</p>
) : (
prompts.map((prompt) => (
<div
key={prompt.id}
className="flex items-start justify-between gap-2 rounded-md border p-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{prompt.title || "未命名提示词"}
</p>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs whitespace-pre-wrap">
{prompt.content}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => handleEdit(prompt.id)}
title="编辑"
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8"
onClick={() => void handleDelete(prompt.id)}
title="删除"
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -3,6 +3,7 @@
import type { ChatStatus } from "ai";
import {
BrainCircuitIcon,
BookMarkedIcon,
CheckIcon,
FileTextIcon,
GraduationCapIcon,
@@ -86,8 +87,14 @@ import {
} from "../ui/dropdown-menu";
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
@@ -177,6 +184,7 @@ export function InputBox({
};
}) {
const { t } = useI18n();
const [localSettings, setLocalSettings] = useLocalSettings();
const searchParams = useSearchParams();
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const { models } = useModels();
@@ -297,6 +305,15 @@ export function InputBox({
});
}, [context, memoryInjectionEnabled, onContextChange]);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => {
if (ragModeLocked) {
return;
}
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (status === "streaming") {
@@ -309,6 +326,9 @@ export function InputBox({
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
// Guard against submitting before the initial model auto-selection
// effect has flushed thread settings to storage/state.
@@ -333,8 +353,10 @@ export function InputBox({
onSubmit,
onStop,
resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking,
status,
threadId,
],
);
@@ -538,6 +560,21 @@ export function InputBox({
</div>
</div>
)}
<div className="flex flex-col gap-1.5">
{isNewThread && (
<CustomPromptBar
className="px-1"
onInsert={(content) => {
const current = textInput.value?.trim() ?? "";
textInput.setInput(current ? `${current}\n${content}` : content);
setTimeout(() => {
document
.querySelector<HTMLTextAreaElement>("textarea[name='message']")
?.focus();
}, 0);
}}
/>
)}
<div className={cn(isNewThread && "gemini-gradient-border gemini-shadow")}>
<PromptInput
className={cn(
@@ -622,6 +659,33 @@ export function InputBox({
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>
<Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
>
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleRagModeToggle}
>
{ragModeEnabled && (
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
)}
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu>
<ModeHoverGuide
mode={
@@ -997,6 +1061,7 @@ export function InputBox({
)}
</PromptInput>
</div>
</div>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
@@ -36,10 +36,13 @@ export function MarkdownContent({
}: MarkdownContentProps) {
const components = useMemo(() => {
return {
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (props.href?.startsWith("#reference-")) {
return <CitationLink {...props} />;
}
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>;
}
@@ -47,6 +47,17 @@ import {
findToolCallResult,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
findDisplayConfigForTool,
isDisplaySamplePayload,
normalizeDefaultDisplaySources,
shouldUseDefaultCitationMode,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -54,6 +65,7 @@ import { cn } from "@/lib/utils";
import { useArtifactsOptional } from "../artifacts";
import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
@@ -74,6 +86,12 @@ export function MessageGroup({
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const steps = useMemo(() => convertToSteps(messages), [messages]);
const [settings] = useLocalSettings();
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[settings.display.ragModeEnabled, skills],
);
const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall");
return filteredSteps[filteredSteps.length - 1];
@@ -142,7 +160,13 @@ export function MessageGroup({
}
></ChainOfThoughtStep>
) : (
<ToolCall key={step.id} {...step} isLoading={isLoading} />
<ToolCall
key={step.id}
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled}
/>
),
)}
{lastToolCallStep && (
@@ -152,6 +176,8 @@ export function MessageGroup({
{...lastToolCallStep}
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled}
/>
</FlipDisplay>
)}
@@ -209,6 +235,8 @@ function ToolCall({
result,
isLast = false,
isLoading = false,
skillDisplayMap = new Map(),
ragModeEnabled = false,
}: {
id?: string;
messageId?: string;
@@ -217,11 +245,56 @@ function ToolCall({
result?: string | Record<string, unknown>;
isLast?: boolean;
isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
const artifacts = useArtifactsOptional();
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: displayMatch.display.mode,
}))
: [];
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
key={id}
label={
displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
: `获取 ${displayItems.length} 条结果`
}
icon={SearchIcon}
>
<DisplayResultsList results={displayItems} />
</ChainOfThoughtStep>
);
}
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
}));
return (
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
<DisplayResultsList results={defaultItems} />
</ChainOfThoughtStep>
);
}
if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
@@ -544,6 +617,28 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
);
}
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
<ChevronDownIcon
className={cn(
"size-3.5 transition-transform duration-200",
open && "rotate-180",
)}
/>
<span>{results.length} 条真实返回结果</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 flex flex-col gap-2">
{results.map((item) => (
<SourceCard key={`${item.skillName}-${item.index}`} source={item} />
))}
</CollapsibleContent>
</Collapsible>
);
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content;
@@ -1,5 +1,5 @@
import type { Message } from "@langchain/langgraph-sdk";
import { FileIcon, Loader2Icon } from "lucide-react";
import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react";
import {
memo,
useCallback,
@@ -25,6 +25,7 @@ import {
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useChatAskTimes } from "@/core/chat-timestamps";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -36,15 +37,20 @@ import {
type FileInMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import {
applyInlineCitations,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { humanMessagePlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { CopyButton } from "../copy-button";
import { SkillDisplayBlock } from "../skill-display-block";
import { ThreadContext } from "./context";
import { DownloadWordButton } from "./download-word-button";
import { MarkdownContent } from "./markdown-content";
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
import { MessageTokenUsage } from "./message-token-usage";
/**
@@ -76,6 +82,7 @@ export function MessageListItem({
isLoading,
threadId,
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
}: {
className?: string;
@@ -83,6 +90,7 @@ export function MessageListItem({
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const isHuman = message.type === "human";
@@ -101,6 +109,7 @@ export function MessageListItem({
isLoading={isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
/>
{!isLoading && (
@@ -162,6 +171,7 @@ function MessageContent_({
isLoading = false,
threadId,
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
}: {
className?: string;
@@ -169,6 +179,7 @@ function MessageContent_({
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
@@ -176,6 +187,7 @@ function MessageContent_({
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
);
const askTimes = useChatAskTimes();
const isHuman = message.type === "human";
const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo(
@@ -184,6 +196,12 @@ function MessageContent_({
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
return <CitationLink href={href} {...props} />;
}
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
return <CitationLink href={href} {...props} />;
}
if (href?.startsWith("/mnt/")) {
const url = resolveArtifact(href);
return (
@@ -201,6 +219,11 @@ function MessageContent_({
[threadId, resolveArtifact],
);
const messageTime = formatAskedTime(
message.additional_kwargs?.client_ts ??
(message.id ? askTimes[message.id] : undefined),
);
const rawContent = extractContentFromMessage(message);
const reasoningContent = extractReasoningContentFromMessage(message);
@@ -220,8 +243,8 @@ function MessageContent_({
if (isHuman) {
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
}
return rawContent ?? "";
}, [rawContent, isHuman]);
return applyInlineCitations(rawContent ?? "", displaySources);
}, [rawContent, isHuman, displaySources]);
const filesList =
files && files.length > 0 ? (
@@ -280,6 +303,7 @@ function MessageContent_({
{messageResponse}
</AIElementMessageContent>
)}
<MessageTimeStamp time={messageTime} align="right" />
</div>
);
}
@@ -287,22 +311,72 @@ function MessageContent_({
return (
<AIElementMessageContent className={className}>
{filesList}
<MarkdownContent
content={contentToDisplay}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
className="my-3"
components={components}
/>
<div data-reference-batch-id={message.id} className="contents">
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
components={components}
/>
</div>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message}
/>
{!isLoading && contentToDisplay ? (
<MessageTimeStamp time={messageTime} align="left" />
) : null}
</AIElementMessageContent>
);
}
/**
* Format a wall-clock message time as ``YYYY-MM-DD HH:mm`` for display, plus a
* full localized string for the hover title. Returns ``null`` when no/invalid
* timestamp is present.
*/
function formatAskedTime(
clientTs: unknown,
): { label: string; full: string } | null {
if (typeof clientTs !== "string" || !clientTs) return null;
const date = new Date(clientTs);
if (Number.isNaN(date.getTime())) return null;
const pad = (n: number) => String(n).padStart(2, "0");
const label =
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
` ${pad(date.getHours())}:${pad(date.getMinutes())}`;
return { label, full: date.toLocaleString() };
}
/**
* Small date+time label shown under a message. ``align`` controls the corner:
* ``right`` for the user's own question, ``left`` for the model's answer.
*/
function MessageTimeStamp({
time,
align = "right",
}: {
time: { label: string; full: string } | null;
align?: "left" | "right";
}) {
if (!time) return null;
return (
<div
className={cn(
"text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums",
align === "right" ? "justify-end pr-0.5" : "justify-start pl-0.5",
)}
title={time.full}
>
<ClockIcon className="size-3 shrink-0" />
<span>{time.label}</span>
</div>
);
}
/**
* Get file extension and check helpers
*/
@@ -470,3 +544,172 @@ function RichFileCard({
}
const MessageContent = memo(MessageContent_);
function AssistantDisplayContent({
content,
displaySources,
isLoading,
renderSkillDisplayBlocks,
rehypePlugins,
components,
}: {
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
renderSkillDisplayBlocks: boolean;
rehypePlugins: MarkdownContentProps["rehypePlugins"];
components: MarkdownContentProps["components"];
}) {
const { parts, placedKeys } = useMemo(
() =>
splitSkillDisplayPlaceholders(
content,
displaySources,
renderSkillDisplayBlocks,
),
[content, displaySources, renderSkillDisplayBlocks],
);
const fallbackBlocks = useMemo(() => {
if (!renderSkillDisplayBlocks) {
return [];
}
return groupDisplaySources(displaySources).filter(
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
);
}, [displaySources, placedKeys, renderSkillDisplayBlocks]);
return (
<>
{parts.map((part, index) =>
part.type === "markdown" ? (
<MarkdownContent
key={`markdown-${index}`}
content={part.content}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
className="my-3"
components={components}
/>
) : (
<SkillDisplayBlock
key={`display-${index}`}
mode={part.mode}
sources={part.sources}
/>
),
)}
{fallbackBlocks.map((group) => (
<SkillDisplayBlock
key={`fallback-${group.key}`}
mode={group.mode}
sources={group.sources}
/>
))}
</>
);
}
type DisplayPart =
| { type: "markdown"; content: string }
| {
type: "display";
key: string;
mode: RuntimeDisplaySource["mode"];
sources: RuntimeDisplaySource[];
};
function splitSkillDisplayPlaceholders(
content: string,
displaySources: RuntimeDisplaySource[],
renderSkillDisplayBlocks: boolean,
): { parts: DisplayPart[]; placedKeys: Set<string> } {
const pattern = /<skill-display\s+([^>]*?)\/>/g;
const parts: DisplayPart[] = [];
const placedKeys = new Set<string>();
let cursor = 0;
for (const match of content.matchAll(pattern)) {
const start = match.index ?? 0;
const before = content.slice(cursor, start);
if (before.trim()) {
parts.push({ type: "markdown", content: before });
}
const attrs = parseDisplayAttrs(match[1] ?? "");
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
if (group) {
placedKeys.add(group.key);
}
if (group && renderSkillDisplayBlocks) {
parts.push({
type: "display",
key: group.key,
mode: group.mode,
sources: group.sources,
});
}
cursor = start + match[0].length;
}
const rest = content.slice(cursor);
if (rest.trim() || parts.length === 0) {
parts.push({ type: "markdown", content: rest });
}
return { parts, placedKeys };
}
function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } {
const attrs: Record<string, string> = {};
for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) {
attrs[match[1]!] = match[2]!;
}
return {
tool: attrs.tool || attrs.skill || attrs.name,
mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined,
};
}
function findDisplayGroup(
sources: RuntimeDisplaySource[],
tool?: string,
mode?: RuntimeDisplaySource["mode"],
) {
return groupDisplaySources(sources).find((group) => {
if (mode && group.mode !== mode) {
return false;
}
if (!tool) {
return true;
}
const left = normalizeName(group.skillName);
const right = normalizeName(tool);
return left === right || left.includes(right) || right.includes(left);
});
}
function groupDisplaySources(sources: RuntimeDisplaySource[]) {
const groups = new Map<
string,
{
key: string;
skillName: string;
mode: RuntimeDisplaySource["mode"];
sources: RuntimeDisplaySource[];
}
>();
for (const source of sources) {
const key = `${source.skillName}:${source.mode}`;
const group =
groups.get(key) ??
{
key,
skillName: source.skillName,
mode: source.mode,
sources: [],
};
group.sources.push(source);
groups.set(key, group);
}
return [...groups.values()];
}
function normalizeName(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
@@ -1,6 +1,7 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Conversation,
@@ -8,6 +9,7 @@ import {
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes } from "@/core/chat-timestamps";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@@ -20,6 +22,12 @@ import {
hasToolCalls,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime";
import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
@@ -63,7 +71,6 @@ export function MessageList({
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) {
const { t } = useI18n();
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
@@ -72,6 +79,31 @@ export function MessageList({
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
// Stamp a wall-clock time onto every question/answer the first time it is
// seen, in a single batched commit. Questions sent this session already carry
// an accurate time (see useThreadStream); this also covers assistant answers
// and messages loaded from history. First-write-wins keeps the earliest time.
useEffect(() => {
const now = new Date().toISOString();
const entries: Record<string, string> = {};
for (const message of messages) {
if (message.type !== "human" && message.type !== "ai") continue;
if (!message.id || message.id.startsWith("opt-")) continue;
if (typeof message.additional_kwargs?.client_ts === "string") continue;
entries[message.id] = now;
}
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
}, [messages]);
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
// Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message
// (no user reply has been sent for them yet).
@@ -190,6 +222,16 @@ export function MessageList({
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
const displaySources =
ragModeEnabled &&
group.type === "assistant" &&
msg.type === "ai"
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
: [];
return (
<MessageListItem
key={`${group.id}/${msg.id}`}
@@ -197,6 +239,7 @@ export function MessageList({
isLoading={thread.isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
/>
);
@@ -442,6 +485,24 @@ export type SetupAgentApprovalPayload = {
skills?: string[];
};
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
): string | undefined {
const beforeIndex =
beforeMessageId === undefined
? messages.length
: messages.findIndex((message) => message.id === beforeMessageId);
const end = beforeIndex < 0 ? messages.length : beforeIndex;
for (let index = end - 1; index >= 0; index--) {
const message = messages[index];
if (message?.type === "human") {
return message.id;
}
}
return undefined;
}
function getSetupAgentApprovalPayload(
message: unknown,
): SetupAgentApprovalPayload | null {
@@ -163,7 +163,8 @@ export function RecentChatList() {
const visibleThreads = threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap",
thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
);
if (visibleThreads.length === 0) {
@@ -2,9 +2,11 @@
import { MoonIcon, SunIcon } from "lucide-react";
import { useMemo, type ComponentType, type SVGProps } from "react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
@@ -14,9 +16,17 @@ import {
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings";
import {
useAppearanceSettings,
useCitationDisplaySettings,
useUpdateAppearanceSettings,
useUpdateCitationDisplaySettings,
} from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
@@ -31,6 +41,28 @@ export function AppearanceSettingsPage() {
const { theme, setTheme } = useTheme();
const currentTheme = theme;
const [settings, setSettings] = useLocalSettings();
const isAdmin = getStoredAuth()?.system_role === "admin";
const { data: appearance } = useAppearanceSettings();
const updateAppearance = useUpdateAppearanceSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const updateCitationDisplay = useUpdateCitationDisplaySettings();
const globalDefault = appearance?.default_theme ?? "light";
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const handleSetGlobalDefault = async (value: "light" | "dark") => {
if (value === globalDefault) return;
try {
await updateAppearance.mutateAsync({ default_theme: value });
toast.success(
value === "light"
? "已将系统默认主题设置为浅色"
: "已将系统默认主题设置为深色",
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
const themeOptions = useMemo(
() => [
@@ -76,6 +108,43 @@ export function AppearanceSettingsPage() {
</div>
</SettingsSection>
{isAdmin && (
<>
<Separator />
<SettingsSection
title="系统默认主题(管理员)"
description="给所有还没有手动切换过主题的用户的默认主题;已经自己切换过的用户保留其个人设置不受影响。"
>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={globalDefault === "light" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("light")}
disabled={updateAppearance.isPending}
>
<SunIcon className="mr-1.5 size-4" />
浅色
</Button>
<Button
type="button"
variant={globalDefault === "dark" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetGlobalDefault("dark")}
disabled={updateAppearance.isPending}
>
<MoonIcon className="mr-1.5 size-4" />
深色
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:
{globalDefault === "light" ? "浅色" : "深色"}
</p>
</div>
</SettingsSection>
</>
)}
<Separator />
<SettingsSection
@@ -125,6 +194,44 @@ export function AppearanceSettingsPage() {
aria-label="回答速度指示器"
/>
</div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">RAG 引用模式</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
</p>
</div>
<Switch
checked={settings.display.ragModeEnabled}
onCheckedChange={(checked) =>
setSettings("display", { ragModeEnabled: checked })
}
aria-label="RAG 引用模式"
/>
</div>
{isAdmin && (
<div className="rounded-lg border p-4">
<div className="mb-3">
<div className="text-sm font-medium">参考文献去除词</div>
<p className="text-muted-foreground mt-1 max-w-2xl text-xs leading-relaxed">
管理员配置。每行一个词或短句,参考文献卡片和详情弹框会自动移除这些广告词、导航词或免责声明。
</p>
</div>
<Textarea
value={cleanupWords.join("\n")}
onChange={(event) => {
const words = event.currentTarget.value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
setSettings("display", { citationCleanupWords: words });
updateCitationDisplay.mutate({ cleanup_words: words });
}}
placeholder={"广告\n点击下载APP\n打开客户端\n免责声明\n责任编辑"}
className="min-h-32 text-sm"
/>
</div>
)}
</SettingsSection>
</div>
);
@@ -333,7 +333,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
);
}
function SkillRow({
export function SkillRow({
skill,
currentUserId,
isAdmin,
@@ -0,0 +1,480 @@
"use client";
import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
emptyDisplayConfig,
inferDisplayConfig,
normalizeDisplaySources,
} from "@/core/skill-display/display-adapter";
import {
getSkill,
getPersistedSkillDisplaySample,
getSkillDisplaySample,
} from "@/core/skills/api";
import { useSetSkillDisplay } from "@/core/skills/hooks";
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
["resultPath", "结果列表路径"],
["titleField", "标题字段"],
["summaryField", "摘要字段"],
["contentField", "正文内容字段"],
["sourceField", "来源字段"],
["authorField", "作者字段"],
["typeField", "类型字段"],
["timeField", "时间字段"],
["scoreField", "相关度字段"],
["urlField", "URL 跳转字段"],
["idField", "ID 跳转字段"],
["linkTemplate", "ID 跳转链接模板"],
];
const displayModeLabels: Record<SkillDisplayMode, string> = {
citation: "编号引用",
list: "列表展示",
table: "表格展示",
card: "卡片展示",
custom: "自定义展示",
none: "不展示",
};
const linkStrategyLabels: Record<SkillLinkStrategy, string> = {
auto: "自动判断",
url: "使用 URL 字段跳转",
id: "使用 ID 模板跳转",
};
function updateConfig<K extends keyof SkillDisplayConfig>(
config: SkillDisplayConfig,
key: K,
value: SkillDisplayConfig[K],
): SkillDisplayConfig {
return { ...config, [key]: value };
}
function formatSample(sample: Record<string, unknown> | null | undefined) {
return sample ? JSON.stringify(sample, null, 2) : "";
}
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
const queryClient = useQueryClient();
const setDisplay = useSetSkillDisplay();
const lastSkillNameRef = useRef(skill.name);
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
const [loadingDbConfig, setLoadingDbConfig] = useState(false);
// Echo server state into the form — on mount, on skill switch, AND after
// a successful save (the refetched skill object has a new `display` identity).
// Important: this drives the "保存后回显" behavior. Without depending on
// `skill.display`, any backend-side normalization (e.g. default values
// backfilled by Pydantic, column reordering) would not flow back into the UI.
useEffect(() => {
const display = skill.display ?? emptyDisplayConfig();
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
}, [skill.name, skill.display]);
useEffect(() => {
let cancelled = false;
async function loadDbSkillConfig() {
setLoadingDbConfig(true);
try {
const fresh = await getSkill(skill.name);
if (cancelled) {
return;
}
const display = fresh.display ?? emptyDisplayConfig();
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
setSampleText(formatSample(fresh.displaySample));
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], fresh);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) => (item.name === skill.name ? { ...item, ...fresh } : item))
: current,
);
} catch (err) {
if (!cancelled) {
toast.error(err instanceof Error ? err.message : String(err));
}
} finally {
if (!cancelled) {
setLoadingDbConfig(false);
}
}
}
void loadDbSkillConfig();
return () => {
cancelled = true;
};
}, [queryClient, skill.name]);
// Sample text is editor-only state — never reset on save. Only seed it once
// per skill, otherwise the user loses the JSON they just pulled in via
// 自动识别 every time they hit 保存.
useEffect(() => {
if (lastSkillNameRef.current !== skill.name) {
lastSkillNameRef.current = skill.name;
setSampleText(formatSample(skill.displaySample));
return;
}
if (skill.displaySample) {
setSampleText(formatSample(skill.displaySample));
}
}, [skill.name, skill.displaySample]);
useEffect(() => {
let cancelled = false;
async function loadPersistedSample() {
try {
const response = await getPersistedSkillDisplaySample(skill.name);
if (cancelled) {
return;
}
if (response.sample) {
setSampleText(formatSample(response.sample));
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
current ? { ...current, displaySample: response.sample } : current,
);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) =>
item.name === skill.name
? { ...item, displaySample: response.sample }
: item,
)
: current,
);
} else if (!skill.displaySample) {
setSampleText("");
}
} catch (err) {
if (!cancelled) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
}
void loadPersistedSample();
return () => {
cancelled = true;
};
}, [queryClient, skill.name, skill.displaySample]);
const samplePayload = useMemo(() => {
try {
return JSON.parse(sampleText);
} catch {
return null;
}
}, [sampleText]);
const previewItems = useMemo(() => (
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
), [draft, samplePayload]);
function generateDraft() {
if (!samplePayload) {
toast.error("样例返回不是有效的 JSON");
return;
}
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
toast.success("已生成展示适配草稿");
}
async function loadSampleAndGenerate() {
try {
const response = await getSkillDisplaySample(skill.name);
const nextSampleText = JSON.stringify(response.sample, null, 2);
setSampleText(nextSampleText);
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
current ? { ...current, displaySample: response.sample } : current,
);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) =>
item.name === skill.name
? { ...item, displaySample: response.sample }
: item,
)
: current,
);
const nextDraft = inferDisplayConfig(response.sample, skill.name);
setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
toast.success("已获取样例并生成展示适配");
} catch (err) {
generateDraft();
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function saveDraft() {
try {
const saved = await setDisplay.mutateAsync({
skillName: skill.name,
display: draft,
sample: samplePayload ?? undefined,
});
const display = saved.display ?? emptyDisplayConfig();
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
setSampleText(formatSample(saved.displaySample));
toast.success("展示适配已保存");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function clearDisplay() {
try {
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
setDraft(emptyDisplayConfig());
setTableColumnsText(JSON.stringify([], null, 2));
toast.success("展示适配已清除");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<div className="space-y-4">
<div className="rounded-lg border bg-muted/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">技能展示适配</div>
<p className="mt-1 text-xs text-muted-foreground">
从技能返回样例中自动识别字段,然后把技能结果显示为编号引用、列表、卡片或自定义样式。
</p>
{loadingDbConfig && (
<p className="mt-1 text-xs text-muted-foreground">
正在从数据库读取已保存的配置...
</p>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">启用</span>
<Switch
checked={draft.enabled}
onCheckedChange={(checked) => setDraft(updateConfig(draft, "enabled", checked))}
/>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">展示方式</span>
<Select
value={draft.mode}
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(displayModeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">跳转方式</span>
<Select
value={draft.linkStrategy}
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(linkStrategyLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">最多展示条数</span>
<Input
type="number"
min={1}
max={100}
value={draft.maxItems}
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
/>
</label>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{fieldLabels.map(([field, label]) => (
<label key={field} className="space-y-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<Input
value={(draft[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
onChange={(event) => setDraft(updateConfig(draft, field, event.currentTarget.value as never))}
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
/>
</label>
))}
</div>
{draft.mode === "table" && (
<label className="mt-4 block space-y-1.5">
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
<Textarea
value={tableColumnsText}
onChange={(event) => {
setTableColumnsText(event.currentTarget.value);
try {
const value = JSON.parse(event.currentTarget.value);
if (Array.isArray(value)) {
setDraft(updateConfig(draft, "tableColumns", value));
}
} catch {
// Keep typing invalid JSON without crashing the panel.
}
}}
placeholder={'[{"label":"任务名称","field":"title"},{"label":"状态","field":"status"}]'}
className="min-h-24 font-mono text-xs"
/>
</label>
)}
{draft.mode === "custom" && (
<label className="mt-4 block space-y-1.5">
<span className="text-xs text-muted-foreground">自定义展示描述</span>
<Textarea
value={draft.customInstruction}
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
className="min-h-24"
/>
</label>
)}
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium">技能返回样例</div>
<Button size="sm" variant="outline" onClick={() => void loadSampleAndGenerate()}>
<WandSparklesIcon className="size-3.5" />
自动识别
</Button>
</div>
<Textarea
value={sampleText}
onChange={(event) => setSampleText(event.currentTarget.value)}
className="min-h-72 font-mono text-xs"
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">展示预览</div>
<div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3">
{!samplePayload ? (
sampleText.trim() ? (
<div className="text-sm text-destructive">样例 JSON 解析失败</div>
) : (
<div className="text-sm text-muted-foreground">数据库暂无技能返回样例,请点击自动识别</div>
)
) : previewItems.length === 0 ? (
<div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div>
) : draft.mode === "citation" ? (
<>
<p className="text-sm leading-relaxed">
编号引用预览:
{previewItems.slice(0, 3).map((_item, idx) => (
<button
type="button"
key={idx}
className="mx-1 inline-flex h-6 min-w-6 items-center justify-center rounded-md border bg-background px-2 text-xs font-semibold"
>
[{idx + 1}]
</button>
))}
</p>
{previewItems.map((item, idx) => (
<PreviewItem key={idx} index={idx + 1} item={item} />
))}
</>
) : (
previewItems.map((item, idx) => <PreviewItem key={idx} index={idx + 1} item={item} />)
)}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => void clearDisplay()} disabled={setDisplay.isPending}>
清除
</Button>
<Button onClick={() => void saveDraft()} disabled={setDisplay.isPending}>
{setDisplay.isPending && <Loader2 className="size-3.5 animate-spin" />}
保存展示适配
</Button>
</div>
</div>
);
}
function PreviewItem({
index,
item,
}: {
index: number;
item: ReturnType<typeof normalizeDisplaySources>[number];
}) {
return (
<div className="rounded-md border bg-background p-3 text-xs">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">
[{index}] {item.title}
</div>
<div className="mt-1 text-muted-foreground">
{item.type}
{item.source ? ` | ${item.source}` : ""}
{item.time ? ` | ${item.time}` : ""}
{typeof item.score === "number" ? ` | ${item.score.toFixed(3)}` : ""}
</div>
</div>
{item.url && (
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="text-muted-foreground hover:text-foreground"
title="打开链接"
>
<ExternalLinkIcon className="size-4" />
</a>
)}
</div>
<p className="mt-2 line-clamp-3 leading-relaxed text-muted-foreground">
{item.snippet || item.content || "暂无摘要"}
</p>
</div>
);
}
@@ -0,0 +1,184 @@
import { ExternalLinkIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { SkillDisplayTableColumn } from "@/core/skills/type";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { cn } from "@/lib/utils";
import { SourceCard } from "./skill-display-sources";
export function SkillDisplayBlock({
mode,
sources,
className,
}: {
mode: RuntimeDisplaySource["mode"];
sources: RuntimeDisplaySource[];
className?: string;
}) {
if (sources.length === 0 || mode === "citation" || mode === "none") {
return null;
}
if (mode === "table") {
return <SkillDisplayTable sources={sources} className={className} />;
}
return (
<div className={cn("not-prose my-4 grid gap-2", className)}>
{sources.map((source) => (
<SourceCard key={`${source.skillName}-${source.index}`} source={source} />
))}
</div>
);
}
function SkillDisplayTable({
sources,
className,
}: {
sources: RuntimeDisplaySource[];
className?: string;
}) {
const [selected, setSelected] = useState<RuntimeDisplaySource | null>(null);
const columns = useMemo(() => buildColumns(sources), [sources]);
return (
<div className={cn("not-prose my-4 overflow-hidden rounded-md border bg-card", className)}>
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-sm">
<thead className="bg-muted/60 text-muted-foreground">
<tr>
{columns.map((column) => (
<th
key={column.field}
className="px-3 py-2 text-left font-medium"
style={column.width ? { width: column.width } : undefined}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{sources.map((source) => (
<tr
key={`${source.skillName}-${source.index}`}
className="cursor-pointer border-t transition-colors hover:bg-accent/40"
onClick={() => setSelected(source)}
>
{columns.map((column) => (
<td key={column.field} className="max-w-[280px] px-3 py-2 align-top">
<span className="line-clamp-2 break-words">
{formatCell(readByPath(source.raw, column.field))}
</span>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<SourceDetailDialog source={selected} onOpenChange={(open) => !open && setSelected(null)} />
</div>
);
}
function SourceDetailDialog({
source,
onOpenChange,
}: {
source: RuntimeDisplaySource | null;
onOpenChange: (open: boolean) => void;
}) {
const content = source?.content || source?.snippet;
const meta = source
? [source.source, source.author ? `作者 ${source.author}` : null, source.type, source.time].filter(Boolean)
: [];
return (
<Dialog open={!!source} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
{source && (
<>
<DialogHeader>
<div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0">
<DialogTitle className="text-base leading-relaxed">
{source.title}
</DialogTitle>
{meta.length > 0 && (
<DialogDescription className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs">
{meta.map((item, index) => (
<span key={`${item}-${index}`}>{item}</span>
))}
</DialogDescription>
)}
</div>
{source.url && (
<Button size="icon-sm" variant="outline" asChild>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
aria-label="打开来源"
>
<ExternalLinkIcon className="size-4" />
</a>
</Button>
)}
</div>
</DialogHeader>
{content && (
<div className="text-foreground max-h-[45vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
{content}
</div>
)}
<pre className="bg-muted/40 max-h-[32vh] overflow-auto rounded-md p-3 text-xs">
{JSON.stringify(source.raw, null, 2)}
</pre>
</>
)}
</DialogContent>
</Dialog>
);
}
function buildColumns(sources: RuntimeDisplaySource[]): SkillDisplayTableColumn[] {
const configured = sources[0]?.tableColumns?.filter((column) => column.field);
if (configured && configured.length > 0) {
return configured;
}
const raw = sources[0]?.raw ?? {};
return Object.keys(raw)
.slice(0, 6)
.map((field) => ({ label: field, field }));
}
function readByPath(value: unknown, path: string): unknown {
if (!path) {
return value;
}
return path.split(".").reduce<unknown>((acc, key) => {
if (acc && typeof acc === "object") {
return (acc as Record<string, unknown>)[key];
}
return undefined;
}, value);
}
function formatCell(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return JSON.stringify(value);
}
@@ -0,0 +1,242 @@
import { ExternalLinkIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import { useState } from "react";
export function SkillDisplaySources({
highlightedReferenceId,
sources,
className,
}: {
highlightedReferenceId?: string | null;
sources: RuntimeDisplaySource[];
className?: string;
}) {
if (sources.length === 0) {
return null;
}
return (
<div className={cn("not-prose grid gap-2", className)}>
{sources.map((source) => (
<SourceCard
key={`${source.skillName}-${source.index}`}
highlighted={highlightedReferenceId === String(source.index)}
source={source}
referenceTarget
/>
))}
</div>
);
}
export function SourceCard({
highlighted = false,
source,
referenceTarget = false,
}: {
highlighted?: boolean;
source: RuntimeDisplaySource;
referenceTarget?: boolean;
}) {
const [open, setOpen] = useState(false);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
const snippet = compactReferenceText(content).slice(0, 280);
const paragraphs = segmentReferenceText(content);
const score =
typeof source.score === "number"
? source.score <= 1
? `${Math.round(source.score * 100)}%`
: String(source.score)
: null;
const meta = [
source.source,
source.author ? `作者 ${source.author}` : null,
source.type && source.type !== "result" ? source.type : null,
source.time,
score ? `相关度 ${score}` : null,
].filter(Boolean);
const isCitation = source.mode === "citation";
const showReferenceNumber = isCitation;
return (
<>
<button
id={referenceTarget ? `reference-${source.index}` : undefined}
type="button"
className={cn(
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
"target:border-amber-300 target:bg-amber-50/80",
highlighted &&
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
)}
onClick={() => setOpen(true)}
>
<div className="flex items-start gap-3">
{showReferenceNumber && (
<span className="bg-primary/10 text-primary border-primary/20 group-hover:bg-primary/15 mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
{source.index}
</span>
)}
<div className="min-w-0 flex-1 space-y-1.5">
<div className="text-foreground line-clamp-2 text-sm leading-snug font-medium">
{source.title}
</div>
{meta.length > 0 && (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]">
{meta.map((item, index) => (
<span key={`${item}-${index}`} className="inline-flex items-center">
{index > 0 && <span className="mr-2 opacity-50">·</span>}
<span className="truncate">{item}</span>
</span>
))}
</div>
)}
{snippet && (
<p className="text-muted-foreground/90 line-clamp-2 text-xs leading-relaxed">
{snippet}
</p>
)}
</div>
{source.url && (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
)}
</div>
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
{showReferenceNumber && (
<span className="bg-primary/10 text-primary border-primary/20 mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
{source.index}
</span>
)}
<DialogTitle className="text-base leading-relaxed">
{source.title}
</DialogTitle>
</div>
{meta.length > 0 && (
<DialogDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
{meta.map((item, index) => (
<span key={`${item}-${index}`}>{item}</span>
))}
</DialogDescription>
)}
</div>
{source.url && (
<Button size="icon-sm" variant="outline" asChild>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
aria-label="打开来源"
>
<ExternalLinkIcon className="size-4" />
</a>
</Button>
)}
</div>
</DialogHeader>
{paragraphs.length > 0 && (
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
{paragraphs.map((paragraph, index) => (
<p key={index} className="whitespace-pre-wrap">
{paragraph}
</p>
))}
</div>
)}
{source.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{source.url}
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
function cleanReferenceText(
raw: string | undefined,
cleanupWords: string[],
): string {
let text = raw ?? "";
const words = cleanupWords.map((word) => word.trim()).filter(Boolean);
if (words.length > 0) {
text = text
.replace(/\r\n/g, "\n")
.split("\n")
.filter((line) => !words.some((word) => line.includes(word)))
.join("\n");
}
return text
.replace(/\r\n/g, "\n")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function compactReferenceText(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function segmentReferenceText(text: string): string[] {
const explicit = text
.split(/\n{2,}/)
.map((item) => item.trim())
.filter(Boolean);
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
return base.slice(0, 80);
}
function splitLongParagraph(text: string): string[] {
const normalized = text
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean)
.join("\n");
if (normalized.length <= 360) {
return normalized ? [normalized] : [];
}
const sentences = normalized
.split(/(?<=[。!?!?;;])\s*/)
.map((item) => item.trim())
.filter(Boolean);
if (sentences.length <= 1) {
return normalized.match(/[\s\S]{1,360}/g) ?? [];
}
const paragraphs: string[] = [];
let current = "";
for (const sentence of sentences) {
if ((current + sentence).length > 360 && current) {
paragraphs.push(current.trim());
current = "";
}
current += sentence;
}
if (current.trim()) {
paragraphs.push(current.trim());
}
return paragraphs;
}
@@ -4,10 +4,12 @@ import {
CopyIcon,
EyeIcon,
FileCode2Icon,
FilesIcon,
FileTextIcon,
GlobeIcon,
Loader2,
PencilIcon,
SlidersHorizontalIcon,
SparklesIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -25,12 +27,16 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
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";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import {
useSetSkillDetail,
useSkill,
useSkillContent,
useSkillFiles,
} from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown";
@@ -62,6 +68,18 @@ function canManageSkill(skill: Skill | null | undefined): boolean {
);
}
function isSkillOwner(skill: Skill | null | undefined): boolean {
if (!skill) return false;
const auth = getStoredAuth();
if (!auth) return false;
return (
skill.category === "custom" &&
!skill.builtin &&
!!skill.owner_user_id &&
skill.owner_user_id === auth.user_id
);
}
function stripYamlFrontmatter(content: string): string {
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart();
}
@@ -83,22 +101,32 @@ export function SkillDetailDialog({
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [activeTab, setActiveTab] = useState<"detail" | "source" | null>(
null,
);
const [activeTab, setActiveTab] = useState<
"detail" | "display" | "source" | "files" | null
>(null);
const [showRawSource, setShowRawSource] = useState(false);
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
const canManage = useMemo(() => canManageSkill(skill), [skill]);
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
const {
data: contentData,
isLoading: isContentLoading,
error: contentError,
} = useSkillContent(open && canManage ? skillName : null);
} = useSkillContent(open ? skillName : null);
const filesEnabled = open && isOwner && activeTab === "files";
const {
data: filesData,
isLoading: isFilesLoading,
error: filesError,
} = useSkillFiles(filesEnabled ? skillName : null);
useEffect(() => {
setEditing(false);
setActiveTab(null);
setShowRawSource(false);
setOpenedFilePath(null);
}, [open, skillName]);
const skillBody = useMemo(() => {
@@ -106,7 +134,7 @@ export function SkillDetailDialog({
}, [contentData]);
const defaultTab: "detail" | "source" =
canManage && skill && !skill.detail ? "source" : "detail";
skill && !skill.detail ? "source" : "detail";
const selectedTab = activeTab ?? defaultTab;
function startEditing() {
@@ -131,6 +159,7 @@ export function SkillDetailDialog({
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex flex-col gap-0 overflow-hidden p-0"
@@ -194,11 +223,21 @@ export function SkillDetailDialog({
{tx.detailSectionTitle}
</TabsTrigger>
{canManage && (
<TabsTrigger value="source" className="text-xs">
<FileCode2Icon className="mr-1 size-3.5" />
{tx.contentSectionTitle}
<TabsTrigger value="display" className="text-xs">
<SlidersHorizontalIcon className="mr-1 size-3.5" />
展示适配
</TabsTrigger>
)}
{isOwner && (
<TabsTrigger value="files" className="text-xs">
<FilesIcon className="mr-1 size-3.5" />
{tx.filesSectionTitle}
</TabsTrigger>
)}
<TabsTrigger value="source" className="text-xs">
<FileCode2Icon className="mr-1 size-3.5" />
{tx.contentSectionTitle}
</TabsTrigger>
</TabsList>
<div className="flex shrink-0 items-center gap-1">
@@ -214,7 +253,6 @@ export function SkillDetailDialog({
</Button>
)}
{selectedTab === "source" &&
canManage &&
!isContentLoading &&
!contentError && (
<>
@@ -299,7 +337,7 @@ export function SkillDetailDialog({
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
<p className="text-sm">{tx.detailEmpty}</p>
{canManage ? (
{isOwner ? (
<p className="mt-1 text-xs">
Open the SKILL.md tab to view the skill source.
</p>
@@ -312,10 +350,47 @@ export function SkillDetailDialog({
{canManage && (
<TabsContent
value="source"
value="display"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }}
>
<SkillDisplayAdapterPanel skill={skill} />
</TabsContent>
)}
{isOwner && (
<TabsContent
value="files"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }}
>
{isFilesLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : filesError ? (
<div className="text-destructive py-6 text-sm">
{filesError instanceof Error
? filesError.message
: String(filesError)}
</div>
) : filesData ? (
<SkillFileTree
root={filesData.root}
selectedPath={openedFilePath}
onOpen={(path) => setOpenedFilePath(path)}
emptyText={tx.filesEmpty}
/>
) : null}
</TabsContent>
)}
<TabsContent
value="source"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }}
>
{isContentLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
@@ -341,10 +416,19 @@ export function SkillDetailDialog({
/>
)}
</TabsContent>
)}
</Tabs>
)}
</DialogContent>
</Dialog>
<SkillFilePreviewDialog
skillName={skill?.name ?? null}
path={openedFilePath}
treeEditable={!!filesData?.editable}
open={!!openedFilePath}
onOpenChange={(next) => {
if (!next) setOpenedFilePath(null);
}}
/>
</>
);
}
@@ -0,0 +1,284 @@
"use client";
import {
CopyIcon,
DownloadIcon,
Loader2,
PencilIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getSkillFileDownloadURL } from "@/core/skills/api";
import {
useSaveSkillFile,
useSkillFileContent,
} from "@/core/skills/hooks";
import { useI18n } from "@/core/i18n/hooks";
import { reasoningPlugins } from "@/core/streamdown";
const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
function isImageMime(mime: string): boolean {
return mime.startsWith("image/");
}
export function SkillFilePreviewDialog({
skillName,
path,
treeEditable,
open,
onOpenChange,
}: {
skillName: string | null;
path: string | null;
/** From the tree response: whether *any* file in this skill is editable for the caller. */
treeEditable: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const tx = t.settings.skills;
const { data, isLoading, error } = useSkillFileContent(
open ? skillName : null,
open ? path : null,
);
const save = useSaveSkillFile();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
// Reset editor state whenever a different file is opened or the dialog closes.
useEffect(() => {
setEditing(false);
setDraft("");
}, [path, open]);
const fileEditable = !!data?.editable && treeEditable;
const isMarkdown = useMemo(() => {
if (!path) return false;
const lower = path.toLowerCase();
return lower.endsWith(".md") || lower.endsWith(".markdown");
}, [path]);
const dirty = editing && data?.content !== undefined && draft !== data.content;
function requestClose(next: boolean) {
if (!next && dirty) {
if (!window.confirm(tx.filesDiscardConfirm)) return;
}
onOpenChange(next);
}
function startEditing() {
if (!data || data.content == null) return;
setDraft(data.content);
setEditing(true);
}
function cancelEditing() {
if (dirty && !window.confirm(tx.filesDiscardConfirm)) return;
setEditing(false);
setDraft("");
}
async function handleSave() {
if (!skillName || !path) return;
try {
await save.mutateAsync({ skillName, path, content: draft });
toast.success(tx.filesSaveSuccess);
setEditing(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleCopyPath() {
if (!path) return;
await navigator.clipboard.writeText(path);
toast.success(tx.filesCopyPathAction);
}
const downloadHref =
skillName && path ? getSkillFileDownloadURL(skillName, path) : undefined;
return (
<Dialog open={open} onOpenChange={requestClose}>
<DialogContent
showCloseButton={false}
className="flex flex-col gap-0 overflow-hidden p-0"
style={{
width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(720px, calc(100dvh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-5 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<DialogTitle className="truncate font-mono text-sm">
{path ?? ""}
</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
{skillName}
{data ? ` · ${formatBytes(data.size)} · ${data.mime}` : null}
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
{fileEditable && !editing && data?.is_text && (
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
onClick={startEditing}
>
<PencilIcon className="size-3.5" />
{tx.filesEditAction}
</Button>
)}
{editing && (
<>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={cancelEditing}
disabled={save.isPending}
>
{tx.filesCancelEditAction}
</Button>
<Button
size="sm"
className="h-7 px-2 text-xs"
onClick={() => void handleSave()}
disabled={save.isPending || !dirty}
>
{save.isPending && <Loader2 className="size-3.5 animate-spin" />}
{tx.filesSaveAction}
</Button>
</>
)}
{!editing && (
<>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
onClick={() => void handleCopyPath()}
>
<CopyIcon className="size-3.5" />
</Button>
{downloadHref && (
<Button
asChild
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
>
<a href={downloadHref} download>
<DownloadIcon className="size-3.5" />
</a>
</Button>
)}
</>
)}
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-1.5 text-xs"
onClick={() => requestClose(false)}
>
<XIcon className="size-4" />
</Button>
</div>
</div>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : error ? (
<div className="text-destructive py-6 text-sm">
{error instanceof Error ? error.message : String(error)}
</div>
) : data == null ? null : editing ? (
<Textarea
value={draft}
onChange={(event) => setDraft(event.currentTarget.value)}
autoFocus
spellCheck={false}
className="h-full min-h-[420px] w-full resize-none font-mono text-xs leading-relaxed"
/>
) : data.is_text ? (
isMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={data.content ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre">
{data.content ?? ""}
</pre>
)
) : isImageMime(data.mime) ? (
<div className="flex items-center justify-center">
{downloadHref && (
<img
src={downloadHref}
alt={data.path}
className="max-h-full max-w-full rounded border"
/>
)}
</div>
) : (
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-12 text-center text-sm">
<p>{tx.filesPreviewBinary}</p>
{downloadHref && (
<Button asChild size="sm" variant="outline" className="mt-3">
<a href={downloadHref} download>
<DownloadIcon className="size-3.5" />
{tx.filesDownloadAction}
</a>
</Button>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -0,0 +1,152 @@
"use client";
import {
ChevronRightIcon,
FileCode2Icon,
FileIcon,
FileJsonIcon,
FileTextIcon,
FolderIcon,
FolderOpenIcon,
ImageIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import type { SkillFileNode } from "@/core/skills/api";
const CODE_EXTS = new Set([
"py", "js", "jsx", "ts", "tsx", "mjs", "cjs",
"sh", "bash", "zsh", "ps1",
"html", "htm", "css", "scss", "sass", "xml", "svg",
"sql", "ini", "toml", "cfg", "env", "yaml", "yml",
]);
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tiff"]);
function fileIconFor(name: string) {
const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
if (name === "SKILL.md") return FileTextIcon;
if (IMAGE_EXTS.has(ext)) return ImageIcon;
if (ext === "md" || ext === "markdown" || ext === "txt" || ext === "rst") return FileTextIcon;
if (ext === "json" || ext === "jsonl") return FileJsonIcon;
if (CODE_EXTS.has(ext)) return FileCode2Icon;
return FileIcon;
}
function TreeRow({
node,
depth,
selectedPath,
onOpen,
}: {
node: SkillFileNode;
depth: number;
selectedPath: string | null;
onOpen: (path: string) => void;
}) {
// Top-level (root) auto-expands; nested directories start collapsed.
const [open, setOpen] = useState(depth === 0);
if (node.type === "dir") {
const FolderGlyph = open ? FolderOpenIcon : FolderIcon;
return (
<div>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={cn(
"group hover:bg-muted/60 flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-sm",
)}
style={{ paddingLeft: 6 + depth * 14 }}
>
<ChevronRightIcon
className={cn(
"text-muted-foreground size-3.5 shrink-0 transition-transform",
open && "rotate-90",
)}
/>
<FolderGlyph className="size-4 shrink-0 text-amber-500/90" />
<span className="truncate font-medium">{node.name}</span>
</button>
{open && node.children && node.children.length > 0 && (
<div className="border-border/60 ml-[14px] border-l border-dashed">
{node.children.map((child) => (
<TreeRow
key={child.path || child.name}
node={child}
depth={depth + 1}
selectedPath={selectedPath}
onOpen={onOpen}
/>
))}
</div>
)}
</div>
);
}
const Icon = fileIconFor(node.name);
const isSelected = selectedPath === node.path;
return (
<button
type="button"
onClick={() => onOpen(node.path)}
className={cn(
"hover:bg-muted/60 flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-sm",
isSelected && "bg-primary/10 hover:bg-primary/15",
)}
style={{ paddingLeft: 6 + depth * 14 + 14 }}
title={node.path}
>
<Icon className="text-muted-foreground size-4 shrink-0" />
<span className="truncate">{node.name}</span>
{typeof node.size === "number" && (
<span className="text-muted-foreground/70 ml-auto shrink-0 text-[10px] tabular-nums">
{formatBytes(node.size)}
</span>
)}
</button>
);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export function SkillFileTree({
root,
onOpen,
selectedPath,
emptyText,
}: {
root: SkillFileNode;
onOpen: (path: string) => void;
selectedPath: string | null;
emptyText: string;
}) {
const isEmpty = useMemo(
() => !root.children || root.children.length === 0,
[root],
);
if (isEmpty) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center text-sm">
{emptyText}
</div>
);
}
return (
<div className="font-mono">
<TreeRow
node={root}
depth={0}
selectedPath={selectedPath}
onOpen={onOpen}
/>
</div>
);
}
@@ -4,6 +4,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useNavigate } from "react-router-dom";
import {
SidebarGroup,
@@ -38,6 +39,21 @@ export function WorkspaceNavChatList({
}) {
const { t } = useI18n();
const pathname = usePathname();
const navigate = useNavigate();
// 「新对话」点击时,如果已经在 /chats/new 路径,直接 navigate 会被 react-router
// 当成同路径 no-op,不更新 location.key,挂在 location.key 上的下游 hook
// (useThreadChat)就感知不到「用户想重开一个对话」,UI 不刷新。
// 这里显式拦截:同路径时用 replace + 强制生成新 location.key 的方式重置。
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) {
return;
}
event.preventDefault();
navigate("/page/workspace/chats/new", {
replace: true,
state: { resetAt: Date.now() },
});
};
return (
<>
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
@@ -49,7 +65,7 @@ export function WorkspaceNavChatList({
asChild
className={cn(NEW_CHAT_CLASS, showCollapseTrigger && "min-w-0 flex-1")}
>
<Link href="/page/workspace/chats/new">
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
<MessageSquarePlus />
<span>{t.sidebar.newChat}</span>
</Link>
@@ -141,7 +157,7 @@ export function WorkspaceNavChatList({
>
<Link href="/page/workspace/scheduled/tasks">
<ListChecks />
<span>任务管理</span>
<span>定时任务</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -149,7 +165,7 @@ export function WorkspaceNavChatList({
)}
</SidebarMenu>
</SidebarGroup>
{!showCollapseTrigger && <WorkspaceNavStudio />}
{/* {!showCollapseTrigger && <WorkspaceNavStudio />} */}
</>
);
}
@@ -2,6 +2,7 @@
import {
BellIcon,
BotIcon,
BrainIcon,
CalendarClockIcon,
ChevronsUpDown,
@@ -11,6 +12,8 @@ import {
MessageSquarePlusIcon,
Settings2Icon,
SettingsIcon,
ShieldCheckIcon,
SparklesIcon,
TagsIcon,
UsersIcon,
} from "lucide-react";
@@ -143,6 +146,16 @@ export function WorkspaceNavMenu() {
问答前缀配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/private-square")
}
>
<ShieldCheckIcon />
私密广场配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
@@ -171,6 +184,22 @@ export function WorkspaceNavMenu() {
定时任务管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/agents")}
>
<BotIcon />
智能体管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skills")}
>
<SparklesIcon />
技能管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}