From ebb6be816b6e976f61579d4c97b1971fd5a45b63 Mon Sep 17 00:00:00 2001 From: xuhd Date: Fri, 15 May 2026 15:08:56 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(frontend):=20=E6=8A=80=E8=83=BD/?= =?UTF-8?q?=E4=B8=93=E5=AE=B6=E5=B9=BF=E5=9C=BA=E5=B1=95=E7=A4=BA=E8=B0=83?= =?UTF-8?q?=E6=95=B4=20+=20=E9=80=9A=E7=9F=A5=E5=90=88=E5=B9=B6=E5=85=A5?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 技能与专家「广场」标签只展示内置和已发布项,Tab 顺序调为「我的」在前并默认选中 - 「智能体」全量更名为「专家」 - 通知由侧边栏铃铛改为「设置和更多」菜单内的弹窗入口,合并通知列表与通知设置 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workspace/agents/agent-gallery.tsx | 6 +- .../workspace/notification-bell.tsx | 212 ----------- .../workspace/notification-dialog.tsx | 329 ++++++++++++++++++ .../settings/notification-settings-page.tsx | 92 ----- .../workspace/settings/settings-dialog.tsx | 10 - .../settings/skill-settings-page.tsx | 14 +- .../workspace/workspace-nav-menu.tsx | 24 +- frontend-web/src/core/i18n/locales/en-US.ts | 11 +- frontend-web/src/core/i18n/locales/types.ts | 3 + frontend-web/src/core/i18n/locales/zh-CN.ts | 53 +-- 10 files changed, 395 insertions(+), 359 deletions(-) delete mode 100644 frontend-web/src/components/workspace/notification-bell.tsx create mode 100644 frontend-web/src/components/workspace/notification-dialog.tsx delete mode 100644 frontend-web/src/components/workspace/settings/notification-settings-page.tsx diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index 3c30fcdb..94529739 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -16,7 +16,7 @@ export function AgentGallery() { const { t } = useI18n(); const { agents, isLoading } = useAgents(); const router = useRouter(); - const [activeTab, setActiveTab] = useState<"all" | "mine">("all"); + const [activeTab, setActiveTab] = useState<"all" | "mine">("mine"); const storedAuth = useMemo(() => getStoredAuth(), []); const currentUserId = storedAuth?.user_id ?? null; @@ -84,8 +84,8 @@ export function AgentGallery() { className="flex flex-1 flex-col overflow-hidden" > - 全部 - 自己的 + 我的 + 广场 diff --git a/frontend-web/src/components/workspace/notification-bell.tsx b/frontend-web/src/components/workspace/notification-bell.tsx deleted file mode 100644 index 14310bea..00000000 --- a/frontend-web/src/components/workspace/notification-bell.tsx +++ /dev/null @@ -1,212 +0,0 @@ -"use client"; - -import { BellIcon, CheckCheckIcon, Loader2, Trash2Icon } from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; - -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - SidebarMenuButton, - useSidebar, -} from "@/components/ui/sidebar"; -import { useI18n } from "@/core/i18n/hooks"; -import { - useDeleteNotification, - useMarkAllNotificationsRead, - useMarkNotificationRead, - useNotifications, - useUnreadCount, -} from "@/core/notifications"; -import type { NotificationItem } from "@/core/notifications"; -import { formatTimeAgo } from "@/core/utils/datetime"; -import { cn } from "@/lib/utils"; - -const BELL_LIMIT = 20; - -export function NotificationBell() { - const { t } = useI18n(); - const { open: isSidebarOpen } = useSidebar(); - const [open, setOpen] = useState(false); - - // Render badge from the lightweight count endpoint so the bell stays cheap - // when nobody opens it. Only when the user actually opens the dropdown do - // we pay for the full list query. - const { data: unreadCount = 0 } = useUnreadCount(); - const { - data: notifications, - isLoading, - isFetching, - } = useNotifications({ limit: BELL_LIMIT }); - const markRead = useMarkNotificationRead(); - const markAllRead = useMarkAllNotificationsRead(); - const removeNotification = useDeleteNotification(); - - const hasUnread = unreadCount > 0; - const items = notifications ?? []; - - async function handleClickItem(notification: NotificationItem) { - if (notification.read) return; - try { - await markRead.mutateAsync(notification.id); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - } - - async function handleMarkAllRead() { - if (!hasUnread) return; - try { - await markAllRead.mutateAsync(); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - } - - async function handleDelete(notification: NotificationItem) { - try { - await removeNotification.mutateAsync(notification.id); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - } - - return ( - - - - {isSidebarOpen ? ( -
-
- - {hasUnread && ( - - {unreadCount > 99 ? "99+" : unreadCount} - - )} -
- {t.notifications.title} - {hasUnread && ( - - {t.notifications.unreadShort.replace("{count}", String(unreadCount))} - - )} -
- ) : ( -
-
- - {hasUnread && ( - - )} -
-
- )} -
-
- -
-
- - - {t.notifications.title} - - {isFetching && !isLoading && ( - - )} -
- -
- - {isLoading ? ( -
- - {t.common.loading} -
- ) : items.length === 0 ? ( -
- {t.notifications.empty} -
- ) : ( -
    - {items.map((notification) => ( -
  • void handleClickItem(notification)} - > -
    - {!notification.read && ( - - )} -
    -
    -

    - {notification.title || notification.type} -

    - {notification.created_at && ( - - {formatTimeAgo(notification.created_at)} - - )} -
    - {notification.body && ( -

    - {notification.body} -

    - )} -
    - -
    -
  • - ))} -
- )} -
-
-
- ); -} diff --git a/frontend-web/src/components/workspace/notification-dialog.tsx b/frontend-web/src/components/workspace/notification-dialog.tsx new file mode 100644 index 00000000..73d2c0db --- /dev/null +++ b/frontend-web/src/components/workspace/notification-dialog.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { + BellIcon, + CheckCheckIcon, + ChevronLeftIcon, + Loader2, + SettingsIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Switch } from "@/components/ui/switch"; +import { useI18n } from "@/core/i18n/hooks"; +import { useNotification } from "@/core/notification/hooks"; +import { + useDeleteNotification, + useMarkAllNotificationsRead, + useMarkNotificationRead, + useNotifications, + useUnreadCount, +} from "@/core/notifications"; +import type { NotificationItem } from "@/core/notifications"; +import { useLocalSettings } from "@/core/settings"; +import { formatTimeAgo } from "@/core/utils/datetime"; +import { cn } from "@/lib/utils"; + +const BELL_LIMIT = 20; + +type NotificationView = "list" | "settings"; + +export function NotificationDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useI18n(); + const [view, setView] = useState("list"); + + const { + data: notifications, + isLoading, + isFetching, + } = useNotifications({ limit: BELL_LIMIT }); + const { data: unreadCount = 0 } = useUnreadCount(); + const markRead = useMarkNotificationRead(); + const markAllRead = useMarkAllNotificationsRead(); + const removeNotification = useDeleteNotification(); + + const hasUnread = unreadCount > 0; + const items = notifications ?? []; + + function handleOpenChange(next: boolean) { + onOpenChange(next); + // 关闭弹窗时回到列表视图,下次打开默认看到通知列表。 + if (!next) setView("list"); + } + + async function handleClickItem(notification: NotificationItem) { + if (notification.read) return; + try { + await markRead.mutateAsync(notification.id); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + async function handleMarkAllRead() { + if (!hasUnread) return; + try { + await markAllRead.mutateAsync(); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + async function handleDelete(notification: NotificationItem) { + try { + await removeNotification.mutateAsync(notification.id); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + return ( + + + {view === "list" ? ( + <> +
+
+ + + {t.notifications.title} + + {isFetching && !isLoading && ( + + )} +
+
+ + + + + +
+
+ +
+ {isLoading ? ( +
+ + {t.common.loading} +
+ ) : items.length === 0 ? ( +
+
+ +
+

+ {t.notifications.empty} +

+
+ ) : ( +
    + {items.map((notification) => ( +
  • void handleClickItem(notification)} + > +
    + +
    +
    +

    + {notification.title || notification.type} +

    + {notification.created_at && ( + + {formatTimeAgo(notification.created_at)} + + )} +
    + {notification.body && ( +

    + {notification.body} +

    + )} +
    + +
    +
  • + ))} +
+ )} +
+
+ + ) : ( + setView("list")} /> + )} +
+
+ ); +} + +function NotificationSettingsView({ onBack }: { onBack: () => void }) { + const { t } = useI18n(); + const { permission, isSupported, requestPermission, showNotification } = + useNotification(); + const [settings, setSettings] = useLocalSettings(); + + const handleRequestPermission = async () => { + await requestPermission(); + }; + + const handleTestNotification = () => { + showNotification(t.settings.notification.testTitle, { + body: t.settings.notification.testBody, + }); + }; + + const handleEnableNotification = (enabled: boolean) => { + setSettings("notification", { enabled }); + }; + + return ( + <> +
+
+ + + {t.notifications.settingsTitle} + +
+ + + +
+
+ {!isSupported ? ( +

+ {t.settings.notification.notSupported} +

+ ) : ( + <> +
+

+ {t.settings.notification.description} +

+ +
+ + {permission === "default" && ( + + )} + + {permission === "denied" && ( +

+ {t.settings.notification.deniedHint} +

+ )} + + {permission === "granted" && settings.notification.enabled && ( + + )} + + )} +
+ + ); +} diff --git a/frontend-web/src/components/workspace/settings/notification-settings-page.tsx b/frontend-web/src/components/workspace/settings/notification-settings-page.tsx deleted file mode 100644 index 82b0cb8c..00000000 --- a/frontend-web/src/components/workspace/settings/notification-settings-page.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { BellIcon } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; -import { useI18n } from "@/core/i18n/hooks"; -import { useNotification } from "@/core/notification/hooks"; -import { useLocalSettings } from "@/core/settings"; - -import { SettingsSection } from "./settings-section"; - -export function NotificationSettingsPage() { - const { t } = useI18n(); - const { permission, isSupported, requestPermission, showNotification } = - useNotification(); - - const [settings, setSettings] = useLocalSettings(); - - const handleRequestPermission = async () => { - await requestPermission(); - }; - - const handleTestNotification = () => { - showNotification(t.settings.notification.testTitle, { - body: t.settings.notification.testBody, - }); - }; - - const handleEnableNotification = async (enabled: boolean) => { - setSettings("notification", { - enabled, - }); - }; - - if (!isSupported) { - return ( - -

- {t.settings.notification.notSupported} -

-
- ); - } - - return ( - -
{t.settings.notification.description}
-
- -
- - } - > -
- {permission === "default" && ( - - )} - - {permission === "denied" && ( -

- {t.settings.notification.deniedHint} -

- )} - - {permission === "granted" && settings.notification.enabled && ( -
- -
- )} -
-
- ); -} diff --git a/frontend-web/src/components/workspace/settings/settings-dialog.tsx b/frontend-web/src/components/workspace/settings/settings-dialog.tsx index 1c306e9b..c8d7debf 100644 --- a/frontend-web/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend-web/src/components/workspace/settings/settings-dialog.tsx @@ -1,7 +1,6 @@ "use client"; import { - BellIcon, BrainIcon, PaletteIcon, SparklesIcon, @@ -21,7 +20,6 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page"; import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page"; import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page"; -import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page"; import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page"; import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page"; import { useI18n } from "@/core/i18n/hooks"; @@ -33,7 +31,6 @@ type SettingsSection = | "memory" | "tools" | "skills" - | "notification" | "about"; type SettingsDialogProps = React.ComponentProps & { @@ -61,11 +58,6 @@ export function SettingsDialog(props: SettingsDialogProps) { label: t.settings.sections.appearance, icon: PaletteIcon, }, - { - id: "notification", - label: t.settings.sections.notification, - icon: BellIcon, - }, { id: "memory", label: t.settings.sections.memory, @@ -85,7 +77,6 @@ export function SettingsDialog(props: SettingsDialogProps) { t.settings.sections.memory, t.settings.sections.tools, t.settings.sections.skills, - t.settings.sections.notification, t.settings.sections.about, ], ); @@ -135,7 +126,6 @@ export function SettingsDialog(props: SettingsDialogProps) { {activeSection === "appearance" && } {activeSection === "memory" && } {activeSection === "tools" && } - {activeSection === "notification" && } {activeSection === "skills" && } {activeSection === "about" && } diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index aa32a9a0..1f3f303d 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -92,7 +92,7 @@ function SkillSettingsList({ }) { const { t } = useI18n(); const router = useRouter(); - const [filter, setFilter] = useState("all"); + const [filter, setFilter] = useState("mine"); const [installOpen, setInstallOpen] = useState(false); const [staleSheetOpen, setStaleSheetOpen] = useState(false); const [archiveSheetOpen, setArchiveSheetOpen] = useState(false); @@ -101,9 +101,9 @@ function SkillSettingsList({ const currentUserId = storedAuth?.user_id ?? null; const isAdmin = storedAuth?.system_role === "admin"; - // Visible-to-me skills, classified into the three buckets the UI cares about. - // "All" = built-in + anyone-published-custom + mine (active state only). - // "Mine" = strictly my own custom skills (active state only). + // 把可见技能分类成 UI 关心的几个桶。 + // "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。 + // "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。 const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => { const all: Skill[] = []; const mine: Skill[] = []; @@ -115,8 +115,6 @@ function SkillSettingsList({ const isOwnedByMe = isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId; const isBuiltin = skill.builtin ?? !skill.owner_user_id; - const isPublishedByOthers = - isCustom && !isOwnedByMe && !isBuiltin && !!skill.published; if (state === "stale") { stale.push(skill); @@ -127,7 +125,7 @@ function SkillSettingsList({ continue; } // state === active - if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) { + if (isBuiltin || !!skill.published) { all.push(skill); } if (isOwnedByMe) { @@ -156,8 +154,8 @@ function SkillSettingsList({
- {t.settings.skills.tabAll} {t.settings.skills.tabMine} + {t.settings.skills.tabAll}
From 6981fc06cacde5d91d5b9bee60ce1ef63d943e52 Mon Sep 17 00:00:00 2001 From: xuhd Date: Fri, 15 May 2026 15:23:34 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(skills):=20=E7=82=B9=E5=87=BB=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E5=B1=95=E7=A4=BA=20SKILL.md=20=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 GET /api/skills/{name}/content 接口,返回公共/自定义技能的 SKILL.md 原文 - 前端技能行内容区可点击,弹窗以等宽原文展示 SKILL.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../settings/skill-settings-page.tsx | 52 ++++++++++++++++++- frontend-web/src/core/skills/api.ts | 18 +++++++ frontend-web/src/core/skills/hooks.ts | 9 ++++ .../backend/app/gateway/routers/skills.py | 30 +++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index 1f3f303d..9abe28f2 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -54,6 +54,7 @@ import { useInstallSkillUpload, useSetSkillPinned, useSetSkillPublished, + useSkillContent, useSkills, useValidateSkillUpload, } from "@/core/skills/hooks"; @@ -256,6 +257,7 @@ function SkillRow({ const setPinned = useSetSkillPinned(); const deleteSkill = useDeleteCustomSkill(); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [detailOpen, setDetailOpen] = useState(false); const isCustom = skill.category === "custom"; // Built-in flag falls back to "no DB owner" so legacy responses without @@ -306,7 +308,10 @@ function SkillRow({ return ( <> - + setDetailOpen(true)} + >
{skill.name} @@ -471,10 +476,55 @@ function SkillRow({ + ); } +function SkillDetailDialog({ + skill, + open, + onOpenChange, +}: { + skill: Skill; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useI18n(); + const { data, isLoading, error } = useSkillContent(open ? skill.name : null); + + return ( + + + + {skill.name} + {skill.description} + +
+ {isLoading ? ( +
+ + {t.common.loading} +
+ ) : error ? ( +
+ {error instanceof Error ? error.message : String(error)} +
+ ) : ( +
+              {data?.content}
+            
+ )} +
+
+
+ ); +} + function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) { const { t } = useI18n(); return ( diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts index e4b953e0..4991425e 100644 --- a/frontend-web/src/core/skills/api.ts +++ b/frontend-web/src/core/skills/api.ts @@ -174,6 +174,24 @@ export async function setSkillPinned( return response.json() as Promise; } +export interface SkillContentResponse { + name: string; + category: string; + content: string; +} + +export async function getSkillContent( + skillName: string, +): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/content`, + ); + if (!response.ok) { + throw await parseDetailedError(response, "Failed to load skill content"); + } + return response.json() as Promise; +} + export async function deleteCustomSkill(skillName: string): Promise { const response = await apiFetch( `${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`, diff --git a/frontend-web/src/core/skills/hooks.ts b/frontend-web/src/core/skills/hooks.ts index bb341b67..68caef26 100644 --- a/frontend-web/src/core/skills/hooks.ts +++ b/frontend-web/src/core/skills/hooks.ts @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { deleteCustomSkill, enableSkill, + getSkillContent, installSkillUpload, setSkillPinned, setSkillPublished, @@ -21,6 +22,14 @@ export function useSkills() { return { skills: data ?? [], isLoading, error }; } +export function useSkillContent(skillName: string | null) { + return useQuery({ + queryKey: ["skill-content", skillName], + queryFn: () => getSkillContent(skillName!), + enabled: !!skillName, + }); +} + export function useEnableSkill() { const queryClient = useQueryClient(); return useMutation({ diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py index f4ccda17..5159d040 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skills.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py @@ -816,6 +816,36 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}") +class SkillContentResponse(BaseModel): + """SKILL.md 原文内容,适用于公共和自定义技能。""" + + name: str + category: SkillCategory + content: str + + +@router.get( + "/skills/{skill_name}/content", + response_model=SkillContentResponse, + summary="Get Skill Markdown Content", + description="Retrieve the raw SKILL.md content of a specific skill (public or custom).", +) +async def get_skill_content(skill_name: str, config: AppConfig = Depends(get_config)) -> SkillContentResponse: + try: + skill_name = skill_name.replace("\r\n", "").replace("\n", "") + skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) + skill = next((s for s in skills if s.name == skill_name), None) + if skill is None: + raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") + content = skill.skill_file.read_text(encoding="utf-8") + return SkillContentResponse(name=skill.name, category=skill.category, content=content) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to get skill content %s: %s", skill_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to get skill content: {str(e)}") + + @router.put( "/skills/{skill_name}", response_model=SkillResponse,