diff --git a/.gitignore b/.gitignore index f6b1fff3..ba42257b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,10 @@ frontend-web/.vscode/ # Claude Code local-only settings (per-user permissions, not shared) .claude/settings.local.json -# Local runtime logs and pid files -.runtime/ -logs/ -offline-backend-20260512/backend/logs/ +# Local runtime logs and pid files +.runtime/ +logs/ +offline-backend-20260512/backend/logs/ # Frontend local/runtime artifacts frontend-web/.env @@ -83,3 +83,6 @@ hermes-agent-main/ offline-backend-20260512/backend/tests/* deploy .history + +# Frontend skills components must be tracked (excluded from the broad skills/ ignore above) +!frontend-web/src/components/workspace/skills/ diff --git a/frontend-web/.env.development b/frontend-web/.env.development index 0d15230d..41decf09 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -7,6 +7,20 @@ VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 - +# CMZS后端接口 +VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm +# 佳来模型对话接口 +VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt +# 多轮检索接口 +VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat +VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat +VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary +VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web +VITE_AIPI_B2ACTOR_URL=htttps://ty.b.uat.4.cn +VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope +VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn +VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer +VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn +VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn # 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL) # VITE_CANVAS_LANGGRAPH_BASE_URL= diff --git a/frontend-web/.env.development.local.example b/frontend-web/.env.development.local.example index 3f551f71..471b2649 100644 --- a/frontend-web/.env.development.local.example +++ b/frontend-web/.env.development.local.example @@ -3,5 +3,18 @@ VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api +# 佳来模型对话接口 +VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt +# 多轮检索接口 +VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat +VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat +VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary +VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web +VITE_AIPI_B2ACTOR_URL=htttps://ty.b.uat.4.cn +VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope +VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn +VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer +VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn +VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn # VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000 diff --git a/frontend-web/.env.production b/frontend-web/.env.production index 1d13e37e..0e94e082 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -7,6 +7,19 @@ VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api +# 佳来模型对话接口 +VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt +# 多轮检索接口 +VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat +VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat +VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary +VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web +VITE_AIPI_B2ACTOR_URL=htttps://ty.b.uat.4.cn +VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope +VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn +VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer +VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn +VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn # 可选:Canvas 独立 LangGraph 地址 # VITE_CANVAS_LANGGRAPH_BASE_URL= diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css new file mode 100644 index 00000000..61890fb3 --- /dev/null +++ b/frontend-web/src/components/page-sdebar.css @@ -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; +} \ No newline at end of file diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx new file mode 100644 index 00000000..251e1d82 --- /dev/null +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -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>(defaultExpandedIds); + const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsDefaultSection, setSettingsDefaultSection] = + useState("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 ( + <> + + + {/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */} +
+
+
+ 问答管理 +
+
+
+ + ); +} diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 704ebd87..9d4d7d1a 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -9,6 +9,7 @@ import { ShieldIcon, SparklesIcon, SquarePenIcon, + StarIcon, TagsIcon, Trash2Icon, } from "lucide-react"; @@ -41,9 +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"; @@ -64,6 +67,7 @@ export function AgentCard({ agent }: AgentCardProps) { 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); @@ -78,6 +82,13 @@ export function AgentCard({ agent }: AgentCardProps) { ); const [detailSkillName, setDetailSkillName] = useState(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"; @@ -91,6 +102,10 @@ export function AgentCard({ agent }: AgentCardProps) { // everyone so toggling published is moot. 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) { @@ -98,7 +113,6 @@ export function AgentCard({ agent }: AgentCardProps) { } setEditName(agent.name); setEditDescription(agent.description ?? ""); - setEditSoul(agent.soul ?? ""); setEditPublished(agent.published); setEditIsPrivate(agent.is_private); setSelectedSkills(agent.skills ?? []); @@ -108,10 +122,23 @@ export function AgentCard({ agent }: AgentCardProps) { 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`); } @@ -197,6 +224,16 @@ export function AgentCard({ agent }: AgentCardProps) { } } + 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 @@ -347,8 +384,25 @@ export function AgentCard({ agent }: AgentCardProps) { )} - {(canEdit || canDelete || isOwned || isAdmin) && ( + {(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
+ {canFavorite && ( + + )} {isAdmin && (
diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index 10001772..5d1f04cc 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -89,12 +89,15 @@ export function AgentGallery() { [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 && + ((a.user_id !== null && a.user_id === currentUserId) || + a.is_favorite) && matchesSearch(a), ), // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend-web/src/components/workspace/artifacts/context.tsx b/frontend-web/src/components/workspace/artifacts/context.tsx index e15481af..f864d965 100644 --- a/frontend-web/src/components/workspace/artifacts/context.tsx +++ b/frontend-web/src/components/workspace/artifacts/context.tsx @@ -32,7 +32,10 @@ interface ArtifactsProviderProps { } export function ArtifactsProvider({ children }: ArtifactsProviderProps) { - const [artifacts, setArtifacts] = useState([]); + const [artifacts, setArtifactsState] = useState([]); + const setArtifacts = useCallback((next: string[]) => { + setArtifactsState(Array.isArray(next) ? next : []); + }, []); const [selectedArtifact, setSelectedArtifact] = useState(null); const [autoSelect, setAutoSelect] = useState(true); const [open, setOpen] = useState( diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index 20b6e434..9af9b39d 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -16,6 +16,7 @@ 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, @@ -147,16 +148,30 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ }); return () => controller.abort(); }, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]); - const referenceBatches = - backendReferenceBatches.length > 0 - ? backendReferenceBatches - : fallbackReferenceBatches; + 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), }); @@ -168,12 +183,17 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ 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), }); - }, [backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]); + }, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]); const latestReferenceBatch = referenceBatches.length > 0 ? referenceBatches[referenceBatches.length - 1]! @@ -183,6 +203,17 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ 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); @@ -487,6 +518,155 @@ function ReferencePanel({ ); } +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) + : {}; + 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, +): 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, +): BackendReferenceSource | null { + const raw = source.raw; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return null; + } + const rawItem = raw as Record; + 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, +) { + 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[]) { + const parts = path.split(".").map((part) => part.trim()).filter(Boolean); + if (parts.length === 0) { + return items; + } + const root: Record = {}; + 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 = {}; + cursor[part] = next; + cursor = next; + } + } + return root; +} + function findPreviousHumanMessageId( messages: Message[], beforeMessageId?: string, @@ -530,8 +710,7 @@ function buildTurnReferenceBatches( beforeMessageId: nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id, includeBeforeMessage: false, - includeModes: ["citation"], - }).filter((source) => source.mode === "citation"); + }).filter((source) => source.mode !== "none"); if (sources.length > 0) { batches.push({ id: assistant.id, @@ -562,8 +741,7 @@ function findLastAssistantAnswer( if ( message?.type === "ai" && message.id && - hasAssistantAnswerContent(message) && - !hasAssistantToolCalls(message) + hasAssistantAnswerContent(message) ) { return message; } @@ -588,18 +766,4 @@ function hasAssistantAnswerContent(message: Message): boolean { }); } -function hasAssistantToolCalls(message: Message): boolean { - const direct = (message as { tool_calls?: unknown }).tool_calls; - if (Array.isArray(direct) && direct.length > 0) { - return true; - } - const chunks = (message as { tool_call_chunks?: unknown }).tool_call_chunks; - if (Array.isArray(chunks) && chunks.length > 0) { - return true; - } - const kwargs = (message as { additional_kwargs?: Record }).additional_kwargs; - const extra = kwargs?.tool_calls; - return Array.isArray(extra) && extra.length > 0; -} - export { ChatBox }; diff --git a/frontend-web/src/components/workspace/custom-prompt-menu.tsx b/frontend-web/src/components/workspace/custom-prompt-menu.tsx new file mode 100644 index 00000000..e7d88948 --- /dev/null +++ b/frontend-web/src/components/workspace/custom-prompt-menu.tsx @@ -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(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 ( + <> +
+ {prompts.length > 0 && ( + <> + {overflowing && ( + + )} +
+
+ {prompts.map((prompt) => ( + + ))} +
+
+ {overflowing && ( + + )} + + )} + {prompts.length === 0 &&
} + +
+ + + + ); +} + +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(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 ( + { + if (!next) resetForm(); + onOpenChange(next); + }} + > + + + 管理常用提示词 + + 保存常用的提示词,保存后会以小标签形式显示在输入框上方,点击即可填入。提示词与你的账号绑定,可跨设备使用。 + + + +
+ setTitle(e.target.value)} + placeholder="名称(可选,例如:周报总结)" + /> +